Line data Source code
1 : /*
2 : * Famedly Matrix SDK
3 : * Copyright (C) 2019, 2020, 2021 Famedly GmbH
4 : *
5 : * This program is free software: you can redistribute it and/or modify
6 : * it under the terms of the GNU Affero General Public License as
7 : * published by the Free Software Foundation, either version 3 of the
8 : * License, or (at your option) any later version.
9 : *
10 : * This program is distributed in the hope that it will be useful,
11 : * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 : * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 : * GNU Affero General Public License for more details.
14 : *
15 : * You should have received a copy of the GNU Affero General Public License
16 : * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 : */
18 :
19 : import 'dart:async';
20 : import 'dart:convert';
21 : import 'dart:core';
22 : import 'dart:math';
23 : import 'dart:typed_data';
24 :
25 : import 'package:async/async.dart';
26 : import 'package:collection/collection.dart' show IterableExtension;
27 : import 'package:http/http.dart' as http;
28 : import 'package:mime/mime.dart';
29 : import 'package:olm/olm.dart' as olm;
30 : import 'package:random_string/random_string.dart';
31 :
32 : import 'package:matrix/encryption.dart';
33 : import 'package:matrix/matrix.dart';
34 : import 'package:matrix/msc_extensions/msc_unpublished_custom_refresh_token_lifetime/msc_unpublished_custom_refresh_token_lifetime.dart';
35 : import 'package:matrix/src/models/timeline_chunk.dart';
36 : import 'package:matrix/src/utils/cached_stream_controller.dart';
37 : import 'package:matrix/src/utils/client_init_exception.dart';
38 : import 'package:matrix/src/utils/compute_callback.dart';
39 : import 'package:matrix/src/utils/multilock.dart';
40 : import 'package:matrix/src/utils/run_benchmarked.dart';
41 : import 'package:matrix/src/utils/run_in_root.dart';
42 : import 'package:matrix/src/utils/sync_update_item_count.dart';
43 : import 'package:matrix/src/utils/try_get_push_rule.dart';
44 :
45 : typedef RoomSorter = int Function(Room a, Room b);
46 :
47 : enum LoginState { loggedIn, loggedOut, softLoggedOut }
48 :
49 : extension TrailingSlash on Uri {
50 99 : Uri stripTrailingSlash() => path.endsWith('/')
51 0 : ? replace(path: path.substring(0, path.length - 1))
52 : : this;
53 : }
54 :
55 : /// Represents a Matrix client to communicate with a
56 : /// [Matrix](https://matrix.org) homeserver and is the entry point for this
57 : /// SDK.
58 : class Client extends MatrixApi {
59 : int? _id;
60 :
61 : // Keeps track of the currently ongoing syncRequest
62 : // in case we want to cancel it.
63 : int _currentSyncId = -1;
64 :
65 58 : int? get id => _id;
66 :
67 : final FutureOr<DatabaseApi> Function(Client)? databaseBuilder;
68 : final FutureOr<DatabaseApi> Function(Client)? legacyDatabaseBuilder;
69 : DatabaseApi? _database;
70 :
71 66 : DatabaseApi? get database => _database;
72 :
73 62 : Encryption? get encryption => _encryption;
74 : Encryption? _encryption;
75 :
76 : Set<KeyVerificationMethod> verificationMethods;
77 :
78 : Set<String> importantStateEvents;
79 :
80 : Set<String> roomPreviewLastEvents;
81 :
82 : Set<String> supportedLoginTypes;
83 :
84 : bool requestHistoryOnLimitedTimeline;
85 :
86 : final bool formatLocalpart;
87 :
88 : final bool mxidLocalPartFallback;
89 :
90 : bool shareKeysWithUnverifiedDevices;
91 :
92 : Future<void> Function(Client client)? onSoftLogout;
93 :
94 62 : DateTime? get accessTokenExpiresAt => _accessTokenExpiresAt;
95 : DateTime? _accessTokenExpiresAt;
96 :
97 : // For CommandsClientExtension
98 : final Map<String, FutureOr<String?> Function(CommandArgs)> commands = {};
99 : final Filter syncFilter;
100 :
101 : final NativeImplementations nativeImplementations;
102 :
103 : String? _syncFilterId;
104 :
105 62 : String? get syncFilterId => _syncFilterId;
106 :
107 : final ComputeCallback? compute;
108 :
109 0 : @Deprecated('Use [nativeImplementations] instead')
110 : Future<T> runInBackground<T, U>(
111 : FutureOr<T> Function(U arg) function, U arg) async {
112 0 : final compute = this.compute;
113 : if (compute != null) {
114 0 : return await compute(function, arg);
115 : }
116 0 : return await function(arg);
117 : }
118 :
119 : final Duration sendTimelineEventTimeout;
120 :
121 : /// The timeout until a typing indicator gets removed automatically.
122 : final Duration typingIndicatorTimeout;
123 :
124 : Future<MatrixImageFileResizedResponse?> Function(
125 : MatrixImageFileResizeArguments)? customImageResizer;
126 :
127 : /// Create a client
128 : /// [clientName] = unique identifier of this client
129 : /// [databaseBuilder]: A function that creates the database instance, that will be used.
130 : /// [legacyDatabaseBuilder]: Use this for your old database implementation to perform an automatic migration
131 : /// [databaseDestroyer]: A function that can be used to destroy a database instance, for example by deleting files from disk.
132 : /// [verificationMethods]: A set of all the verification methods this client can handle. Includes:
133 : /// KeyVerificationMethod.numbers: Compare numbers. Most basic, should be supported
134 : /// KeyVerificationMethod.emoji: Compare emojis
135 : /// [importantStateEvents]: A set of all the important state events to load when the client connects.
136 : /// To speed up performance only a set of state events is loaded on startup, those that are
137 : /// needed to display a room list. All the remaining state events are automatically post-loaded
138 : /// when opening the timeline of a room or manually by calling `room.postLoad()`.
139 : /// This set will always include the following state events:
140 : /// - m.room.name
141 : /// - m.room.avatar
142 : /// - m.room.message
143 : /// - m.room.encrypted
144 : /// - m.room.encryption
145 : /// - m.room.canonical_alias
146 : /// - m.room.tombstone
147 : /// - *some* m.room.member events, where needed
148 : /// [roomPreviewLastEvents]: The event types that should be used to calculate the last event
149 : /// in a room for the room list.
150 : /// Set [requestHistoryOnLimitedTimeline] to controll the automatic behaviour if the client
151 : /// receives a limited timeline flag for a room.
152 : /// If [mxidLocalPartFallback] is true, then the local part of the mxid will be shown
153 : /// if there is no other displayname available. If not then this will return "Unknown user".
154 : /// If [formatLocalpart] is true, then the localpart of an mxid will
155 : /// be formatted in the way, that all "_" characters are becomming white spaces and
156 : /// the first character of each word becomes uppercase.
157 : /// If your client supports more login types like login with token or SSO, then add this to
158 : /// [supportedLoginTypes]. Set a custom [syncFilter] if you like. By default the app
159 : /// will use lazy_load_members.
160 : /// Set [nativeImplementations] to [NativeImplementationsIsolate] in order to
161 : /// enable the SDK to compute some code in background.
162 : /// Set [timelineEventTimeout] to the preferred time the Client should retry
163 : /// sending events on connection problems or to `Duration.zero` to disable it.
164 : /// Set [customImageResizer] to your own implementation for a more advanced
165 : /// and faster image resizing experience.
166 : /// Set [enableDehydratedDevices] to enable experimental support for enabling MSC3814 dehydrated devices.
167 38 : Client(
168 : this.clientName, {
169 : this.databaseBuilder,
170 : this.legacyDatabaseBuilder,
171 : Set<KeyVerificationMethod>? verificationMethods,
172 : http.Client? httpClient,
173 : Set<String>? importantStateEvents,
174 :
175 : /// You probably don't want to add state events which are also
176 : /// in important state events to this list, or get ready to face
177 : /// only having one event of that particular type in preLoad because
178 : /// previewEvents are stored with stateKey '' not the actual state key
179 : /// of your state event
180 : Set<String>? roomPreviewLastEvents,
181 : this.pinUnreadRooms = false,
182 : this.pinInvitedRooms = true,
183 : @Deprecated('Use [sendTimelineEventTimeout] instead.')
184 : int? sendMessageTimeoutSeconds,
185 : this.requestHistoryOnLimitedTimeline = false,
186 : Set<String>? supportedLoginTypes,
187 : this.mxidLocalPartFallback = true,
188 : this.formatLocalpart = true,
189 : @Deprecated('Use [nativeImplementations] instead') this.compute,
190 : NativeImplementations nativeImplementations = NativeImplementations.dummy,
191 : Level? logLevel,
192 : Filter? syncFilter,
193 : Duration defaultNetworkRequestTimeout = const Duration(seconds: 35),
194 : this.sendTimelineEventTimeout = const Duration(minutes: 1),
195 : this.customImageResizer,
196 : this.shareKeysWithUnverifiedDevices = true,
197 : this.enableDehydratedDevices = false,
198 : this.receiptsPublicByDefault = true,
199 :
200 : /// Implement your https://spec.matrix.org/v1.9/client-server-api/#soft-logout
201 : /// logic here.
202 : /// Set this to `refreshAccessToken()` for the easiest way to handle the
203 : /// most common reason for soft logouts.
204 : /// You can also perform a new login here by passing the existing deviceId.
205 : this.onSoftLogout,
206 :
207 : /// Experimental feature which allows to send a custom refresh token
208 : /// lifetime to the server which overrides the default one. Needs server
209 : /// support.
210 : this.customRefreshTokenLifetime,
211 : this.typingIndicatorTimeout = const Duration(seconds: 30),
212 : }) : syncFilter = syncFilter ??
213 38 : Filter(
214 38 : room: RoomFilter(
215 38 : state: StateFilter(lazyLoadMembers: true),
216 : ),
217 : ),
218 : importantStateEvents = importantStateEvents ??= {},
219 : roomPreviewLastEvents = roomPreviewLastEvents ??= {},
220 : supportedLoginTypes =
221 38 : supportedLoginTypes ?? {AuthenticationTypes.password},
222 : verificationMethods = verificationMethods ?? <KeyVerificationMethod>{},
223 : nativeImplementations = compute != null
224 0 : ? NativeImplementationsIsolate(compute)
225 : : nativeImplementations,
226 38 : super(
227 38 : httpClient: FixedTimeoutHttpClient(
228 5 : httpClient ?? http.Client(), defaultNetworkRequestTimeout)) {
229 0 : if (logLevel != null) Logs().level = logLevel;
230 76 : importantStateEvents.addAll([
231 : EventTypes.RoomName,
232 : EventTypes.RoomAvatar,
233 : EventTypes.Encryption,
234 : EventTypes.RoomCanonicalAlias,
235 : EventTypes.RoomTombstone,
236 : EventTypes.SpaceChild,
237 : EventTypes.SpaceParent,
238 : EventTypes.RoomCreate,
239 : ]);
240 76 : roomPreviewLastEvents.addAll([
241 : EventTypes.Message,
242 : EventTypes.Encrypted,
243 : EventTypes.Sticker,
244 : EventTypes.CallInvite,
245 : EventTypes.CallAnswer,
246 : EventTypes.CallReject,
247 : EventTypes.CallHangup,
248 : EventTypes.GroupCallMember,
249 : ]);
250 :
251 : // register all the default commands
252 38 : registerDefaultCommands();
253 : }
254 :
255 : Duration? customRefreshTokenLifetime;
256 :
257 : /// Fetches the refreshToken from the database and tries to get a new
258 : /// access token from the server and then stores it correctly. Unlike the
259 : /// pure API call of `Client.refresh()` this handles the complete soft
260 : /// logout case.
261 : /// Throws an Exception if there is no refresh token available or the
262 : /// client is not logged in.
263 1 : Future<void> refreshAccessToken() async {
264 3 : final storedClient = await database?.getClient(clientName);
265 1 : final refreshToken = storedClient?.tryGet<String>('refresh_token');
266 : if (refreshToken == null) {
267 0 : throw Exception('No refresh token available');
268 : }
269 2 : final homeserverUrl = homeserver?.toString();
270 1 : final userId = userID;
271 1 : final deviceId = deviceID;
272 : if (homeserverUrl == null || userId == null || deviceId == null) {
273 0 : throw Exception('Cannot refresh access token when not logged in');
274 : }
275 :
276 1 : final tokenResponse = await refreshWithCustomRefreshTokenLifetime(
277 : refreshToken,
278 1 : refreshTokenLifetimeMs: customRefreshTokenLifetime?.inMilliseconds,
279 : );
280 :
281 2 : accessToken = tokenResponse.accessToken;
282 1 : final expiresInMs = tokenResponse.expiresInMs;
283 : final tokenExpiresAt = expiresInMs == null
284 : ? null
285 3 : : DateTime.now().add(Duration(milliseconds: expiresInMs));
286 1 : _accessTokenExpiresAt = tokenExpiresAt;
287 2 : await database?.updateClient(
288 : homeserverUrl,
289 1 : tokenResponse.accessToken,
290 : tokenExpiresAt,
291 1 : tokenResponse.refreshToken,
292 : userId,
293 : deviceId,
294 1 : deviceName,
295 1 : prevBatch,
296 2 : encryption?.pickledOlmAccount,
297 : );
298 : }
299 :
300 : /// The required name for this client.
301 : final String clientName;
302 :
303 : /// The Matrix ID of the current logged user.
304 64 : String? get userID => _userID;
305 : String? _userID;
306 :
307 : /// This points to the position in the synchronization history.
308 62 : String? get prevBatch => _prevBatch;
309 : String? _prevBatch;
310 :
311 : /// The device ID is an unique identifier for this device.
312 60 : String? get deviceID => _deviceID;
313 : String? _deviceID;
314 :
315 : /// The device name is a human readable identifier for this device.
316 2 : String? get deviceName => _deviceName;
317 : String? _deviceName;
318 :
319 : // for group calls
320 : // A unique identifier used for resolving duplicate group call
321 : // sessions from a given device. When the session_id field changes from
322 : // an incoming m.call.member event, any existing calls from this device in
323 : // this call should be terminated. The id is generated once per client load.
324 0 : String? get groupCallSessionId => _groupCallSessionId;
325 : String? _groupCallSessionId;
326 :
327 : /// Returns the current login state.
328 0 : @Deprecated('Use [onLoginStateChanged.value] instead')
329 : LoginState get loginState =>
330 0 : onLoginStateChanged.value ?? LoginState.loggedOut;
331 :
332 62 : bool isLogged() => accessToken != null;
333 :
334 : /// A list of all rooms the user is participating or invited.
335 68 : List<Room> get rooms => _rooms;
336 : List<Room> _rooms = [];
337 :
338 : /// Get a list of the archived rooms
339 : ///
340 : /// Attention! Archived rooms are only returned if [loadArchive()] was called
341 : /// beforehand! The state refers to the last retrieval via [loadArchive()]!
342 2 : List<ArchivedRoom> get archivedRooms => _archivedRooms;
343 :
344 : bool enableDehydratedDevices = false;
345 :
346 : /// Whether read receipts are sent as public receipts by default or just as private receipts.
347 : bool receiptsPublicByDefault = true;
348 :
349 : /// Whether this client supports end-to-end encryption using olm.
350 117 : bool get encryptionEnabled => encryption?.enabled == true;
351 :
352 : /// Whether this client is able to encrypt and decrypt files.
353 0 : bool get fileEncryptionEnabled => encryptionEnabled;
354 :
355 18 : String get identityKey => encryption?.identityKey ?? '';
356 :
357 81 : String get fingerprintKey => encryption?.fingerprintKey ?? '';
358 :
359 : /// Whether this session is unknown to others
360 24 : bool get isUnknownSession =>
361 132 : userDeviceKeys[userID]?.deviceKeys[deviceID]?.signed != true;
362 :
363 : /// Warning! This endpoint is for testing only!
364 0 : set rooms(List<Room> newList) {
365 0 : Logs().w('Warning! This endpoint is for testing only!');
366 0 : _rooms = newList;
367 : }
368 :
369 : /// Key/Value store of account data.
370 : Map<String, BasicEvent> _accountData = {};
371 :
372 62 : Map<String, BasicEvent> get accountData => _accountData;
373 :
374 : /// Evaluate if an event should notify quickly
375 0 : PushruleEvaluator get pushruleEvaluator =>
376 0 : _pushruleEvaluator ?? PushruleEvaluator.fromRuleset(PushRuleSet());
377 : PushruleEvaluator? _pushruleEvaluator;
378 :
379 31 : void _updatePushrules() {
380 31 : final ruleset = TryGetPushRule.tryFromJson(
381 62 : _accountData[EventTypes.PushRules]
382 31 : ?.content
383 31 : .tryGetMap<String, Object?>('global') ??
384 29 : {});
385 62 : _pushruleEvaluator = PushruleEvaluator.fromRuleset(ruleset);
386 : }
387 :
388 : /// Presences of users by a given matrix ID
389 : @Deprecated('Use `fetchCurrentPresence(userId)` instead.')
390 : Map<String, CachedPresence> presences = {};
391 :
392 : int _transactionCounter = 0;
393 :
394 13 : String generateUniqueTransactionId() {
395 26 : _transactionCounter++;
396 65 : return '$clientName-$_transactionCounter-${DateTime.now().millisecondsSinceEpoch}';
397 : }
398 :
399 1 : Room? getRoomByAlias(String alias) {
400 2 : for (final room in rooms) {
401 2 : if (room.canonicalAlias == alias) return room;
402 : }
403 : return null;
404 : }
405 :
406 : /// Searches in the local cache for the given room and returns null if not
407 : /// found. If you have loaded the [loadArchive()] before, it can also return
408 : /// archived rooms.
409 32 : Room? getRoomById(String id) {
410 161 : for (final room in <Room>[...rooms, ..._archivedRooms.map((e) => e.room)]) {
411 58 : if (room.id == id) return room;
412 : }
413 :
414 : return null;
415 : }
416 :
417 32 : Map<String, dynamic> get directChats =>
418 113 : _accountData['m.direct']?.content ?? {};
419 :
420 : /// Returns the (first) room ID from the store which is a private chat with the user [userId].
421 : /// Returns null if there is none.
422 6 : String? getDirectChatFromUserId(String userId) {
423 24 : final directChats = _accountData['m.direct']?.content[userId];
424 7 : if (directChats is List<dynamic> && directChats.isNotEmpty) {
425 : final potentialRooms = directChats
426 1 : .cast<String>()
427 2 : .map(getRoomById)
428 4 : .where((room) => room != null && room.membership == Membership.join);
429 1 : if (potentialRooms.isNotEmpty) {
430 2 : return potentialRooms.fold<Room>(potentialRooms.first!,
431 1 : (Room prev, Room? r) {
432 : if (r == null) {
433 : return prev;
434 : }
435 2 : final prevLast = prev.lastEvent?.originServerTs ?? DateTime(0);
436 2 : final rLast = r.lastEvent?.originServerTs ?? DateTime(0);
437 :
438 1 : return rLast.isAfter(prevLast) ? r : prev;
439 1 : }).id;
440 : }
441 : }
442 10 : for (final room in rooms) {
443 8 : if (room.membership == Membership.invite &&
444 12 : room.getState(EventTypes.RoomMember, userID!)?.senderId == userId &&
445 0 : room.getState(EventTypes.RoomMember, userID!)?.content['is_direct'] ==
446 : true) {
447 0 : return room.id;
448 : }
449 : }
450 : return null;
451 : }
452 :
453 : /// Gets discovery information about the domain. The file may include additional keys.
454 0 : Future<DiscoveryInformation> getDiscoveryInformationsByUserId(
455 : String MatrixIdOrDomain,
456 : ) async {
457 : try {
458 0 : final response = await httpClient.get(Uri.https(
459 0 : MatrixIdOrDomain.domain ?? '', '/.well-known/matrix/client'));
460 0 : var respBody = response.body;
461 : try {
462 0 : respBody = utf8.decode(response.bodyBytes);
463 : } catch (_) {
464 : // No-OP
465 : }
466 0 : final rawJson = json.decode(respBody);
467 0 : return DiscoveryInformation.fromJson(rawJson);
468 : } catch (_) {
469 : // we got an error processing or fetching the well-known information, let's
470 : // provide a reasonable fallback.
471 0 : return DiscoveryInformation(
472 0 : mHomeserver: HomeserverInformation(
473 0 : baseUrl: Uri.https(MatrixIdOrDomain.domain ?? '', '')),
474 : );
475 : }
476 : }
477 :
478 : /// Checks the supported versions of the Matrix protocol and the supported
479 : /// login types. Throws an exception if the server is not compatible with the
480 : /// client and sets [homeserver] to [homeserverUrl] if it is. Supports the
481 : /// types `Uri` and `String`.
482 33 : Future<
483 : (
484 : DiscoveryInformation?,
485 : GetVersionsResponse versions,
486 : List<LoginFlow>,
487 : )> checkHomeserver(
488 : Uri homeserverUrl, {
489 : bool checkWellKnown = true,
490 : Set<String>? overrideSupportedVersions,
491 : }) async {
492 : final supportedVersions =
493 : overrideSupportedVersions ?? Client.supportedVersions;
494 : try {
495 66 : homeserver = homeserverUrl.stripTrailingSlash();
496 :
497 : // Look up well known
498 : DiscoveryInformation? wellKnown;
499 : if (checkWellKnown) {
500 : try {
501 1 : wellKnown = await getWellknown();
502 0 : homeserver = wellKnown.mHomeserver.baseUrl.stripTrailingSlash();
503 : } catch (e) {
504 2 : Logs().v('Found no well known information', e);
505 : }
506 : }
507 :
508 : // Check if server supports at least one supported version
509 33 : final versions = await getVersions();
510 33 : if (!versions.versions
511 99 : .any((version) => supportedVersions.contains(version))) {
512 0 : throw BadServerVersionsException(
513 0 : versions.versions.toSet(),
514 : supportedVersions,
515 : );
516 : }
517 :
518 33 : final loginTypes = await getLoginFlows() ?? [];
519 165 : if (!loginTypes.any((f) => supportedLoginTypes.contains(f.type))) {
520 0 : throw BadServerLoginTypesException(
521 0 : loginTypes.map((f) => f.type ?? '').toSet(), supportedLoginTypes);
522 : }
523 :
524 : return (wellKnown, versions, loginTypes);
525 : } catch (_) {
526 1 : homeserver = null;
527 : rethrow;
528 : }
529 : }
530 :
531 : /// Checks to see if a username is available, and valid, for the server.
532 : /// Returns the fully-qualified Matrix user ID (MXID) that has been registered.
533 : /// You have to call [checkHomeserver] first to set a homeserver.
534 0 : @override
535 : Future<RegisterResponse> register({
536 : String? username,
537 : String? password,
538 : String? deviceId,
539 : String? initialDeviceDisplayName,
540 : bool? inhibitLogin,
541 : bool? refreshToken,
542 : AuthenticationData? auth,
543 : AccountKind? kind,
544 : }) async {
545 0 : final response = await super.register(
546 : kind: kind,
547 : username: username,
548 : password: password,
549 : auth: auth,
550 : deviceId: deviceId,
551 : initialDeviceDisplayName: initialDeviceDisplayName,
552 : inhibitLogin: inhibitLogin,
553 0 : refreshToken: refreshToken ?? onSoftLogout != null,
554 : );
555 :
556 : // Connect if there is an access token in the response.
557 0 : final accessToken = response.accessToken;
558 0 : final deviceId_ = response.deviceId;
559 0 : final userId = response.userId;
560 0 : final homeserver = this.homeserver;
561 : if (accessToken == null || deviceId_ == null || homeserver == null) {
562 0 : throw Exception(
563 : 'Registered but token, device ID, user ID or homeserver is null.');
564 : }
565 0 : final expiresInMs = response.expiresInMs;
566 : final tokenExpiresAt = expiresInMs == null
567 : ? null
568 0 : : DateTime.now().add(Duration(milliseconds: expiresInMs));
569 :
570 0 : await init(
571 : newToken: accessToken,
572 : newTokenExpiresAt: tokenExpiresAt,
573 0 : newRefreshToken: response.refreshToken,
574 : newUserID: userId,
575 : newHomeserver: homeserver,
576 : newDeviceName: initialDeviceDisplayName ?? '',
577 : newDeviceID: deviceId_);
578 : return response;
579 : }
580 :
581 : /// Handles the login and allows the client to call all APIs which require
582 : /// authentication. Returns false if the login was not successful. Throws
583 : /// MatrixException if login was not successful.
584 : /// To just login with the username 'alice' you set [identifier] to:
585 : /// `AuthenticationUserIdentifier(user: 'alice')`
586 : /// Maybe you want to set [user] to the same String to stay compatible with
587 : /// older server versions.
588 4 : @override
589 : Future<LoginResponse> login(
590 : LoginType type, {
591 : AuthenticationIdentifier? identifier,
592 : String? password,
593 : String? token,
594 : String? deviceId,
595 : String? initialDeviceDisplayName,
596 : bool? refreshToken,
597 : @Deprecated('Deprecated in favour of identifier.') String? user,
598 : @Deprecated('Deprecated in favour of identifier.') String? medium,
599 : @Deprecated('Deprecated in favour of identifier.') String? address,
600 : }) async {
601 4 : if (homeserver == null) {
602 0 : final domain = identifier is AuthenticationUserIdentifier
603 0 : ? identifier.user.domain
604 : : null;
605 : if (domain != null) {
606 0 : await checkHomeserver(Uri.https(domain, ''));
607 : } else {
608 0 : throw Exception('No homeserver specified!');
609 : }
610 : }
611 4 : final response = await super.login(
612 : type,
613 : identifier: identifier,
614 : password: password,
615 : token: token,
616 : deviceId: deviceId,
617 : initialDeviceDisplayName: initialDeviceDisplayName,
618 : // ignore: deprecated_member_use
619 : user: user,
620 : // ignore: deprecated_member_use
621 : medium: medium,
622 : // ignore: deprecated_member_use
623 : address: address,
624 4 : refreshToken: refreshToken ?? onSoftLogout != null,
625 : );
626 :
627 : // Connect if there is an access token in the response.
628 4 : final accessToken = response.accessToken;
629 4 : final deviceId_ = response.deviceId;
630 4 : final userId = response.userId;
631 4 : final homeserver_ = homeserver;
632 : if (homeserver_ == null) {
633 0 : throw Exception('Registered but homerserver is null.');
634 : }
635 :
636 4 : final expiresInMs = response.expiresInMs;
637 : final tokenExpiresAt = expiresInMs == null
638 : ? null
639 0 : : DateTime.now().add(Duration(milliseconds: expiresInMs));
640 :
641 4 : await init(
642 : newToken: accessToken,
643 : newTokenExpiresAt: tokenExpiresAt,
644 4 : newRefreshToken: response.refreshToken,
645 : newUserID: userId,
646 : newHomeserver: homeserver_,
647 : newDeviceName: initialDeviceDisplayName ?? '',
648 : newDeviceID: deviceId_,
649 : );
650 : return response;
651 : }
652 :
653 : /// Sends a logout command to the homeserver and clears all local data,
654 : /// including all persistent data from the store.
655 9 : @override
656 : Future<void> logout() async {
657 : try {
658 : // Upload keys to make sure all are cached on the next login.
659 21 : await encryption?.keyManager.uploadInboundGroupSessions();
660 9 : await super.logout();
661 : } catch (e, s) {
662 2 : Logs().e('Logout failed', e, s);
663 : rethrow;
664 : } finally {
665 9 : await clear();
666 : }
667 : }
668 :
669 : /// Sends a logout command to the homeserver and clears all local data,
670 : /// including all persistent data from the store.
671 0 : @override
672 : Future<void> logoutAll() async {
673 : // Upload keys to make sure all are cached on the next login.
674 0 : await encryption?.keyManager.uploadInboundGroupSessions();
675 :
676 0 : final futures = <Future>[];
677 0 : futures.add(super.logoutAll());
678 0 : futures.add(clear());
679 0 : await Future.wait(futures).catchError((e, s) {
680 0 : Logs().e('Logout all failed', e, s);
681 : throw e;
682 : });
683 : }
684 :
685 : /// Run any request and react on user interactive authentication flows here.
686 1 : Future<T> uiaRequestBackground<T>(
687 : Future<T> Function(AuthenticationData? auth) request) {
688 1 : final completer = Completer<T>();
689 : UiaRequest? uia;
690 1 : uia = UiaRequest(
691 : request: request,
692 1 : onUpdate: (state) {
693 : if (uia != null) {
694 1 : if (state == UiaRequestState.done) {
695 2 : completer.complete(uia.result);
696 0 : } else if (state == UiaRequestState.fail) {
697 0 : completer.completeError(uia.error!);
698 : } else {
699 0 : onUiaRequest.add(uia);
700 : }
701 : }
702 : },
703 : );
704 1 : return completer.future;
705 : }
706 :
707 : /// Returns an existing direct room ID with this user or creates a new one.
708 : /// By default encryption will be enabled if the client supports encryption
709 : /// and the other user has uploaded any encryption keys.
710 6 : Future<String> startDirectChat(
711 : String mxid, {
712 : bool? enableEncryption,
713 : List<StateEvent>? initialState,
714 : bool waitForSync = true,
715 : Map<String, dynamic>? powerLevelContentOverride,
716 : CreateRoomPreset? preset = CreateRoomPreset.trustedPrivateChat,
717 : }) async {
718 : // Try to find an existing direct chat
719 6 : final directChatRoomId = getDirectChatFromUserId(mxid);
720 : if (directChatRoomId != null) {
721 0 : final room = getRoomById(directChatRoomId);
722 : if (room != null) {
723 0 : if (room.membership == Membership.join) {
724 : return directChatRoomId;
725 0 : } else if (room.membership == Membership.invite) {
726 : // we might already have an invite into a DM room. If that is the case, we should try to join. If the room is
727 : // unjoinable, that will automatically leave the room, so in that case we need to continue creating a new
728 : // room. (This implicitly also prevents the room from being returned as a DM room by getDirectChatFromUserId,
729 : // because it only returns joined or invited rooms atm.)
730 0 : await room.join();
731 0 : if (room.membership != Membership.leave) {
732 : if (waitForSync) {
733 0 : if (room.membership != Membership.join) {
734 : // Wait for room actually appears in sync with the right membership
735 0 : await waitForRoomInSync(directChatRoomId, join: true);
736 : }
737 : }
738 : return directChatRoomId;
739 : }
740 : }
741 : }
742 : }
743 :
744 : enableEncryption ??=
745 5 : encryptionEnabled && await userOwnsEncryptionKeys(mxid);
746 : if (enableEncryption) {
747 2 : initialState ??= [];
748 2 : if (!initialState.any((s) => s.type == EventTypes.Encryption)) {
749 4 : initialState.add(StateEvent(
750 2 : content: {
751 2 : 'algorithm': supportedGroupEncryptionAlgorithms.first,
752 : },
753 : type: EventTypes.Encryption,
754 : ));
755 : }
756 : }
757 :
758 : // Start a new direct chat
759 6 : final roomId = await createRoom(
760 6 : invite: [mxid],
761 : isDirect: true,
762 : preset: preset,
763 : initialState: initialState,
764 : powerLevelContentOverride: powerLevelContentOverride,
765 : );
766 :
767 : if (waitForSync) {
768 1 : final room = getRoomById(roomId);
769 2 : if (room == null || room.membership != Membership.join) {
770 : // Wait for room actually appears in sync
771 0 : await waitForRoomInSync(roomId, join: true);
772 : }
773 : }
774 :
775 12 : await Room(id: roomId, client: this).addToDirectChat(mxid);
776 :
777 : return roomId;
778 : }
779 :
780 : /// Simplified method to create a new group chat. By default it is a private
781 : /// chat. The encryption is enabled if this client supports encryption and
782 : /// the preset is not a public chat.
783 2 : Future<String> createGroupChat({
784 : String? groupName,
785 : bool? enableEncryption,
786 : List<String>? invite,
787 : CreateRoomPreset preset = CreateRoomPreset.privateChat,
788 : List<StateEvent>? initialState,
789 : Visibility? visibility,
790 : HistoryVisibility? historyVisibility,
791 : bool waitForSync = true,
792 : bool groupCall = false,
793 : Map<String, dynamic>? powerLevelContentOverride,
794 : }) async {
795 : enableEncryption ??=
796 2 : encryptionEnabled && preset != CreateRoomPreset.publicChat;
797 : if (enableEncryption) {
798 1 : initialState ??= [];
799 1 : if (!initialState.any((s) => s.type == EventTypes.Encryption)) {
800 2 : initialState.add(StateEvent(
801 1 : content: {
802 1 : 'algorithm': supportedGroupEncryptionAlgorithms.first,
803 : },
804 : type: EventTypes.Encryption,
805 : ));
806 : }
807 : }
808 : if (historyVisibility != null) {
809 0 : initialState ??= [];
810 0 : if (!initialState.any((s) => s.type == EventTypes.HistoryVisibility)) {
811 0 : initialState.add(StateEvent(
812 0 : content: {
813 0 : 'history_visibility': historyVisibility.text,
814 : },
815 : type: EventTypes.HistoryVisibility,
816 : ));
817 : }
818 : }
819 : if (groupCall) {
820 1 : powerLevelContentOverride ??= {};
821 2 : powerLevelContentOverride['events'] ??= {};
822 2 : powerLevelContentOverride['events'][EventTypes.GroupCallMember] ??=
823 1 : powerLevelContentOverride['events_default'] ?? 0;
824 : }
825 :
826 2 : final roomId = await createRoom(
827 : invite: invite,
828 : preset: preset,
829 : name: groupName,
830 : initialState: initialState,
831 : visibility: visibility,
832 : powerLevelContentOverride: powerLevelContentOverride,
833 : );
834 :
835 : if (waitForSync) {
836 1 : if (getRoomById(roomId) == null) {
837 : // Wait for room actually appears in sync
838 0 : await waitForRoomInSync(roomId, join: true);
839 : }
840 : }
841 : return roomId;
842 : }
843 :
844 : /// Wait for the room to appear into the enabled section of the room sync.
845 : /// By default, the function will listen for room in invite, join and leave
846 : /// sections of the sync.
847 0 : Future<SyncUpdate> waitForRoomInSync(String roomId,
848 : {bool join = false, bool invite = false, bool leave = false}) async {
849 : if (!join && !invite && !leave) {
850 : join = true;
851 : invite = true;
852 : leave = true;
853 : }
854 :
855 : // Wait for the next sync where this room appears.
856 0 : final syncUpdate = await onSync.stream.firstWhere((sync) =>
857 0 : invite && (sync.rooms?.invite?.containsKey(roomId) ?? false) ||
858 0 : join && (sync.rooms?.join?.containsKey(roomId) ?? false) ||
859 0 : leave && (sync.rooms?.leave?.containsKey(roomId) ?? false));
860 :
861 : // Wait for this sync to be completely processed.
862 0 : await onSyncStatus.stream.firstWhere(
863 0 : (syncStatus) => syncStatus.status == SyncStatus.finished,
864 : );
865 : return syncUpdate;
866 : }
867 :
868 : /// Checks if the given user has encryption keys. May query keys from the
869 : /// server to answer this.
870 2 : Future<bool> userOwnsEncryptionKeys(String userId) async {
871 4 : if (userId == userID) return encryptionEnabled;
872 6 : if (_userDeviceKeys[userId]?.deviceKeys.isNotEmpty ?? false) {
873 : return true;
874 : }
875 3 : final keys = await queryKeys({userId: []});
876 3 : return keys.deviceKeys?[userId]?.isNotEmpty ?? false;
877 : }
878 :
879 : /// Creates a new space and returns the Room ID. The parameters are mostly
880 : /// the same like in [createRoom()].
881 : /// Be aware that spaces appear in the [rooms] list. You should check if a
882 : /// room is a space by using the `room.isSpace` getter and then just use the
883 : /// room as a space with `room.toSpace()`.
884 : ///
885 : /// https://github.com/matrix-org/matrix-doc/blob/matthew/msc1772/proposals/1772-groups-as-rooms.md
886 1 : Future<String> createSpace(
887 : {String? name,
888 : String? topic,
889 : Visibility visibility = Visibility.public,
890 : String? spaceAliasName,
891 : List<String>? invite,
892 : List<Invite3pid>? invite3pid,
893 : String? roomVersion,
894 : bool waitForSync = false}) async {
895 1 : final id = await createRoom(
896 : name: name,
897 : topic: topic,
898 : visibility: visibility,
899 : roomAliasName: spaceAliasName,
900 1 : creationContent: {'type': 'm.space'},
901 1 : powerLevelContentOverride: {'events_default': 100},
902 : invite: invite,
903 : invite3pid: invite3pid,
904 : roomVersion: roomVersion,
905 : );
906 :
907 : if (waitForSync) {
908 0 : await waitForRoomInSync(id, join: true);
909 : }
910 :
911 : return id;
912 : }
913 :
914 0 : @Deprecated('Use fetchOwnProfile() instead')
915 0 : Future<Profile> get ownProfile => fetchOwnProfile();
916 :
917 : /// Returns the user's own displayname and avatar url. In Matrix it is possible that
918 : /// one user can have different displaynames and avatar urls in different rooms.
919 : /// Tries to get the profile from homeserver first, if failed, falls back to a profile
920 : /// from a room where the user exists. Set `useServerCache` to true to get any
921 : /// prior value from this function
922 0 : Future<Profile> fetchOwnProfileFromServer(
923 : {bool useServerCache = false}) async {
924 : try {
925 0 : return await getProfileFromUserId(
926 0 : userID!,
927 : getFromRooms: false,
928 : cache: useServerCache,
929 : );
930 : } catch (e) {
931 0 : Logs().w(
932 : '[Matrix] getting profile from homeserver failed, falling back to first room with required profile');
933 0 : return await getProfileFromUserId(
934 0 : userID!,
935 : getFromRooms: true,
936 : cache: true,
937 : );
938 : }
939 : }
940 :
941 : /// Returns the user's own displayname and avatar url. In Matrix it is possible that
942 : /// one user can have different displaynames and avatar urls in different rooms.
943 : /// This returns the profile from the first room by default, override `getFromRooms`
944 : /// to false to fetch from homeserver.
945 1 : Future<Profile> fetchOwnProfile({
946 : bool getFromRooms = true,
947 : bool cache = true,
948 : }) =>
949 1 : getProfileFromUserId(
950 1 : userID!,
951 : getFromRooms: getFromRooms,
952 : cache: cache,
953 : );
954 :
955 : final Map<String, ProfileInformation> _profileRoomsCache = {};
956 : final Map<String, ProfileInformation> _profileServerCache = {};
957 :
958 : /// Get the combined profile information for this user.
959 : /// If [getFromRooms] is true then the profile will first be searched from the
960 : /// room memberships. This is unstable if the given user makes use of different displaynames
961 : /// and avatars per room, which is common for some bots and bridges.
962 : /// If [cache] is true then
963 : /// the profile get cached for this session. Please note that then the profile may
964 : /// become outdated if the user changes the displayname or avatar in this session.
965 4 : Future<Profile> getProfileFromUserId(String userId,
966 : {bool cache = true, bool getFromRooms = true}) async {
967 : var profile =
968 10 : getFromRooms ? _profileRoomsCache[userId] : _profileServerCache[userId];
969 : if (cache && profile != null) {
970 0 : return Profile(
971 : userId: userId,
972 0 : displayName: profile.displayname,
973 0 : avatarUrl: profile.avatarUrl,
974 : );
975 : }
976 :
977 : if (getFromRooms) {
978 11 : final room = rooms.firstWhereOrNull((Room room) =>
979 18 : room.getParticipants().indexWhere((User user) => user.id == userId) !=
980 3 : -1);
981 : if (room != null) {
982 : final user =
983 5 : room.getParticipants().firstWhere((User user) => user.id == userId);
984 1 : final profileFromRooms = Profile(
985 : userId: userId,
986 1 : displayName: user.displayName,
987 1 : avatarUrl: user.avatarUrl,
988 : );
989 3 : _profileRoomsCache[userId] = ProfileInformation(
990 1 : avatarUrl: profileFromRooms.avatarUrl,
991 1 : displayname: profileFromRooms.displayName,
992 : );
993 : return profileFromRooms;
994 : }
995 : }
996 4 : profile = await getUserProfile(userId);
997 0 : if (cache || _profileServerCache.containsKey(userId)) {
998 6 : _profileServerCache[userId] = profile;
999 : }
1000 3 : return Profile(
1001 : userId: userId,
1002 3 : displayName: profile.displayname,
1003 3 : avatarUrl: profile.avatarUrl,
1004 : );
1005 : }
1006 :
1007 : final List<ArchivedRoom> _archivedRooms = [];
1008 :
1009 : /// Return an archive room containing the room and the timeline for a specific archived room.
1010 2 : ArchivedRoom? getArchiveRoomFromCache(String roomId) {
1011 8 : for (var i = 0; i < _archivedRooms.length; i++) {
1012 4 : final archive = _archivedRooms[i];
1013 6 : if (archive.room.id == roomId) return archive;
1014 : }
1015 : return null;
1016 : }
1017 :
1018 : /// Remove all the archives stored in cache.
1019 2 : void clearArchivesFromCache() {
1020 4 : _archivedRooms.clear();
1021 : }
1022 :
1023 0 : @Deprecated('Use [loadArchive()] instead.')
1024 0 : Future<List<Room>> get archive => loadArchive();
1025 :
1026 : /// Fetch all the archived rooms from the server and return the list of the
1027 : /// room. If you want to have the Timelines bundled with it, use
1028 : /// loadArchiveWithTimeline instead.
1029 1 : Future<List<Room>> loadArchive() async {
1030 5 : return (await loadArchiveWithTimeline()).map((e) => e.room).toList();
1031 : }
1032 :
1033 : // Synapse caches sync responses. Documentation:
1034 : // https://matrix-org.github.io/synapse/latest/usage/configuration/config_documentation.html#caches-and-associated-values
1035 : // At the time of writing, the cache key consists of the following fields: user, timeout, since, filter_id,
1036 : // full_state, device_id, last_ignore_accdata_streampos.
1037 : // Since we can't pass a since token, the easiest field to vary is the timeout to bust through the synapse cache and
1038 : // give us the actual currently left rooms. Since the timeout doesn't matter for initial sync, this should actually
1039 : // not make any visible difference apart from properly fetching the cached rooms.
1040 : int _archiveCacheBusterTimeout = 0;
1041 :
1042 : /// Fetch the archived rooms from the server and return them as a list of
1043 : /// [ArchivedRoom] objects containing the [Room] and the associated [Timeline].
1044 3 : Future<List<ArchivedRoom>> loadArchiveWithTimeline() async {
1045 6 : _archivedRooms.clear();
1046 3 : final syncResp = await sync(
1047 : filter: '{"room":{"include_leave":true,"timeline":{"limit":10}}}',
1048 3 : timeout: _archiveCacheBusterTimeout,
1049 3 : setPresence: syncPresence,
1050 : );
1051 : // wrap around and hope there are not more than 30 leaves in 2 minutes :)
1052 12 : _archiveCacheBusterTimeout = (_archiveCacheBusterTimeout + 1) % 30;
1053 :
1054 6 : final leave = syncResp.rooms?.leave;
1055 : if (leave != null) {
1056 6 : for (final entry in leave.entries) {
1057 9 : await _storeArchivedRoom(entry.key, entry.value);
1058 : }
1059 : }
1060 :
1061 : // Sort the archived rooms by last event originServerTs as this is the
1062 : // best indicator we have to sort them. For archived rooms where we don't
1063 : // have any, we move them to the bottom.
1064 3 : final beginningOfTime = DateTime.fromMillisecondsSinceEpoch(0);
1065 9 : _archivedRooms.sort((b, a) =>
1066 6 : (a.room.lastEvent?.originServerTs ?? beginningOfTime)
1067 12 : .compareTo(b.room.lastEvent?.originServerTs ?? beginningOfTime));
1068 :
1069 3 : return _archivedRooms;
1070 : }
1071 :
1072 : /// [_storeArchivedRoom]
1073 : /// @leftRoom we can pass a room which was left so that we don't loose states
1074 3 : Future<void> _storeArchivedRoom(
1075 : String id,
1076 : LeftRoomUpdate update, {
1077 : Room? leftRoom,
1078 : }) async {
1079 : final roomUpdate = update;
1080 : final archivedRoom = leftRoom ??
1081 3 : Room(
1082 : id: id,
1083 : membership: Membership.leave,
1084 : client: this,
1085 3 : roomAccountData: roomUpdate.accountData
1086 3 : ?.asMap()
1087 12 : .map((k, v) => MapEntry(v.type, v)) ??
1088 3 : <String, BasicRoomEvent>{},
1089 : );
1090 : // Set membership of room to leave, in the case we got a left room passed, otherwise
1091 : // the left room would have still membership join, which would be wrong for the setState later
1092 3 : archivedRoom.membership = Membership.leave;
1093 3 : final timeline = Timeline(
1094 : room: archivedRoom,
1095 3 : chunk: TimelineChunk(
1096 9 : events: roomUpdate.timeline?.events?.reversed
1097 3 : .toList() // we display the event in the other sence
1098 9 : .map((e) => Event.fromMatrixEvent(e, archivedRoom))
1099 3 : .toList() ??
1100 0 : []));
1101 :
1102 9 : archivedRoom.prev_batch = update.timeline?.prevBatch;
1103 :
1104 3 : final stateEvents = roomUpdate.state;
1105 : if (stateEvents != null) {
1106 3 : await _handleRoomEvents(archivedRoom, stateEvents, EventUpdateType.state,
1107 : store: false);
1108 : }
1109 :
1110 6 : final timelineEvents = roomUpdate.timeline?.events;
1111 : if (timelineEvents != null) {
1112 9 : await _handleRoomEvents(archivedRoom, timelineEvents.reversed.toList(),
1113 : EventUpdateType.timeline,
1114 : store: false);
1115 : }
1116 :
1117 12 : for (var i = 0; i < timeline.events.length; i++) {
1118 : // Try to decrypt encrypted events but don't update the database.
1119 3 : if (archivedRoom.encrypted && archivedRoom.client.encryptionEnabled) {
1120 0 : if (timeline.events[i].type == EventTypes.Encrypted) {
1121 0 : await archivedRoom.client.encryption!
1122 0 : .decryptRoomEvent(
1123 0 : archivedRoom.id,
1124 0 : timeline.events[i],
1125 : )
1126 0 : .then(
1127 0 : (decrypted) => timeline.events[i] = decrypted,
1128 : );
1129 : }
1130 : }
1131 : }
1132 :
1133 9 : _archivedRooms.add(ArchivedRoom(room: archivedRoom, timeline: timeline));
1134 : }
1135 :
1136 : final _serverConfigCache = AsyncCache<ServerConfig>(const Duration(hours: 1));
1137 :
1138 : /// Gets the config of the content repository, such as upload limit.
1139 4 : @override
1140 : Future<ServerConfig> getConfig() =>
1141 16 : _serverConfigCache.fetch(() => super.getConfig());
1142 :
1143 : /// Uploads a file and automatically caches it in the database, if it is small enough
1144 : /// and returns the mxc url.
1145 4 : @override
1146 : Future<Uri> uploadContent(Uint8List file,
1147 : {String? filename, String? contentType}) async {
1148 4 : final mediaConfig = await getConfig();
1149 4 : final maxMediaSize = mediaConfig.mUploadSize;
1150 8 : if (maxMediaSize != null && maxMediaSize < file.lengthInBytes) {
1151 0 : throw FileTooBigMatrixException(file.lengthInBytes, maxMediaSize);
1152 : }
1153 :
1154 3 : contentType ??= lookupMimeType(filename ?? '', headerBytes: file);
1155 : final mxc = await super
1156 4 : .uploadContent(file, filename: filename, contentType: contentType);
1157 :
1158 4 : final database = this.database;
1159 12 : if (database != null && file.length <= database.maxFileSize) {
1160 4 : await database.storeFile(
1161 8 : mxc, file, DateTime.now().millisecondsSinceEpoch);
1162 : }
1163 : return mxc;
1164 : }
1165 :
1166 : /// Sends a typing notification and initiates a megolm session, if needed
1167 0 : @override
1168 : Future<void> setTyping(
1169 : String userId,
1170 : String roomId,
1171 : bool typing, {
1172 : int? timeout,
1173 : }) async {
1174 0 : await super.setTyping(userId, roomId, typing, timeout: timeout);
1175 0 : final room = getRoomById(roomId);
1176 0 : if (typing && room != null && encryptionEnabled && room.encrypted) {
1177 : // ignore: unawaited_futures
1178 0 : encryption?.keyManager.prepareOutboundGroupSession(roomId);
1179 : }
1180 : }
1181 :
1182 : /// dumps the local database and exports it into a String.
1183 : ///
1184 : /// WARNING: never re-import the dump twice
1185 : ///
1186 : /// This can be useful to migrate a session from one device to a future one.
1187 0 : Future<String?> exportDump() async {
1188 0 : if (database != null) {
1189 0 : await abortSync();
1190 0 : await dispose(closeDatabase: false);
1191 :
1192 0 : final export = await database!.exportDump();
1193 :
1194 0 : await clear();
1195 : return export;
1196 : }
1197 : return null;
1198 : }
1199 :
1200 : /// imports a dumped session
1201 : ///
1202 : /// WARNING: never re-import the dump twice
1203 0 : Future<bool> importDump(String export) async {
1204 : try {
1205 : // stopping sync loop and subscriptions while keeping DB open
1206 0 : await dispose(closeDatabase: false);
1207 : } catch (_) {
1208 : // Client was probably not initialized yet.
1209 : }
1210 :
1211 0 : _database ??= await databaseBuilder!.call(this);
1212 :
1213 0 : final success = await database!.importDump(export);
1214 :
1215 : if (success) {
1216 : // closing including DB
1217 0 : await dispose();
1218 :
1219 : try {
1220 0 : bearerToken = null;
1221 :
1222 0 : await init(
1223 : waitForFirstSync: false,
1224 : waitUntilLoadCompletedLoaded: false,
1225 : );
1226 : } catch (e) {
1227 : return false;
1228 : }
1229 : }
1230 : return success;
1231 : }
1232 :
1233 : /// Uploads a new user avatar for this user. Leave file null to remove the
1234 : /// current avatar.
1235 1 : Future<void> setAvatar(MatrixFile? file) async {
1236 : if (file == null) {
1237 : // We send an empty String to remove the avatar. Sending Null **should**
1238 : // work but it doesn't with Synapse. See:
1239 : // https://gitlab.com/famedly/company/frontend/famedlysdk/-/issues/254
1240 0 : return setAvatarUrl(userID!, Uri.parse(''));
1241 : }
1242 1 : final uploadResp = await uploadContent(
1243 1 : file.bytes,
1244 1 : filename: file.name,
1245 1 : contentType: file.mimeType,
1246 : );
1247 2 : await setAvatarUrl(userID!, uploadResp);
1248 : return;
1249 : }
1250 :
1251 : /// Returns the global push rules for the logged in user.
1252 0 : PushRuleSet? get globalPushRules {
1253 0 : final pushrules = _accountData['m.push_rules']
1254 0 : ?.content
1255 0 : .tryGetMap<String, Object?>('global');
1256 0 : return pushrules != null ? TryGetPushRule.tryFromJson(pushrules) : null;
1257 : }
1258 :
1259 : /// Returns the device push rules for the logged in user.
1260 0 : PushRuleSet? get devicePushRules {
1261 0 : final pushrules = _accountData['m.push_rules']
1262 0 : ?.content
1263 0 : .tryGetMap<String, Object?>('device');
1264 0 : return pushrules != null ? TryGetPushRule.tryFromJson(pushrules) : null;
1265 : }
1266 :
1267 : static const Set<String> supportedVersions = {'v1.1', 'v1.2'};
1268 : static const List<String> supportedDirectEncryptionAlgorithms = [
1269 : AlgorithmTypes.olmV1Curve25519AesSha2
1270 : ];
1271 : static const List<String> supportedGroupEncryptionAlgorithms = [
1272 : AlgorithmTypes.megolmV1AesSha2
1273 : ];
1274 : static const int defaultThumbnailSize = 800;
1275 :
1276 : /// The newEvent signal is the most important signal in this concept. Every time
1277 : /// the app receives a new synchronization, this event is called for every signal
1278 : /// to update the GUI. For example, for a new message, it is called:
1279 : /// onRoomEvent( "m.room.message", "!chat_id:server.com", "timeline", {sender: "@bob:server.com", body: "Hello world"} )
1280 : final CachedStreamController<EventUpdate> onEvent = CachedStreamController();
1281 :
1282 : /// The onToDeviceEvent is called when there comes a new to device event. It is
1283 : /// already decrypted if necessary.
1284 : final CachedStreamController<ToDeviceEvent> onToDeviceEvent =
1285 : CachedStreamController();
1286 :
1287 : /// Tells you about to-device and room call specific events in sync
1288 : final CachedStreamController<List<BasicEventWithSender>> onCallEvents =
1289 : CachedStreamController();
1290 :
1291 : /// Called when the login state e.g. user gets logged out.
1292 : final CachedStreamController<LoginState> onLoginStateChanged =
1293 : CachedStreamController();
1294 :
1295 : /// Called when the local cache is reset
1296 : final CachedStreamController<bool> onCacheCleared = CachedStreamController();
1297 :
1298 : /// Encryption errors are coming here.
1299 : final CachedStreamController<SdkError> onEncryptionError =
1300 : CachedStreamController();
1301 :
1302 : /// When a new sync response is coming in, this gives the complete payload.
1303 : final CachedStreamController<SyncUpdate> onSync = CachedStreamController();
1304 :
1305 : /// This gives the current status of the synchronization
1306 : final CachedStreamController<SyncStatusUpdate> onSyncStatus =
1307 : CachedStreamController();
1308 :
1309 : /// Callback will be called on presences.
1310 : @Deprecated(
1311 : 'Deprecated, use onPresenceChanged instead which has a timestamp.')
1312 : final CachedStreamController<Presence> onPresence = CachedStreamController();
1313 :
1314 : /// Callback will be called on presence updates.
1315 : final CachedStreamController<CachedPresence> onPresenceChanged =
1316 : CachedStreamController();
1317 :
1318 : /// Callback will be called on account data updates.
1319 : @Deprecated('Use `client.onSync` instead')
1320 : final CachedStreamController<BasicEvent> onAccountData =
1321 : CachedStreamController();
1322 :
1323 : /// Will be called when another device is requesting session keys for a room.
1324 : final CachedStreamController<RoomKeyRequest> onRoomKeyRequest =
1325 : CachedStreamController();
1326 :
1327 : /// Will be called when another device is requesting verification with this device.
1328 : final CachedStreamController<KeyVerification> onKeyVerificationRequest =
1329 : CachedStreamController();
1330 :
1331 : /// When the library calls an endpoint that needs UIA the `UiaRequest` is passed down this screen.
1332 : /// The client can open a UIA prompt based on this.
1333 : final CachedStreamController<UiaRequest> onUiaRequest =
1334 : CachedStreamController();
1335 :
1336 : @Deprecated('This is not in use anywhere anymore')
1337 : final CachedStreamController<Event> onGroupMember = CachedStreamController();
1338 :
1339 : final CachedStreamController<String> onCancelSendEvent =
1340 : CachedStreamController();
1341 :
1342 : /// When a state in a room has been updated this will return the room ID
1343 : /// and the state event.
1344 : final CachedStreamController<({String roomId, StrippedStateEvent state})>
1345 : onRoomState = CachedStreamController();
1346 :
1347 : /// How long should the app wait until it retrys the synchronisation after
1348 : /// an error?
1349 : int syncErrorTimeoutSec = 3;
1350 :
1351 : bool _initLock = false;
1352 :
1353 : /// Fetches the corresponding Event object from a notification including a
1354 : /// full Room object with the sender User object in it. Returns null if this
1355 : /// push notification is not corresponding to an existing event.
1356 : /// The client does **not** need to be initialized first. If it is not
1357 : /// initialized, it will only fetch the necessary parts of the database. This
1358 : /// should make it possible to run this parallel to another client with the
1359 : /// same client name.
1360 : /// This also checks if the given event has a readmarker and returns null
1361 : /// in this case.
1362 1 : Future<Event?> getEventByPushNotification(
1363 : PushNotification notification, {
1364 : bool storeInDatabase = true,
1365 : Duration timeoutForServerRequests = const Duration(seconds: 8),
1366 : bool returnNullIfSeen = true,
1367 : }) async {
1368 : // Get access token if necessary:
1369 3 : final database = _database ??= await databaseBuilder?.call(this);
1370 1 : if (!isLogged()) {
1371 : if (database == null) {
1372 0 : throw Exception(
1373 : 'Can not execute getEventByPushNotification() without a database');
1374 : }
1375 0 : final clientInfoMap = await database.getClient(clientName);
1376 0 : final token = clientInfoMap?.tryGet<String>('token');
1377 : if (token == null) {
1378 0 : throw Exception('Client is not logged in.');
1379 : }
1380 0 : accessToken = token;
1381 : }
1382 :
1383 1 : await ensureNotSoftLoggedOut();
1384 :
1385 : // Check if the notification contains an event at all:
1386 1 : final eventId = notification.eventId;
1387 1 : final roomId = notification.roomId;
1388 : if (eventId == null || roomId == null) return null;
1389 :
1390 : // Create the room object:
1391 1 : final room = getRoomById(roomId) ??
1392 1 : await database?.getSingleRoom(this, roomId) ??
1393 1 : Room(
1394 : id: roomId,
1395 : client: this,
1396 : );
1397 1 : final roomName = notification.roomName;
1398 1 : final roomAlias = notification.roomAlias;
1399 : if (roomName != null) {
1400 2 : room.setState(Event(
1401 : eventId: 'TEMP',
1402 : stateKey: '',
1403 : type: EventTypes.RoomName,
1404 1 : content: {'name': roomName},
1405 : room: room,
1406 : senderId: 'UNKNOWN',
1407 1 : originServerTs: DateTime.now(),
1408 : ));
1409 : }
1410 : if (roomAlias != null) {
1411 2 : room.setState(Event(
1412 : eventId: 'TEMP',
1413 : stateKey: '',
1414 : type: EventTypes.RoomCanonicalAlias,
1415 1 : content: {'alias': roomAlias},
1416 : room: room,
1417 : senderId: 'UNKNOWN',
1418 1 : originServerTs: DateTime.now(),
1419 : ));
1420 : }
1421 :
1422 : // Load the event from the notification or from the database or from server:
1423 : MatrixEvent? matrixEvent;
1424 1 : final content = notification.content;
1425 1 : final sender = notification.sender;
1426 1 : final type = notification.type;
1427 : if (content != null && sender != null && type != null) {
1428 1 : matrixEvent = MatrixEvent(
1429 : content: content,
1430 : senderId: sender,
1431 : type: type,
1432 1 : originServerTs: DateTime.now(),
1433 : eventId: eventId,
1434 : roomId: roomId,
1435 : );
1436 : }
1437 : matrixEvent ??= await database
1438 1 : ?.getEventById(eventId, room)
1439 1 : .timeout(timeoutForServerRequests);
1440 :
1441 : try {
1442 1 : matrixEvent ??= await getOneRoomEvent(roomId, eventId)
1443 1 : .timeout(timeoutForServerRequests);
1444 0 : } on MatrixException catch (_) {
1445 : // No access to the MatrixEvent. Search in /notifications
1446 0 : final notificationsResponse = await getNotifications();
1447 0 : matrixEvent ??= notificationsResponse.notifications
1448 0 : .firstWhereOrNull((notification) =>
1449 0 : notification.roomId == roomId &&
1450 0 : notification.event.eventId == eventId)
1451 0 : ?.event;
1452 : }
1453 :
1454 : if (matrixEvent == null) {
1455 0 : throw Exception('Unable to find event for this push notification!');
1456 : }
1457 :
1458 : // If the event was already in database, check if it has a read marker
1459 : // before displaying it.
1460 : if (returnNullIfSeen) {
1461 3 : if (room.fullyRead == matrixEvent.eventId) {
1462 : return null;
1463 : }
1464 : final readMarkerEvent = await database
1465 2 : ?.getEventById(room.fullyRead, room)
1466 1 : .timeout(timeoutForServerRequests);
1467 : if (readMarkerEvent != null &&
1468 0 : readMarkerEvent.originServerTs.isAfter(
1469 0 : matrixEvent.originServerTs
1470 : // As origin server timestamps are not always correct data in
1471 : // a federated environment, we add 10 minutes to the calculation
1472 : // to reduce the possibility that an event is marked as read which
1473 : // isn't.
1474 0 : ..add(Duration(minutes: 10)),
1475 : )) {
1476 : return null;
1477 : }
1478 : }
1479 :
1480 : // Load the sender of this event
1481 : try {
1482 : await room
1483 2 : .requestUser(matrixEvent.senderId)
1484 1 : .timeout(timeoutForServerRequests);
1485 : } catch (e, s) {
1486 2 : Logs().w('Unable to request user for push helper', e, s);
1487 1 : final senderDisplayName = notification.senderDisplayName;
1488 : if (senderDisplayName != null && sender != null) {
1489 2 : room.setState(User(sender, displayName: senderDisplayName, room: room));
1490 : }
1491 : }
1492 :
1493 : // Create Event object and decrypt if necessary
1494 1 : var event = Event.fromMatrixEvent(
1495 : matrixEvent,
1496 : room,
1497 : status: EventStatus.sent,
1498 : );
1499 :
1500 1 : final encryption = this.encryption;
1501 2 : if (event.type == EventTypes.Encrypted && encryption != null) {
1502 0 : var decrypted = await encryption.decryptRoomEvent(roomId, event);
1503 0 : if (decrypted.messageType == MessageTypes.BadEncrypted &&
1504 0 : prevBatch != null) {
1505 0 : await oneShotSync();
1506 0 : decrypted = await encryption.decryptRoomEvent(roomId, event);
1507 : }
1508 : event = decrypted;
1509 : }
1510 :
1511 : if (storeInDatabase) {
1512 2 : await database?.transaction(() async {
1513 1 : await database.storeEventUpdate(
1514 1 : EventUpdate(
1515 : roomID: roomId,
1516 : type: EventUpdateType.timeline,
1517 1 : content: event.toJson(),
1518 : ),
1519 : this);
1520 : });
1521 : }
1522 :
1523 : return event;
1524 : }
1525 :
1526 : /// Sets the user credentials and starts the synchronisation.
1527 : ///
1528 : /// Before you can connect you need at least an [accessToken], a [homeserver],
1529 : /// a [userID], a [deviceID], and a [deviceName].
1530 : ///
1531 : /// Usually you don't need to call this method yourself because [login()], [register()]
1532 : /// and even the constructor calls it.
1533 : ///
1534 : /// Sends [LoginState.loggedIn] to [onLoginStateChanged].
1535 : ///
1536 : /// If one of [newToken], [newUserID], [newDeviceID], [newDeviceName] is set then
1537 : /// all of them must be set! If you don't set them, this method will try to
1538 : /// get them from the database.
1539 : ///
1540 : /// Set [waitForFirstSync] and [waitUntilLoadCompletedLoaded] to false to speed this
1541 : /// up. You can then wait for `roomsLoading`, `_accountDataLoading` and
1542 : /// `userDeviceKeysLoading` where it is necessary.
1543 31 : Future<void> init({
1544 : String? newToken,
1545 : DateTime? newTokenExpiresAt,
1546 : String? newRefreshToken,
1547 : Uri? newHomeserver,
1548 : String? newUserID,
1549 : String? newDeviceName,
1550 : String? newDeviceID,
1551 : String? newOlmAccount,
1552 : bool waitForFirstSync = true,
1553 : bool waitUntilLoadCompletedLoaded = true,
1554 :
1555 : /// Will be called if the app performs a migration task from the [legacyDatabaseBuilder]
1556 : void Function()? onMigration,
1557 : }) async {
1558 : if ((newToken != null ||
1559 : newUserID != null ||
1560 : newDeviceID != null ||
1561 : newDeviceName != null) &&
1562 : (newToken == null ||
1563 : newUserID == null ||
1564 : newDeviceID == null ||
1565 : newDeviceName == null)) {
1566 0 : throw ClientInitPreconditionError(
1567 : 'If one of [newToken, newUserID, newDeviceID, newDeviceName] is set then all of them must be set!',
1568 : );
1569 : }
1570 :
1571 31 : if (_initLock) {
1572 0 : throw ClientInitPreconditionError(
1573 : '[init()] has been called multiple times!',
1574 : );
1575 : }
1576 31 : _initLock = true;
1577 : String? olmAccount;
1578 : String? accessToken;
1579 : String? userID;
1580 : try {
1581 124 : Logs().i('Initialize client $clientName');
1582 93 : if (onLoginStateChanged.value == LoginState.loggedIn) {
1583 0 : throw ClientInitPreconditionError(
1584 : 'User is already logged in! Call [logout()] first!',
1585 : );
1586 : }
1587 :
1588 31 : final databaseBuilder = this.databaseBuilder;
1589 : if (databaseBuilder != null) {
1590 58 : _database ??= await runBenchmarked<DatabaseApi>(
1591 : 'Build database',
1592 58 : () async => await databaseBuilder(this),
1593 : );
1594 : }
1595 :
1596 62 : _groupCallSessionId = randomAlpha(12);
1597 62 : _serverConfigCache.invalidate();
1598 :
1599 89 : final account = await this.database?.getClient(clientName);
1600 1 : newRefreshToken ??= account?.tryGet<String>('refresh_token');
1601 : if (account != null) {
1602 2 : _id = account['client_id'];
1603 3 : homeserver = Uri.parse(account['homeserver_url']);
1604 2 : accessToken = this.accessToken = account['token'];
1605 : final tokenExpiresAtMs =
1606 2 : int.tryParse(account.tryGet<String>('token_expires_at') ?? '');
1607 1 : _accessTokenExpiresAt = tokenExpiresAtMs == null
1608 : ? null
1609 0 : : DateTime.fromMillisecondsSinceEpoch(tokenExpiresAtMs);
1610 2 : userID = _userID = account['user_id'];
1611 2 : _deviceID = account['device_id'];
1612 2 : _deviceName = account['device_name'];
1613 2 : _syncFilterId = account['sync_filter_id'];
1614 2 : _prevBatch = account['prev_batch'];
1615 1 : olmAccount = account['olm_account'];
1616 : }
1617 : if (newToken != null) {
1618 31 : accessToken = this.accessToken = newToken;
1619 31 : _accessTokenExpiresAt = newTokenExpiresAt;
1620 31 : homeserver = newHomeserver;
1621 31 : userID = _userID = newUserID;
1622 31 : _deviceID = newDeviceID;
1623 31 : _deviceName = newDeviceName;
1624 : olmAccount = newOlmAccount;
1625 : } else {
1626 1 : accessToken = this.accessToken = newToken ?? accessToken;
1627 2 : _accessTokenExpiresAt = newTokenExpiresAt ?? accessTokenExpiresAt;
1628 2 : homeserver = newHomeserver ?? homeserver;
1629 1 : userID = _userID = newUserID ?? userID;
1630 2 : _deviceID = newDeviceID ?? _deviceID;
1631 2 : _deviceName = newDeviceName ?? _deviceName;
1632 : olmAccount = newOlmAccount ?? olmAccount;
1633 : }
1634 :
1635 : // If we are refreshing the session, we are done here:
1636 93 : if (onLoginStateChanged.value == LoginState.softLoggedOut) {
1637 : if (newRefreshToken != null && accessToken != null && userID != null) {
1638 : // Store the new tokens:
1639 0 : await _database?.updateClient(
1640 0 : homeserver.toString(),
1641 : accessToken,
1642 0 : accessTokenExpiresAt,
1643 : newRefreshToken,
1644 : userID,
1645 0 : _deviceID,
1646 0 : _deviceName,
1647 0 : prevBatch,
1648 0 : encryption?.pickledOlmAccount,
1649 : );
1650 : }
1651 0 : onLoginStateChanged.add(LoginState.loggedIn);
1652 : return;
1653 : }
1654 :
1655 31 : if (accessToken == null || homeserver == null || userID == null) {
1656 1 : if (legacyDatabaseBuilder != null) {
1657 1 : await _migrateFromLegacyDatabase(onMigration: onMigration);
1658 1 : if (isLogged()) return;
1659 : }
1660 : // we aren't logged in
1661 0 : await encryption?.dispose();
1662 0 : _encryption = null;
1663 0 : onLoginStateChanged.add(LoginState.loggedOut);
1664 0 : Logs().i('User is not logged in.');
1665 0 : _initLock = false;
1666 : return;
1667 : }
1668 :
1669 31 : await encryption?.dispose();
1670 : try {
1671 : // make sure to throw an exception if libolm doesn't exist
1672 31 : await olm.init();
1673 24 : olm.get_library_version();
1674 48 : _encryption = Encryption(client: this);
1675 : } catch (e) {
1676 21 : Logs().e('Error initializing encryption $e');
1677 7 : await encryption?.dispose();
1678 7 : _encryption = null;
1679 : }
1680 55 : await encryption?.init(olmAccount);
1681 :
1682 31 : final database = this.database;
1683 : if (database != null) {
1684 29 : if (id != null) {
1685 0 : await database.updateClient(
1686 0 : homeserver.toString(),
1687 : accessToken,
1688 0 : accessTokenExpiresAt,
1689 : newRefreshToken,
1690 : userID,
1691 0 : _deviceID,
1692 0 : _deviceName,
1693 0 : prevBatch,
1694 0 : encryption?.pickledOlmAccount,
1695 : );
1696 : } else {
1697 58 : _id = await database.insertClient(
1698 29 : clientName,
1699 58 : homeserver.toString(),
1700 : accessToken,
1701 29 : accessTokenExpiresAt,
1702 : newRefreshToken,
1703 : userID,
1704 29 : _deviceID,
1705 29 : _deviceName,
1706 29 : prevBatch,
1707 52 : encryption?.pickledOlmAccount,
1708 : );
1709 : }
1710 29 : userDeviceKeysLoading = database
1711 29 : .getUserDeviceKeys(this)
1712 87 : .then((keys) => _userDeviceKeys = keys);
1713 116 : roomsLoading = database.getRoomList(this).then((rooms) {
1714 29 : _rooms = rooms;
1715 29 : _sortRooms();
1716 : });
1717 116 : _accountDataLoading = database.getAccountData().then((data) {
1718 29 : _accountData = data;
1719 29 : _updatePushrules();
1720 : });
1721 : // ignore: deprecated_member_use_from_same_package
1722 58 : presences.clear();
1723 : if (waitUntilLoadCompletedLoaded) {
1724 29 : await userDeviceKeysLoading;
1725 29 : await roomsLoading;
1726 29 : await _accountDataLoading;
1727 : }
1728 : }
1729 31 : _initLock = false;
1730 62 : onLoginStateChanged.add(LoginState.loggedIn);
1731 62 : Logs().i(
1732 124 : 'Successfully connected as ${userID.localpart} with ${homeserver.toString()}',
1733 : );
1734 :
1735 : /// Timeout of 0, so that we don't see a spinner for 30 seconds.
1736 62 : firstSyncReceived = _sync(timeout: Duration.zero);
1737 : if (waitForFirstSync) {
1738 31 : await firstSyncReceived;
1739 : }
1740 : return;
1741 1 : } on ClientInitPreconditionError {
1742 : rethrow;
1743 : } catch (e, s) {
1744 2 : Logs().wtf('Client initialization failed', e, s);
1745 2 : onLoginStateChanged.addError(e, s);
1746 1 : final clientInitException = ClientInitException(
1747 : e,
1748 1 : homeserver: homeserver,
1749 : accessToken: accessToken,
1750 : userId: userID,
1751 1 : deviceId: deviceID,
1752 1 : deviceName: deviceName,
1753 : olmAccount: olmAccount,
1754 : );
1755 1 : await clear();
1756 : throw clientInitException;
1757 : } finally {
1758 31 : _initLock = false;
1759 : }
1760 : }
1761 :
1762 : /// Used for testing only
1763 1 : void setUserId(String s) {
1764 1 : _userID = s;
1765 : }
1766 :
1767 : /// Resets all settings and stops the synchronisation.
1768 9 : Future<void> clear() async {
1769 27 : Logs().outputEvents.clear();
1770 : try {
1771 9 : await abortSync();
1772 16 : await database?.clear();
1773 9 : _backgroundSync = true;
1774 : } catch (e, s) {
1775 0 : Logs().e('Unable to clear database', e, s);
1776 : } finally {
1777 9 : _database = null;
1778 : }
1779 :
1780 27 : _id = accessToken = _syncFilterId =
1781 45 : homeserver = _userID = _deviceID = _deviceName = _prevBatch = null;
1782 18 : _rooms = [];
1783 18 : _eventsPendingDecryption.clear();
1784 15 : await encryption?.dispose();
1785 9 : _encryption = null;
1786 18 : onLoginStateChanged.add(LoginState.loggedOut);
1787 : }
1788 :
1789 : bool _backgroundSync = true;
1790 : Future<void>? _currentSync;
1791 : Future<void> _retryDelay = Future.value();
1792 :
1793 0 : bool get syncPending => _currentSync != null;
1794 :
1795 : /// Controls the background sync (automatically looping forever if turned on).
1796 : /// If you use soft logout, you need to manually call
1797 : /// `ensureNotSoftLoggedOut()` before doing any API request after setting
1798 : /// the background sync to false, as the soft logout is handeld automatically
1799 : /// in the sync loop.
1800 25 : set backgroundSync(bool enabled) {
1801 25 : _backgroundSync = enabled;
1802 25 : if (_backgroundSync) {
1803 6 : runInRoot(() async => _sync());
1804 : }
1805 : }
1806 :
1807 : /// Immediately start a sync and wait for completion.
1808 : /// If there is an active sync already, wait for the active sync instead.
1809 1 : Future<void> oneShotSync() {
1810 1 : return _sync();
1811 : }
1812 :
1813 : /// Pass a timeout to set how long the server waits before sending an empty response.
1814 : /// (Corresponds to the timeout param on the /sync request.)
1815 31 : Future<void> _sync({Duration? timeout}) {
1816 : final currentSync =
1817 124 : _currentSync ??= _innerSync(timeout: timeout).whenComplete(() {
1818 31 : _currentSync = null;
1819 93 : if (_backgroundSync && isLogged() && !_disposed) {
1820 31 : _sync();
1821 : }
1822 : });
1823 : return currentSync;
1824 : }
1825 :
1826 : /// Presence that is set on sync.
1827 : PresenceType? syncPresence;
1828 :
1829 31 : Future<void> _checkSyncFilter() async {
1830 31 : final userID = this.userID;
1831 31 : if (syncFilterId == null && userID != null) {
1832 : final syncFilterId =
1833 93 : _syncFilterId = await defineFilter(userID, syncFilter);
1834 60 : await database?.storeSyncFilterId(syncFilterId);
1835 : }
1836 : return;
1837 : }
1838 :
1839 : Future<void>? _handleSoftLogoutFuture;
1840 :
1841 1 : Future<void> _handleSoftLogout() async {
1842 1 : final onSoftLogout = this.onSoftLogout;
1843 : if (onSoftLogout == null) return;
1844 :
1845 2 : _handleSoftLogoutFuture ??= () async {
1846 2 : onLoginStateChanged.add(LoginState.softLoggedOut);
1847 : try {
1848 1 : await onSoftLogout(this);
1849 2 : onLoginStateChanged.add(LoginState.loggedIn);
1850 : } catch (e, s) {
1851 0 : Logs().w('Unable to refresh session after soft logout', e, s);
1852 0 : await clear();
1853 : rethrow;
1854 : }
1855 1 : }();
1856 1 : await _handleSoftLogoutFuture;
1857 1 : _handleSoftLogoutFuture = null;
1858 : }
1859 :
1860 : /// Checks if the token expires in under [expiresIn] time and calls the
1861 : /// given `onSoftLogout()` if so. You have to provide `onSoftLogout` in the
1862 : /// Client constructor. Otherwise this will do nothing.
1863 31 : Future<void> ensureNotSoftLoggedOut(
1864 : [Duration expiresIn = const Duration(minutes: 1)]) async {
1865 31 : final tokenExpiresAt = accessTokenExpiresAt;
1866 31 : if (onSoftLogout != null &&
1867 : tokenExpiresAt != null &&
1868 3 : tokenExpiresAt.difference(DateTime.now()) <= expiresIn) {
1869 0 : await _handleSoftLogout();
1870 : }
1871 : }
1872 :
1873 : /// Pass a timeout to set how long the server waits before sending an empty response.
1874 : /// (Corresponds to the timeout param on the /sync request.)
1875 31 : Future<void> _innerSync({Duration? timeout}) async {
1876 31 : await _retryDelay;
1877 124 : _retryDelay = Future.delayed(Duration(seconds: syncErrorTimeoutSec));
1878 93 : if (!isLogged() || _disposed || _aborted) return;
1879 : try {
1880 31 : if (_initLock) {
1881 0 : Logs().d('Running sync while init isn\'t done yet, dropping request');
1882 : return;
1883 : }
1884 : Object? syncError;
1885 :
1886 : // The timeout we send to the server for the sync loop. It says to the
1887 : // server that we want to receive an empty sync response after this
1888 : // amount of time if nothing happens.
1889 : timeout ??= const Duration(seconds: 30);
1890 :
1891 62 : await ensureNotSoftLoggedOut(timeout * 2);
1892 :
1893 31 : await _checkSyncFilter();
1894 :
1895 31 : final syncRequest = sync(
1896 31 : filter: syncFilterId,
1897 31 : since: prevBatch,
1898 31 : timeout: timeout.inMilliseconds,
1899 31 : setPresence: syncPresence,
1900 125 : ).then((v) => Future<SyncUpdate?>.value(v)).catchError((e) {
1901 1 : if (e is MatrixException) {
1902 : syncError = e;
1903 : } else {
1904 0 : syncError = SyncConnectionException(e);
1905 : }
1906 : return null;
1907 : });
1908 62 : _currentSyncId = syncRequest.hashCode;
1909 93 : onSyncStatus.add(SyncStatusUpdate(SyncStatus.waitingForResponse));
1910 :
1911 : // The timeout for the response from the server. If we do not set a sync
1912 : // timeout (for initial sync) we give the server a longer time to
1913 : // responde.
1914 31 : final responseTimeout = timeout == Duration.zero
1915 : ? const Duration(minutes: 2)
1916 31 : : timeout + const Duration(seconds: 10);
1917 :
1918 31 : final syncResp = await syncRequest.timeout(responseTimeout);
1919 93 : onSyncStatus.add(SyncStatusUpdate(SyncStatus.processing));
1920 : if (syncResp == null) throw syncError ?? 'Unknown sync error';
1921 93 : if (_currentSyncId != syncRequest.hashCode) {
1922 12 : Logs()
1923 12 : .w('Current sync request ID has changed. Dropping this sync loop!');
1924 : return;
1925 : }
1926 :
1927 31 : final database = this.database;
1928 : if (database != null) {
1929 29 : await userDeviceKeysLoading;
1930 29 : await roomsLoading;
1931 29 : await _accountDataLoading;
1932 87 : _currentTransaction = database.transaction(() async {
1933 29 : await _handleSync(syncResp, direction: Direction.f);
1934 87 : if (prevBatch != syncResp.nextBatch) {
1935 58 : await database.storePrevBatch(syncResp.nextBatch);
1936 : }
1937 : });
1938 29 : await runBenchmarked(
1939 : 'Process sync',
1940 58 : () async => await _currentTransaction,
1941 29 : syncResp.itemCount,
1942 : );
1943 : } else {
1944 4 : await _handleSync(syncResp, direction: Direction.f);
1945 : }
1946 62 : if (_disposed || _aborted) return;
1947 62 : _prevBatch = syncResp.nextBatch;
1948 93 : onSyncStatus.add(SyncStatusUpdate(SyncStatus.cleaningUp));
1949 : // ignore: unawaited_futures
1950 29 : database?.deleteOldFiles(
1951 116 : DateTime.now().subtract(Duration(days: 30)).millisecondsSinceEpoch);
1952 31 : await updateUserDeviceKeys();
1953 31 : if (encryptionEnabled) {
1954 48 : encryption?.onSync();
1955 : }
1956 :
1957 : // try to process the to_device queue
1958 : try {
1959 31 : await processToDeviceQueue();
1960 : } catch (_) {} // we want to dispose any errors this throws
1961 :
1962 62 : _retryDelay = Future.value();
1963 93 : onSyncStatus.add(SyncStatusUpdate(SyncStatus.finished));
1964 1 : } on MatrixException catch (e, s) {
1965 3 : onSyncStatus.add(SyncStatusUpdate(SyncStatus.error,
1966 1 : error: SdkError(exception: e, stackTrace: s)));
1967 2 : if (e.error == MatrixError.M_UNKNOWN_TOKEN) {
1968 1 : final onSoftLogout = this.onSoftLogout;
1969 3 : if (e.raw.tryGet<bool>('soft_logout') == true && onSoftLogout != null) {
1970 2 : Logs().w('The user has been soft logged out! Try to login again...');
1971 :
1972 1 : await _handleSoftLogout();
1973 : } else {
1974 0 : Logs().w('The user has been logged out!');
1975 0 : await clear();
1976 : }
1977 : }
1978 0 : } on SyncConnectionException catch (e, s) {
1979 0 : Logs().w('Syncloop failed: Client has not connection to the server');
1980 0 : onSyncStatus.add(SyncStatusUpdate(SyncStatus.error,
1981 0 : error: SdkError(exception: e, stackTrace: s)));
1982 : } catch (e, s) {
1983 0 : if (!isLogged() || _disposed || _aborted) return;
1984 0 : Logs().e('Error during processing events', e, s);
1985 0 : onSyncStatus.add(SyncStatusUpdate(SyncStatus.error,
1986 0 : error: SdkError(
1987 0 : exception: e is Exception ? e : Exception(e), stackTrace: s)));
1988 : }
1989 : }
1990 :
1991 : /// Use this method only for testing utilities!
1992 18 : Future<void> handleSync(SyncUpdate sync, {Direction? direction}) async {
1993 : // ensure we don't upload keys because someone forgot to set a key count
1994 36 : sync.deviceOneTimeKeysCount ??= {
1995 46 : 'signed_curve25519': encryption?.olmManager.maxNumberOfOneTimeKeys ?? 100,
1996 : };
1997 18 : await _handleSync(sync, direction: direction);
1998 : }
1999 :
2000 31 : Future<void> _handleSync(SyncUpdate sync, {Direction? direction}) async {
2001 31 : final syncToDevice = sync.toDevice;
2002 : if (syncToDevice != null) {
2003 31 : await _handleToDeviceEvents(syncToDevice);
2004 : }
2005 :
2006 31 : if (sync.rooms != null) {
2007 62 : final join = sync.rooms?.join;
2008 : if (join != null) {
2009 31 : await _handleRooms(join, direction: direction);
2010 : }
2011 62 : final invite = sync.rooms?.invite;
2012 : if (invite != null) {
2013 31 : await _handleRooms(invite, direction: direction);
2014 : }
2015 62 : final leave = sync.rooms?.leave;
2016 : if (leave != null) {
2017 31 : await _handleRooms(leave, direction: direction);
2018 : }
2019 : }
2020 111 : for (final newPresence in sync.presence ?? <Presence>[]) {
2021 31 : final cachedPresence = CachedPresence.fromMatrixEvent(newPresence);
2022 : // ignore: deprecated_member_use_from_same_package
2023 93 : presences[newPresence.senderId] = cachedPresence;
2024 : // ignore: deprecated_member_use_from_same_package
2025 62 : onPresence.add(newPresence);
2026 62 : onPresenceChanged.add(cachedPresence);
2027 89 : await database?.storePresence(newPresence.senderId, cachedPresence);
2028 : }
2029 112 : for (final newAccountData in sync.accountData ?? []) {
2030 60 : await database?.storeAccountData(
2031 29 : newAccountData.type,
2032 58 : jsonEncode(newAccountData.content),
2033 : );
2034 93 : accountData[newAccountData.type] = newAccountData;
2035 : // ignore: deprecated_member_use_from_same_package
2036 62 : onAccountData.add(newAccountData);
2037 :
2038 62 : if (newAccountData.type == EventTypes.PushRules) {
2039 31 : _updatePushrules();
2040 : }
2041 : }
2042 :
2043 31 : final syncDeviceLists = sync.deviceLists;
2044 : if (syncDeviceLists != null) {
2045 31 : await _handleDeviceListsEvents(syncDeviceLists);
2046 : }
2047 31 : if (encryptionEnabled) {
2048 48 : encryption?.handleDeviceOneTimeKeysCount(
2049 48 : sync.deviceOneTimeKeysCount, sync.deviceUnusedFallbackKeyTypes);
2050 : }
2051 31 : _sortRooms();
2052 62 : onSync.add(sync);
2053 : }
2054 :
2055 31 : Future<void> _handleDeviceListsEvents(DeviceListsUpdate deviceLists) async {
2056 62 : if (deviceLists.changed is List) {
2057 93 : for (final userId in deviceLists.changed ?? []) {
2058 62 : final userKeys = _userDeviceKeys[userId];
2059 : if (userKeys != null) {
2060 1 : userKeys.outdated = true;
2061 2 : await database?.storeUserDeviceKeysInfo(userId, true);
2062 : }
2063 : }
2064 93 : for (final userId in deviceLists.left ?? []) {
2065 62 : if (_userDeviceKeys.containsKey(userId)) {
2066 0 : _userDeviceKeys.remove(userId);
2067 : }
2068 : }
2069 : }
2070 : }
2071 :
2072 31 : Future<void> _handleToDeviceEvents(List<BasicEventWithSender> events) async {
2073 31 : final Map<String, List<String>> roomsWithNewKeyToSessionId = {};
2074 31 : final List<ToDeviceEvent> callToDeviceEvents = [];
2075 62 : for (final event in events) {
2076 62 : var toDeviceEvent = ToDeviceEvent.fromJson(event.toJson());
2077 124 : Logs().v('Got to_device event of type ${toDeviceEvent.type}');
2078 31 : if (encryptionEnabled) {
2079 48 : if (toDeviceEvent.type == EventTypes.Encrypted) {
2080 48 : toDeviceEvent = await encryption!.decryptToDeviceEvent(toDeviceEvent);
2081 96 : Logs().v('Decrypted type is: ${toDeviceEvent.type}');
2082 :
2083 : /// collect new keys so that we can find those events in the decryption queue
2084 48 : if (toDeviceEvent.type == EventTypes.ForwardedRoomKey ||
2085 48 : toDeviceEvent.type == EventTypes.RoomKey) {
2086 46 : final roomId = event.content['room_id'];
2087 46 : final sessionId = event.content['session_id'];
2088 23 : if (roomId is String && sessionId is String) {
2089 0 : (roomsWithNewKeyToSessionId[roomId] ??= []).add(sessionId);
2090 : }
2091 : }
2092 : }
2093 48 : await encryption?.handleToDeviceEvent(toDeviceEvent);
2094 : }
2095 93 : if (toDeviceEvent.type.startsWith(CallConstants.callEventsRegxp)) {
2096 0 : callToDeviceEvents.add(toDeviceEvent);
2097 : }
2098 62 : onToDeviceEvent.add(toDeviceEvent);
2099 : }
2100 :
2101 31 : if (callToDeviceEvents.isNotEmpty) {
2102 0 : onCallEvents.add(callToDeviceEvents);
2103 : }
2104 :
2105 : // emit updates for all events in the queue
2106 31 : for (final entry in roomsWithNewKeyToSessionId.entries) {
2107 0 : final roomId = entry.key;
2108 0 : final sessionIds = entry.value;
2109 :
2110 0 : final room = getRoomById(roomId);
2111 : if (room != null) {
2112 0 : final List<BasicEvent> events = [];
2113 0 : for (final event in _eventsPendingDecryption) {
2114 0 : if (event.event.roomID != roomId) continue;
2115 0 : if (!sessionIds.contains(
2116 0 : event.event.content['content']?['session_id'])) continue;
2117 :
2118 0 : final decryptedEvent = await event.event.decrypt(room);
2119 0 : if (decryptedEvent.content.tryGet<String>('type') !=
2120 : EventTypes.Encrypted) {
2121 0 : events.add(BasicEvent.fromJson(decryptedEvent.content));
2122 : }
2123 : }
2124 :
2125 0 : await _handleRoomEvents(
2126 : room, events, EventUpdateType.decryptedTimelineQueue);
2127 :
2128 0 : _eventsPendingDecryption.removeWhere((e) => events.any(
2129 0 : (decryptedEvent) =>
2130 0 : decryptedEvent.content['event_id'] ==
2131 0 : e.event.content['event_id']));
2132 : }
2133 : }
2134 62 : _eventsPendingDecryption.removeWhere((e) => e.timedOut);
2135 : }
2136 :
2137 31 : Future<void> _handleRooms(Map<String, SyncRoomUpdate> rooms,
2138 : {Direction? direction}) async {
2139 : var handledRooms = 0;
2140 62 : for (final entry in rooms.entries) {
2141 93 : onSyncStatus.add(SyncStatusUpdate(
2142 : SyncStatus.processing,
2143 93 : progress: ++handledRooms / rooms.length,
2144 : ));
2145 31 : final id = entry.key;
2146 31 : final syncRoomUpdate = entry.value;
2147 :
2148 : // Is the timeline limited? Then all previous messages should be
2149 : // removed from the database!
2150 31 : if (syncRoomUpdate is JoinedRoomUpdate &&
2151 93 : syncRoomUpdate.timeline?.limited == true) {
2152 60 : await database?.deleteTimelineForRoom(id);
2153 : }
2154 31 : final room = await _updateRoomsByRoomUpdate(id, syncRoomUpdate);
2155 :
2156 : final timelineUpdateType = direction != null
2157 31 : ? (direction == Direction.b
2158 : ? EventUpdateType.history
2159 : : EventUpdateType.timeline)
2160 : : EventUpdateType.timeline;
2161 :
2162 : /// Handle now all room events and save them in the database
2163 31 : if (syncRoomUpdate is JoinedRoomUpdate) {
2164 31 : final state = syncRoomUpdate.state;
2165 :
2166 31 : if (state != null && state.isNotEmpty) {
2167 : // TODO: This method seems to be comperatively slow for some updates
2168 31 : await _handleRoomEvents(
2169 : room,
2170 : state,
2171 : EventUpdateType.state,
2172 : );
2173 : }
2174 :
2175 62 : final timelineEvents = syncRoomUpdate.timeline?.events;
2176 31 : if (timelineEvents != null && timelineEvents.isNotEmpty) {
2177 31 : await _handleRoomEvents(room, timelineEvents, timelineUpdateType);
2178 : }
2179 :
2180 31 : final ephemeral = syncRoomUpdate.ephemeral;
2181 31 : if (ephemeral != null && ephemeral.isNotEmpty) {
2182 : // TODO: This method seems to be comperatively slow for some updates
2183 31 : await _handleEphemerals(
2184 : room,
2185 : ephemeral,
2186 : );
2187 : }
2188 :
2189 31 : final accountData = syncRoomUpdate.accountData;
2190 31 : if (accountData != null && accountData.isNotEmpty) {
2191 31 : await _handleRoomEvents(
2192 : room,
2193 : accountData,
2194 : EventUpdateType.accountData,
2195 : );
2196 : }
2197 : }
2198 :
2199 31 : if (syncRoomUpdate is LeftRoomUpdate) {
2200 62 : final timelineEvents = syncRoomUpdate.timeline?.events;
2201 31 : if (timelineEvents != null && timelineEvents.isNotEmpty) {
2202 31 : await _handleRoomEvents(room, timelineEvents, timelineUpdateType,
2203 : store: false);
2204 : }
2205 31 : final accountData = syncRoomUpdate.accountData;
2206 31 : if (accountData != null && accountData.isNotEmpty) {
2207 31 : await _handleRoomEvents(
2208 : room, accountData, EventUpdateType.accountData,
2209 : store: false);
2210 : }
2211 31 : final state = syncRoomUpdate.state;
2212 31 : if (state != null && state.isNotEmpty) {
2213 31 : await _handleRoomEvents(room, state, EventUpdateType.state,
2214 : store: false);
2215 : }
2216 : }
2217 :
2218 31 : if (syncRoomUpdate is InvitedRoomUpdate) {
2219 31 : final state = syncRoomUpdate.inviteState;
2220 31 : if (state != null && state.isNotEmpty) {
2221 31 : await _handleRoomEvents(room, state, EventUpdateType.inviteState);
2222 : }
2223 : }
2224 89 : await database?.storeRoomUpdate(id, syncRoomUpdate, room.lastEvent, this);
2225 : }
2226 : }
2227 :
2228 31 : Future<void> _handleEphemerals(Room room, List<BasicRoomEvent> events) async {
2229 31 : final List<ReceiptEventContent> receipts = [];
2230 :
2231 62 : for (final event in events) {
2232 62 : await _handleRoomEvents(room, [event], EventUpdateType.ephemeral);
2233 :
2234 : // Receipt events are deltas between two states. We will create a
2235 : // fake room account data event for this and store the difference
2236 : // there.
2237 62 : if (event.type != 'm.receipt') continue;
2238 :
2239 93 : receipts.add(ReceiptEventContent.fromJson(event.content));
2240 : }
2241 :
2242 31 : if (receipts.isNotEmpty) {
2243 31 : final receiptStateContent = room.receiptState;
2244 :
2245 62 : for (final e in receipts) {
2246 31 : await receiptStateContent.update(e, room);
2247 : }
2248 :
2249 31 : await _handleRoomEvents(
2250 : room,
2251 31 : [
2252 31 : BasicRoomEvent(
2253 : type: LatestReceiptState.eventType,
2254 31 : roomId: room.id,
2255 31 : content: receiptStateContent.toJson(),
2256 : )
2257 : ],
2258 : EventUpdateType.accountData);
2259 : }
2260 : }
2261 :
2262 : /// Stores event that came down /sync but didn't get decrypted because of missing keys yet.
2263 : final List<_EventPendingDecryption> _eventsPendingDecryption = [];
2264 :
2265 31 : Future<void> _handleRoomEvents(
2266 : Room room, List<BasicEvent> events, EventUpdateType type,
2267 : {bool store = true}) async {
2268 : // Calling events can be omitted if they are outdated from the same sync. So
2269 : // we collect them first before we handle them.
2270 31 : final callEvents = <Event>[];
2271 :
2272 62 : for (final event in events) {
2273 : // The client must ignore any new m.room.encryption event to prevent
2274 : // man-in-the-middle attacks!
2275 62 : if ((event.type == EventTypes.Encryption &&
2276 31 : room.encrypted &&
2277 3 : event.content.tryGet<String>('algorithm') !=
2278 : room
2279 1 : .getState(EventTypes.Encryption)
2280 1 : ?.content
2281 1 : .tryGet<String>('algorithm'))) {
2282 : continue;
2283 : }
2284 :
2285 : var update =
2286 93 : EventUpdate(roomID: room.id, type: type, content: event.toJson());
2287 65 : if (event.type == EventTypes.Encrypted && encryptionEnabled) {
2288 2 : update = await update.decrypt(room);
2289 :
2290 : // if the event failed to decrypt, add it to the queue
2291 6 : if (update.content.tryGet<String>('type') == EventTypes.Encrypted) {
2292 8 : _eventsPendingDecryption.add(_EventPendingDecryption(EventUpdate(
2293 2 : roomID: update.roomID,
2294 : type: EventUpdateType.decryptedTimelineQueue,
2295 2 : content: update.content)));
2296 : }
2297 : }
2298 62 : if (event.type == EventTypes.Message &&
2299 31 : !room.isDirectChat &&
2300 31 : database != null &&
2301 29 : event is MatrixEvent &&
2302 58 : room.getState(EventTypes.RoomMember, event.senderId) == null) {
2303 : // In order to correctly render room list previews we need to fetch the member from the database
2304 87 : final user = await database?.getUser(event.senderId, room);
2305 : if (user != null) {
2306 29 : room.setState(user);
2307 : }
2308 : }
2309 31 : _updateRoomsByEventUpdate(room, update);
2310 31 : if (type != EventUpdateType.ephemeral && store) {
2311 60 : await database?.storeEventUpdate(update, this);
2312 : }
2313 31 : if (encryptionEnabled) {
2314 48 : await encryption?.handleEventUpdate(update);
2315 : }
2316 62 : onEvent.add(update);
2317 :
2318 31 : if (prevBatch != null &&
2319 14 : (type == EventUpdateType.timeline ||
2320 6 : type == EventUpdateType.decryptedTimelineQueue)) {
2321 14 : if ((update.content
2322 14 : .tryGet<String>('type')
2323 28 : ?.startsWith(CallConstants.callEventsRegxp) ??
2324 : false)) {
2325 4 : final callEvent = Event.fromJson(update.content, room);
2326 2 : callEvents.add(callEvent);
2327 : }
2328 : }
2329 : }
2330 31 : if (callEvents.isNotEmpty) {
2331 4 : onCallEvents.add(callEvents);
2332 : }
2333 : }
2334 :
2335 : /// stores when we last checked for stale calls
2336 : DateTime lastStaleCallRun = DateTime(0);
2337 :
2338 31 : Future<Room> _updateRoomsByRoomUpdate(
2339 : String roomId, SyncRoomUpdate chatUpdate) async {
2340 : // Update the chat list item.
2341 : // Search the room in the rooms
2342 155 : final roomIndex = rooms.indexWhere((r) => r.id == roomId);
2343 62 : final found = roomIndex != -1;
2344 31 : final membership = chatUpdate is LeftRoomUpdate
2345 : ? Membership.leave
2346 31 : : chatUpdate is InvitedRoomUpdate
2347 : ? Membership.invite
2348 : : Membership.join;
2349 :
2350 : final room = found
2351 24 : ? rooms[roomIndex]
2352 31 : : (chatUpdate is JoinedRoomUpdate
2353 31 : ? Room(
2354 : id: roomId,
2355 : membership: membership,
2356 62 : prev_batch: chatUpdate.timeline?.prevBatch,
2357 : highlightCount:
2358 62 : chatUpdate.unreadNotifications?.highlightCount ?? 0,
2359 : notificationCount:
2360 62 : chatUpdate.unreadNotifications?.notificationCount ?? 0,
2361 31 : summary: chatUpdate.summary,
2362 : client: this,
2363 : )
2364 31 : : Room(id: roomId, membership: membership, client: this));
2365 :
2366 : // Does the chat already exist in the list rooms?
2367 31 : if (!found && membership != Membership.leave) {
2368 : // Check if the room is not in the rooms in the invited list
2369 62 : if (_archivedRooms.isNotEmpty) {
2370 12 : _archivedRooms.removeWhere((archive) => archive.room.id == roomId);
2371 : }
2372 93 : final position = membership == Membership.invite ? 0 : rooms.length;
2373 : // Add the new chat to the list
2374 62 : rooms.insert(position, room);
2375 : }
2376 : // If the membership is "leave" then remove the item and stop here
2377 12 : else if (found && membership == Membership.leave) {
2378 0 : rooms.removeAt(roomIndex);
2379 :
2380 : // in order to keep the archive in sync, add left room to archive
2381 0 : if (chatUpdate is LeftRoomUpdate) {
2382 0 : await _storeArchivedRoom(room.id, chatUpdate, leftRoom: room);
2383 : }
2384 : }
2385 : // Update notification, highlight count and/or additional information
2386 : else if (found &&
2387 12 : chatUpdate is JoinedRoomUpdate &&
2388 48 : (rooms[roomIndex].membership != membership ||
2389 48 : rooms[roomIndex].notificationCount !=
2390 12 : (chatUpdate.unreadNotifications?.notificationCount ?? 0) ||
2391 48 : rooms[roomIndex].highlightCount !=
2392 12 : (chatUpdate.unreadNotifications?.highlightCount ?? 0) ||
2393 12 : chatUpdate.summary != null ||
2394 24 : chatUpdate.timeline?.prevBatch != null)) {
2395 12 : rooms[roomIndex].membership = membership;
2396 12 : rooms[roomIndex].notificationCount =
2397 5 : chatUpdate.unreadNotifications?.notificationCount ?? 0;
2398 12 : rooms[roomIndex].highlightCount =
2399 5 : chatUpdate.unreadNotifications?.highlightCount ?? 0;
2400 8 : if (chatUpdate.timeline?.prevBatch != null) {
2401 10 : rooms[roomIndex].prev_batch = chatUpdate.timeline?.prevBatch;
2402 : }
2403 :
2404 4 : final summary = chatUpdate.summary;
2405 : if (summary != null) {
2406 4 : final roomSummaryJson = rooms[roomIndex].summary.toJson()
2407 2 : ..addAll(summary.toJson());
2408 4 : rooms[roomIndex].summary = RoomSummary.fromJson(roomSummaryJson);
2409 : }
2410 : // ignore: deprecated_member_use_from_same_package
2411 28 : rooms[roomIndex].onUpdate.add(rooms[roomIndex].id);
2412 8 : if ((chatUpdate.timeline?.limited ?? false) &&
2413 1 : requestHistoryOnLimitedTimeline) {
2414 0 : Logs().v(
2415 0 : 'Limited timeline for ${rooms[roomIndex].id} request history now');
2416 0 : runInRoot(rooms[roomIndex].requestHistory);
2417 : }
2418 : }
2419 : return room;
2420 : }
2421 :
2422 31 : void _updateRoomsByEventUpdate(Room room, EventUpdate eventUpdate) {
2423 62 : if (eventUpdate.type == EventUpdateType.history) return;
2424 :
2425 31 : switch (eventUpdate.type) {
2426 31 : case EventUpdateType.inviteState:
2427 93 : room.setState(StrippedStateEvent.fromJson(eventUpdate.content));
2428 : break;
2429 31 : case EventUpdateType.state:
2430 31 : case EventUpdateType.timeline:
2431 62 : final event = Event.fromJson(eventUpdate.content, room);
2432 :
2433 : // Update the room state:
2434 31 : if (event.stateKey != null &&
2435 124 : (!room.partial || importantStateEvents.contains(event.type))) {
2436 31 : room.setState(event);
2437 : }
2438 62 : if (eventUpdate.type != EventUpdateType.timeline) break;
2439 :
2440 : // If last event is null or not a valid room preview event anyway,
2441 : // just use this:
2442 31 : if (room.lastEvent == null) {
2443 31 : room.lastEvent = event;
2444 : break;
2445 : }
2446 :
2447 : // Is this event redacting the last event?
2448 62 : if (event.type == EventTypes.Redaction &&
2449 : ({
2450 4 : room.lastEvent?.eventId,
2451 4 : room.lastEvent?.relationshipEventId
2452 2 : }.contains(
2453 6 : event.redacts ?? event.content.tryGet<String>('redacts')))) {
2454 4 : room.lastEvent?.setRedactionEvent(event);
2455 : break;
2456 : }
2457 :
2458 : // Is this event an edit of the last event? Otherwise ignore it.
2459 62 : if (event.relationshipType == RelationshipTypes.edit) {
2460 12 : if (event.relationshipEventId == room.lastEvent?.eventId ||
2461 9 : (room.lastEvent?.relationshipType == RelationshipTypes.edit &&
2462 6 : event.relationshipEventId ==
2463 6 : room.lastEvent?.relationshipEventId)) {
2464 3 : room.lastEvent = event;
2465 : }
2466 : break;
2467 : }
2468 :
2469 : // Is this event of an important type for the last event?
2470 93 : if (!roomPreviewLastEvents.contains(event.type)) break;
2471 :
2472 : // Event is a valid new lastEvent:
2473 31 : room.lastEvent = event;
2474 :
2475 : break;
2476 31 : case EventUpdateType.accountData:
2477 124 : room.roomAccountData[eventUpdate.content['type']] =
2478 62 : BasicRoomEvent.fromJson(eventUpdate.content);
2479 : break;
2480 31 : case EventUpdateType.ephemeral:
2481 93 : room.setEphemeral(BasicRoomEvent.fromJson(eventUpdate.content));
2482 : break;
2483 0 : case EventUpdateType.history:
2484 0 : case EventUpdateType.decryptedTimelineQueue:
2485 : break;
2486 : }
2487 : // ignore: deprecated_member_use_from_same_package
2488 93 : room.onUpdate.add(room.id);
2489 : }
2490 :
2491 : bool _sortLock = false;
2492 :
2493 : /// If `true` then unread rooms are pinned at the top of the room list.
2494 : bool pinUnreadRooms;
2495 :
2496 : /// If `true` then unread rooms are pinned at the top of the room list.
2497 : bool pinInvitedRooms;
2498 :
2499 : /// The compare function how the rooms should be sorted internally. By default
2500 : /// rooms are sorted by timestamp of the last m.room.message event or the last
2501 : /// event if there is no known message.
2502 62 : RoomSorter get sortRoomsBy => (a, b) {
2503 31 : if (pinInvitedRooms &&
2504 93 : a.membership != b.membership &&
2505 186 : [a.membership, b.membership].any((m) => m == Membership.invite)) {
2506 93 : return a.membership == Membership.invite ? -1 : 1;
2507 93 : } else if (a.isFavourite != b.isFavourite) {
2508 4 : return a.isFavourite ? -1 : 1;
2509 31 : } else if (pinUnreadRooms &&
2510 0 : a.notificationCount != b.notificationCount) {
2511 0 : return b.notificationCount.compareTo(a.notificationCount);
2512 : } else {
2513 62 : return b.timeCreated.millisecondsSinceEpoch
2514 93 : .compareTo(a.timeCreated.millisecondsSinceEpoch);
2515 : }
2516 : };
2517 :
2518 31 : void _sortRooms() {
2519 124 : if (_sortLock || rooms.length < 2) return;
2520 31 : _sortLock = true;
2521 93 : rooms.sort(sortRoomsBy);
2522 31 : _sortLock = false;
2523 : }
2524 :
2525 : Future? userDeviceKeysLoading;
2526 : Future? roomsLoading;
2527 : Future? _accountDataLoading;
2528 : Future? firstSyncReceived;
2529 :
2530 46 : Future? get accountDataLoading => _accountDataLoading;
2531 :
2532 : /// A map of known device keys per user.
2533 50 : Map<String, DeviceKeysList> get userDeviceKeys => _userDeviceKeys;
2534 : Map<String, DeviceKeysList> _userDeviceKeys = {};
2535 :
2536 : /// A list of all not verified and not blocked device keys. Clients should
2537 : /// display a warning if this list is not empty and suggest the user to
2538 : /// verify or block those devices.
2539 0 : List<DeviceKeys> get unverifiedDevices {
2540 0 : final userId = userID;
2541 0 : if (userId == null) return [];
2542 0 : return userDeviceKeys[userId]
2543 0 : ?.deviceKeys
2544 0 : .values
2545 0 : .where((deviceKey) => !deviceKey.verified && !deviceKey.blocked)
2546 0 : .toList() ??
2547 0 : [];
2548 : }
2549 :
2550 : /// Gets user device keys by its curve25519 key. Returns null if it isn't found
2551 23 : DeviceKeys? getUserDeviceKeysByCurve25519Key(String senderKey) {
2552 55 : for (final user in userDeviceKeys.values) {
2553 18 : final device = user.deviceKeys.values
2554 36 : .firstWhereOrNull((e) => e.curve25519Key == senderKey);
2555 : if (device != null) {
2556 : return device;
2557 : }
2558 : }
2559 : return null;
2560 : }
2561 :
2562 29 : Future<Set<String>> _getUserIdsInEncryptedRooms() async {
2563 : final userIds = <String>{};
2564 58 : for (final room in rooms) {
2565 87 : if (room.encrypted && room.membership == Membership.join) {
2566 : try {
2567 29 : final userList = await room.requestParticipants();
2568 58 : for (final user in userList) {
2569 29 : if ([Membership.join, Membership.invite]
2570 58 : .contains(user.membership)) {
2571 58 : userIds.add(user.id);
2572 : }
2573 : }
2574 : } catch (e, s) {
2575 0 : Logs().e('[E2EE] Failed to fetch participants', e, s);
2576 : }
2577 : }
2578 : }
2579 : return userIds;
2580 : }
2581 :
2582 : final Map<String, DateTime> _keyQueryFailures = {};
2583 :
2584 31 : Future<void> updateUserDeviceKeys({Set<String>? additionalUsers}) async {
2585 : try {
2586 31 : final database = this.database;
2587 31 : if (!isLogged() || database == null) return;
2588 29 : final dbActions = <Future<dynamic> Function()>[];
2589 29 : final trackedUserIds = await _getUserIdsInEncryptedRooms();
2590 29 : if (!isLogged()) return;
2591 58 : trackedUserIds.add(userID!);
2592 1 : if (additionalUsers != null) trackedUserIds.addAll(additionalUsers);
2593 :
2594 : // Remove all userIds we no longer need to track the devices of.
2595 29 : _userDeviceKeys
2596 47 : .removeWhere((String userId, v) => !trackedUserIds.contains(userId));
2597 :
2598 : // Check if there are outdated device key lists. Add it to the set.
2599 29 : final outdatedLists = <String, List<String>>{};
2600 59 : for (final userId in (additionalUsers ?? <String>[])) {
2601 2 : outdatedLists[userId] = [];
2602 : }
2603 58 : for (final userId in trackedUserIds) {
2604 : final deviceKeysList =
2605 87 : _userDeviceKeys[userId] ??= DeviceKeysList(userId, this);
2606 87 : final failure = _keyQueryFailures[userId.domain];
2607 :
2608 : // deviceKeysList.outdated is not nullable but we have seen this error
2609 : // in production: `Failed assertion: boolean expression must not be null`
2610 : // So this could either be a null safety bug in Dart or a result of
2611 : // using unsound null safety. The extra equal check `!= false` should
2612 : // save us here.
2613 58 : if (deviceKeysList.outdated != false &&
2614 : (failure == null ||
2615 0 : DateTime.now()
2616 0 : .subtract(Duration(minutes: 5))
2617 0 : .isAfter(failure))) {
2618 58 : outdatedLists[userId] = [];
2619 : }
2620 : }
2621 :
2622 29 : if (outdatedLists.isNotEmpty) {
2623 : // Request the missing device key lists from the server.
2624 29 : final response = await queryKeys(outdatedLists, timeout: 10000);
2625 29 : if (!isLogged()) return;
2626 :
2627 29 : final deviceKeys = response.deviceKeys;
2628 : if (deviceKeys != null) {
2629 58 : for (final rawDeviceKeyListEntry in deviceKeys.entries) {
2630 29 : final userId = rawDeviceKeyListEntry.key;
2631 : final userKeys =
2632 87 : _userDeviceKeys[userId] ??= DeviceKeysList(userId, this);
2633 58 : final oldKeys = Map<String, DeviceKeys>.from(userKeys.deviceKeys);
2634 58 : userKeys.deviceKeys = {};
2635 : for (final rawDeviceKeyEntry
2636 87 : in rawDeviceKeyListEntry.value.entries) {
2637 29 : final deviceId = rawDeviceKeyEntry.key;
2638 :
2639 : // Set the new device key for this device
2640 29 : final entry = DeviceKeys.fromMatrixDeviceKeys(
2641 61 : rawDeviceKeyEntry.value, this, oldKeys[deviceId]?.lastActive);
2642 29 : final ed25519Key = entry.ed25519Key;
2643 29 : final curve25519Key = entry.curve25519Key;
2644 29 : if (entry.isValid &&
2645 58 : deviceId == entry.deviceId &&
2646 : ed25519Key != null &&
2647 : curve25519Key != null) {
2648 : // Check if deviceId or deviceKeys are known
2649 29 : if (!oldKeys.containsKey(deviceId)) {
2650 : final oldPublicKeys =
2651 29 : await database.deviceIdSeen(userId, deviceId);
2652 : if (oldPublicKeys != null &&
2653 4 : oldPublicKeys != curve25519Key + ed25519Key) {
2654 2 : Logs().w(
2655 : 'Already seen Device ID has been added again. This might be an attack!');
2656 : continue;
2657 : }
2658 29 : final oldDeviceId = await database.publicKeySeen(ed25519Key);
2659 2 : if (oldDeviceId != null && oldDeviceId != deviceId) {
2660 0 : Logs().w(
2661 : 'Already seen ED25519 has been added again. This might be an attack!');
2662 : continue;
2663 : }
2664 : final oldDeviceId2 =
2665 29 : await database.publicKeySeen(curve25519Key);
2666 2 : if (oldDeviceId2 != null && oldDeviceId2 != deviceId) {
2667 0 : Logs().w(
2668 : 'Already seen Curve25519 has been added again. This might be an attack!');
2669 : continue;
2670 : }
2671 29 : await database.addSeenDeviceId(
2672 29 : userId, deviceId, curve25519Key + ed25519Key);
2673 29 : await database.addSeenPublicKey(ed25519Key, deviceId);
2674 29 : await database.addSeenPublicKey(curve25519Key, deviceId);
2675 : }
2676 :
2677 : // is this a new key or the same one as an old one?
2678 : // better store an update - the signatures might have changed!
2679 29 : final oldKey = oldKeys[deviceId];
2680 : if (oldKey == null ||
2681 9 : (oldKey.ed25519Key == entry.ed25519Key &&
2682 9 : oldKey.curve25519Key == entry.curve25519Key)) {
2683 : if (oldKey != null) {
2684 : // be sure to save the verified status
2685 6 : entry.setDirectVerified(oldKey.directVerified);
2686 6 : entry.blocked = oldKey.blocked;
2687 6 : entry.validSignatures = oldKey.validSignatures;
2688 : }
2689 58 : userKeys.deviceKeys[deviceId] = entry;
2690 58 : if (deviceId == deviceID &&
2691 87 : entry.ed25519Key == fingerprintKey) {
2692 : // Always trust the own device
2693 23 : entry.setDirectVerified(true);
2694 : }
2695 87 : dbActions.add(() => database.storeUserDeviceKey(
2696 : userId,
2697 : deviceId,
2698 58 : json.encode(entry.toJson()),
2699 29 : entry.directVerified,
2700 29 : entry.blocked,
2701 58 : entry.lastActive.millisecondsSinceEpoch,
2702 : ));
2703 0 : } else if (oldKeys.containsKey(deviceId)) {
2704 : // This shouldn't ever happen. The same device ID has gotten
2705 : // a new public key. So we ignore the update. TODO: ask krille
2706 : // if we should instead use the new key with unknown verified / blocked status
2707 0 : userKeys.deviceKeys[deviceId] = oldKeys[deviceId]!;
2708 : }
2709 : } else {
2710 0 : Logs().w('Invalid device ${entry.userId}:${entry.deviceId}');
2711 : }
2712 : }
2713 : // delete old/unused entries
2714 32 : for (final oldDeviceKeyEntry in oldKeys.entries) {
2715 3 : final deviceId = oldDeviceKeyEntry.key;
2716 6 : if (!userKeys.deviceKeys.containsKey(deviceId)) {
2717 : // we need to remove an old key
2718 : dbActions
2719 3 : .add(() => database.removeUserDeviceKey(userId, deviceId));
2720 : }
2721 : }
2722 29 : userKeys.outdated = false;
2723 : dbActions
2724 87 : .add(() => database.storeUserDeviceKeysInfo(userId, false));
2725 : }
2726 : }
2727 : // next we parse and persist the cross signing keys
2728 29 : final crossSigningTypes = {
2729 29 : 'master': response.masterKeys,
2730 29 : 'self_signing': response.selfSigningKeys,
2731 29 : 'user_signing': response.userSigningKeys,
2732 : };
2733 58 : for (final crossSigningKeysEntry in crossSigningTypes.entries) {
2734 29 : final keyType = crossSigningKeysEntry.key;
2735 29 : final keys = crossSigningKeysEntry.value;
2736 : if (keys == null) {
2737 : continue;
2738 : }
2739 58 : for (final crossSigningKeyListEntry in keys.entries) {
2740 29 : final userId = crossSigningKeyListEntry.key;
2741 : final userKeys =
2742 58 : _userDeviceKeys[userId] ??= DeviceKeysList(userId, this);
2743 : final oldKeys =
2744 58 : Map<String, CrossSigningKey>.from(userKeys.crossSigningKeys);
2745 58 : userKeys.crossSigningKeys = {};
2746 : // add the types we aren't handling atm back
2747 58 : for (final oldEntry in oldKeys.entries) {
2748 87 : if (!oldEntry.value.usage.contains(keyType)) {
2749 116 : userKeys.crossSigningKeys[oldEntry.key] = oldEntry.value;
2750 : } else {
2751 : // There is a previous cross-signing key with this usage, that we no
2752 : // longer need/use. Clear it from the database.
2753 6 : dbActions.add(() =>
2754 6 : database.removeUserCrossSigningKey(userId, oldEntry.key));
2755 : }
2756 : }
2757 29 : final entry = CrossSigningKey.fromMatrixCrossSigningKey(
2758 29 : crossSigningKeyListEntry.value, this);
2759 29 : final publicKey = entry.publicKey;
2760 29 : if (entry.isValid && publicKey != null) {
2761 29 : final oldKey = oldKeys[publicKey];
2762 9 : if (oldKey == null || oldKey.ed25519Key == entry.ed25519Key) {
2763 : if (oldKey != null) {
2764 : // be sure to save the verification status
2765 6 : entry.setDirectVerified(oldKey.directVerified);
2766 6 : entry.blocked = oldKey.blocked;
2767 6 : entry.validSignatures = oldKey.validSignatures;
2768 : }
2769 58 : userKeys.crossSigningKeys[publicKey] = entry;
2770 : } else {
2771 : // This shouldn't ever happen. The same device ID has gotten
2772 : // a new public key. So we ignore the update. TODO: ask krille
2773 : // if we should instead use the new key with unknown verified / blocked status
2774 0 : userKeys.crossSigningKeys[publicKey] = oldKey;
2775 : }
2776 87 : dbActions.add(() => database.storeUserCrossSigningKey(
2777 : userId,
2778 : publicKey,
2779 58 : json.encode(entry.toJson()),
2780 29 : entry.directVerified,
2781 29 : entry.blocked,
2782 : ));
2783 : }
2784 87 : _userDeviceKeys[userId]?.outdated = false;
2785 : dbActions
2786 87 : .add(() => database.storeUserDeviceKeysInfo(userId, false));
2787 : }
2788 : }
2789 :
2790 : // now process all the failures
2791 29 : if (response.failures != null) {
2792 87 : for (final failureDomain in response.failures?.keys ?? <String>[]) {
2793 0 : _keyQueryFailures[failureDomain] = DateTime.now();
2794 : }
2795 : }
2796 : }
2797 :
2798 29 : if (dbActions.isNotEmpty) {
2799 29 : if (!isLogged()) return;
2800 58 : await database.transaction(() async {
2801 58 : for (final f in dbActions) {
2802 29 : await f();
2803 : }
2804 : });
2805 : }
2806 : } catch (e, s) {
2807 0 : Logs().e('[LibOlm] Unable to update user device keys', e, s);
2808 : }
2809 : }
2810 :
2811 : bool _toDeviceQueueNeedsProcessing = true;
2812 :
2813 : /// Processes the to_device queue and tries to send every entry.
2814 : /// This function MAY throw an error, which just means the to_device queue wasn't
2815 : /// proccessed all the way.
2816 31 : Future<void> processToDeviceQueue() async {
2817 31 : final database = this.database;
2818 29 : if (database == null || !_toDeviceQueueNeedsProcessing) {
2819 : return;
2820 : }
2821 29 : final entries = await database.getToDeviceEventQueue();
2822 29 : if (entries.isEmpty) {
2823 29 : _toDeviceQueueNeedsProcessing = false;
2824 : return;
2825 : }
2826 2 : for (final entry in entries) {
2827 : // Convert the Json Map to the correct format regarding
2828 : // https: //matrix.org/docs/spec/client_server/r0.6.1#put-matrix-client-r0-sendtodevice-eventtype-txnid
2829 3 : final data = entry.content.map((k, v) =>
2830 1 : MapEntry<String, Map<String, Map<String, dynamic>>>(
2831 : k,
2832 3 : (v as Map).map((k, v) => MapEntry<String, Map<String, dynamic>>(
2833 1 : k, Map<String, dynamic>.from(v)))));
2834 :
2835 : try {
2836 3 : await super.sendToDevice(entry.type, entry.txnId, data);
2837 1 : } on MatrixException catch (e) {
2838 0 : Logs().w(
2839 0 : '[To-Device] failed to to_device message from the queue to the server. Ignoring error: $e');
2840 0 : Logs().w('Payload: $data');
2841 : }
2842 2 : await database.deleteFromToDeviceQueue(entry.id);
2843 : }
2844 : }
2845 :
2846 : /// Sends a raw to_device event with a [eventType], a [txnId] and a content
2847 : /// [messages]. Before sending, it tries to re-send potentially queued
2848 : /// to_device events and adds the current one to the queue, should it fail.
2849 10 : @override
2850 : Future<void> sendToDevice(
2851 : String eventType,
2852 : String txnId,
2853 : Map<String, Map<String, Map<String, dynamic>>> messages,
2854 : ) async {
2855 : try {
2856 10 : await processToDeviceQueue();
2857 10 : await super.sendToDevice(eventType, txnId, messages);
2858 : } catch (e, s) {
2859 2 : Logs().w(
2860 : '[Client] Problem while sending to_device event, retrying later...',
2861 : e,
2862 : s);
2863 1 : final database = this.database;
2864 : if (database != null) {
2865 1 : _toDeviceQueueNeedsProcessing = true;
2866 1 : await database.insertIntoToDeviceQueue(
2867 1 : eventType, txnId, json.encode(messages));
2868 : }
2869 : rethrow;
2870 : }
2871 : }
2872 :
2873 : /// Send an (unencrypted) to device [message] of a specific [eventType] to all
2874 : /// devices of a set of [users].
2875 2 : Future<void> sendToDevicesOfUserIds(
2876 : Set<String> users,
2877 : String eventType,
2878 : Map<String, dynamic> message, {
2879 : String? messageId,
2880 : }) async {
2881 : // Send with send-to-device messaging
2882 2 : final data = <String, Map<String, Map<String, dynamic>>>{};
2883 3 : for (final user in users) {
2884 2 : data[user] = {'*': message};
2885 : }
2886 2 : await sendToDevice(
2887 2 : eventType, messageId ?? generateUniqueTransactionId(), data);
2888 : return;
2889 : }
2890 :
2891 : final MultiLock<DeviceKeys> _sendToDeviceEncryptedLock = MultiLock();
2892 :
2893 : /// Sends an encrypted [message] of this [eventType] to these [deviceKeys].
2894 9 : Future<void> sendToDeviceEncrypted(
2895 : List<DeviceKeys> deviceKeys,
2896 : String eventType,
2897 : Map<String, dynamic> message, {
2898 : String? messageId,
2899 : bool onlyVerified = false,
2900 : }) async {
2901 9 : final encryption = this.encryption;
2902 9 : if (!encryptionEnabled || encryption == null) return;
2903 : // Don't send this message to blocked devices, and if specified onlyVerified
2904 : // then only send it to verified devices
2905 9 : if (deviceKeys.isNotEmpty) {
2906 18 : deviceKeys.removeWhere((DeviceKeys deviceKeys) =>
2907 9 : deviceKeys.blocked ||
2908 42 : (deviceKeys.userId == userID && deviceKeys.deviceId == deviceID) ||
2909 0 : (onlyVerified && !deviceKeys.verified));
2910 9 : if (deviceKeys.isEmpty) return;
2911 : }
2912 :
2913 : // So that we can guarantee order of encrypted to_device messages to be preserved we
2914 : // must ensure that we don't attempt to encrypt multiple concurrent to_device messages
2915 : // to the same device at the same time.
2916 : // A failure to do so can result in edge-cases where encryption and sending order of
2917 : // said to_device messages does not match up, resulting in an olm session corruption.
2918 : // As we send to multiple devices at the same time, we may only proceed here if the lock for
2919 : // *all* of them is freed and lock *all* of them while sending.
2920 :
2921 : try {
2922 18 : await _sendToDeviceEncryptedLock.lock(deviceKeys);
2923 :
2924 : // Send with send-to-device messaging
2925 9 : final data = await encryption.encryptToDeviceMessage(
2926 : deviceKeys,
2927 : eventType,
2928 : message,
2929 : );
2930 : eventType = EventTypes.Encrypted;
2931 9 : await sendToDevice(
2932 9 : eventType, messageId ?? generateUniqueTransactionId(), data);
2933 : } finally {
2934 18 : _sendToDeviceEncryptedLock.unlock(deviceKeys);
2935 : }
2936 : }
2937 :
2938 : /// Sends an encrypted [message] of this [eventType] to these [deviceKeys].
2939 : /// This request happens partly in the background and partly in the
2940 : /// foreground. It automatically chunks sending to device keys based on
2941 : /// activity.
2942 5 : Future<void> sendToDeviceEncryptedChunked(
2943 : List<DeviceKeys> deviceKeys,
2944 : String eventType,
2945 : Map<String, dynamic> message,
2946 : ) async {
2947 5 : if (!encryptionEnabled) return;
2948 : // be sure to copy our device keys list
2949 5 : deviceKeys = List<DeviceKeys>.from(deviceKeys);
2950 9 : deviceKeys.removeWhere((DeviceKeys k) =>
2951 19 : k.blocked || (k.userId == userID && k.deviceId == deviceID));
2952 5 : if (deviceKeys.isEmpty) return;
2953 4 : message = message.copy(); // make sure we deep-copy the message
2954 : // make sure all the olm sessions are loaded from database
2955 16 : Logs().v('Sending to device chunked... (${deviceKeys.length} devices)');
2956 : // sort so that devices we last received messages from get our message first
2957 16 : deviceKeys.sort((keyA, keyB) => keyB.lastActive.compareTo(keyA.lastActive));
2958 : // and now send out in chunks of 20
2959 : const chunkSize = 20;
2960 :
2961 : // first we send out all the chunks that we await
2962 : var i = 0;
2963 : // we leave this in a for-loop for now, so that we can easily adjust the break condition
2964 : // based on other things, if we want to hard-`await` more devices in the future
2965 16 : for (; i < deviceKeys.length && i <= 0; i += chunkSize) {
2966 12 : Logs().v('Sending chunk $i...');
2967 4 : final chunk = deviceKeys.sublist(
2968 : i,
2969 12 : i + chunkSize > deviceKeys.length
2970 4 : ? deviceKeys.length
2971 1 : : i + chunkSize);
2972 : // and send
2973 4 : await sendToDeviceEncrypted(chunk, eventType, message);
2974 : }
2975 : // now send out the background chunks
2976 8 : if (i < deviceKeys.length) {
2977 : // ignore: unawaited_futures
2978 1 : () async {
2979 3 : for (; i < deviceKeys.length; i += chunkSize) {
2980 : // wait 50ms to not freeze the UI
2981 2 : await Future.delayed(Duration(milliseconds: 50));
2982 3 : Logs().v('Sending chunk $i...');
2983 1 : final chunk = deviceKeys.sublist(
2984 : i,
2985 3 : i + chunkSize > deviceKeys.length
2986 1 : ? deviceKeys.length
2987 0 : : i + chunkSize);
2988 : // and send
2989 1 : await sendToDeviceEncrypted(chunk, eventType, message);
2990 : }
2991 1 : }();
2992 : }
2993 : }
2994 :
2995 : /// Whether all push notifications are muted using the [.m.rule.master]
2996 : /// rule of the push rules: https://matrix.org/docs/spec/client_server/r0.6.0#m-rule-master
2997 0 : bool get allPushNotificationsMuted {
2998 : final Map<String, Object?>? globalPushRules =
2999 0 : _accountData[EventTypes.PushRules]
3000 0 : ?.content
3001 0 : .tryGetMap<String, Object?>('global');
3002 : if (globalPushRules == null) return false;
3003 :
3004 0 : final globalPushRulesOverride = globalPushRules.tryGetList('override');
3005 : if (globalPushRulesOverride != null) {
3006 0 : for (final pushRule in globalPushRulesOverride) {
3007 0 : if (pushRule['rule_id'] == '.m.rule.master') {
3008 0 : return pushRule['enabled'];
3009 : }
3010 : }
3011 : }
3012 : return false;
3013 : }
3014 :
3015 1 : Future<void> setMuteAllPushNotifications(bool muted) async {
3016 1 : await setPushRuleEnabled(
3017 : 'global',
3018 : PushRuleKind.override,
3019 : '.m.rule.master',
3020 : muted,
3021 : );
3022 : return;
3023 : }
3024 :
3025 : /// Changes the password. You should either set oldPasswort or another authentication flow.
3026 1 : @override
3027 : Future<void> changePassword(String newPassword,
3028 : {String? oldPassword,
3029 : AuthenticationData? auth,
3030 : bool? logoutDevices}) async {
3031 1 : final userID = this.userID;
3032 : try {
3033 : if (oldPassword != null && userID != null) {
3034 1 : auth = AuthenticationPassword(
3035 1 : identifier: AuthenticationUserIdentifier(user: userID),
3036 : password: oldPassword,
3037 : );
3038 : }
3039 1 : await super.changePassword(newPassword,
3040 : auth: auth, logoutDevices: logoutDevices);
3041 0 : } on MatrixException catch (matrixException) {
3042 0 : if (!matrixException.requireAdditionalAuthentication) {
3043 : rethrow;
3044 : }
3045 0 : if (matrixException.authenticationFlows?.length != 1 ||
3046 0 : !(matrixException.authenticationFlows?.first.stages
3047 0 : .contains(AuthenticationTypes.password) ??
3048 : false)) {
3049 : rethrow;
3050 : }
3051 : if (oldPassword == null || userID == null) {
3052 : rethrow;
3053 : }
3054 0 : return changePassword(
3055 : newPassword,
3056 0 : auth: AuthenticationPassword(
3057 0 : identifier: AuthenticationUserIdentifier(user: userID),
3058 : password: oldPassword,
3059 0 : session: matrixException.session,
3060 : ),
3061 : logoutDevices: logoutDevices,
3062 : );
3063 : } catch (_) {
3064 : rethrow;
3065 : }
3066 : }
3067 :
3068 : /// Clear all local cached messages, room information and outbound group
3069 : /// sessions and perform a new clean sync.
3070 2 : Future<void> clearCache() async {
3071 2 : await abortSync();
3072 2 : _prevBatch = null;
3073 4 : rooms.clear();
3074 4 : await database?.clearCache();
3075 6 : encryption?.keyManager.clearOutboundGroupSessions();
3076 4 : _eventsPendingDecryption.clear();
3077 4 : onCacheCleared.add(true);
3078 : // Restart the syncloop
3079 2 : backgroundSync = true;
3080 : }
3081 :
3082 : /// A list of mxids of users who are ignored.
3083 1 : List<String> get ignoredUsers =>
3084 3 : List<String>.from(_accountData['m.ignored_user_list']
3085 1 : ?.content
3086 1 : .tryGetMap<String, Object?>('ignored_users')
3087 1 : ?.keys ??
3088 1 : <String>[]);
3089 :
3090 : /// Ignore another user. This will clear the local cached messages to
3091 : /// hide all previous messages from this user.
3092 1 : Future<void> ignoreUser(String userId) async {
3093 1 : if (!userId.isValidMatrixId) {
3094 0 : throw Exception('$userId is not a valid mxid!');
3095 : }
3096 3 : await setAccountData(userID!, 'm.ignored_user_list', {
3097 1 : 'ignored_users': Map.fromEntries(
3098 6 : (ignoredUsers..add(userId)).map((key) => MapEntry(key, {}))),
3099 : });
3100 1 : await clearCache();
3101 : return;
3102 : }
3103 :
3104 : /// Unignore a user. This will clear the local cached messages and request
3105 : /// them again from the server to avoid gaps in the timeline.
3106 1 : Future<void> unignoreUser(String userId) async {
3107 1 : if (!userId.isValidMatrixId) {
3108 0 : throw Exception('$userId is not a valid mxid!');
3109 : }
3110 2 : if (!ignoredUsers.contains(userId)) {
3111 0 : throw Exception('$userId is not in the ignore list!');
3112 : }
3113 3 : await setAccountData(userID!, 'm.ignored_user_list', {
3114 1 : 'ignored_users': Map.fromEntries(
3115 3 : (ignoredUsers..remove(userId)).map((key) => MapEntry(key, {}))),
3116 : });
3117 1 : await clearCache();
3118 : return;
3119 : }
3120 :
3121 : /// The newest presence of this user if there is any. Fetches it from the
3122 : /// database first and then from the server if necessary or returns offline.
3123 2 : Future<CachedPresence> fetchCurrentPresence(
3124 : String userId, {
3125 : bool fetchOnlyFromCached = false,
3126 : }) async {
3127 : // ignore: deprecated_member_use_from_same_package
3128 4 : final cachedPresence = presences[userId];
3129 : if (cachedPresence != null) {
3130 : return cachedPresence;
3131 : }
3132 :
3133 0 : final dbPresence = await database?.getPresence(userId);
3134 : // ignore: deprecated_member_use_from_same_package
3135 0 : if (dbPresence != null) return presences[userId] = dbPresence;
3136 :
3137 0 : if (fetchOnlyFromCached) return CachedPresence.neverSeen(userId);
3138 :
3139 : try {
3140 0 : final result = await getPresence(userId);
3141 0 : final presence = CachedPresence.fromPresenceResponse(result, userId);
3142 0 : await database?.storePresence(userId, presence);
3143 : // ignore: deprecated_member_use_from_same_package
3144 0 : return presences[userId] = presence;
3145 : } catch (e) {
3146 0 : final presence = CachedPresence.neverSeen(userId);
3147 0 : await database?.storePresence(userId, presence);
3148 : // ignore: deprecated_member_use_from_same_package
3149 0 : return presences[userId] = presence;
3150 : }
3151 : }
3152 :
3153 : bool _disposed = false;
3154 : bool _aborted = false;
3155 76 : Future _currentTransaction = Future.sync(() => {});
3156 :
3157 : /// Blackholes any ongoing sync call. Currently ongoing sync *processing* is
3158 : /// still going to be finished, new data is ignored.
3159 25 : Future<void> abortSync() async {
3160 25 : _aborted = true;
3161 25 : backgroundSync = false;
3162 50 : _currentSyncId = -1;
3163 : try {
3164 25 : await _currentTransaction;
3165 : } catch (_) {
3166 : // No-OP
3167 : }
3168 25 : _currentSync = null;
3169 : // reset _aborted for being able to restart the sync.
3170 25 : _aborted = false;
3171 : }
3172 :
3173 : /// Stops the synchronization and closes the database. After this
3174 : /// you can safely make this Client instance null.
3175 23 : Future<void> dispose({bool closeDatabase = true}) async {
3176 23 : _disposed = true;
3177 23 : await abortSync();
3178 43 : await encryption?.dispose();
3179 23 : _encryption = null;
3180 : try {
3181 : if (closeDatabase) {
3182 21 : final database = _database;
3183 21 : _database = null;
3184 : await database
3185 19 : ?.close()
3186 19 : .catchError((e, s) => Logs().w('Failed to close database: ', e, s));
3187 : }
3188 : } catch (error, stacktrace) {
3189 0 : Logs().w('Failed to close database: ', error, stacktrace);
3190 : }
3191 : return;
3192 : }
3193 :
3194 1 : Future<void> _migrateFromLegacyDatabase({
3195 : void Function()? onMigration,
3196 : }) async {
3197 2 : Logs().i('Check legacy database for migration data...');
3198 2 : final legacyDatabase = await legacyDatabaseBuilder?.call(this);
3199 2 : final migrateClient = await legacyDatabase?.getClient(clientName);
3200 1 : final database = this.database;
3201 :
3202 : if (migrateClient == null || legacyDatabase == null || database == null) {
3203 0 : await legacyDatabase?.close();
3204 0 : _initLock = false;
3205 : return;
3206 : }
3207 2 : Logs().i('Found data in the legacy database!');
3208 0 : onMigration?.call();
3209 2 : _id = migrateClient['client_id'];
3210 : final tokenExpiresAtMs =
3211 2 : int.tryParse(migrateClient.tryGet<String>('token_expires_at') ?? '');
3212 1 : await database.insertClient(
3213 1 : clientName,
3214 1 : migrateClient['homeserver_url'],
3215 1 : migrateClient['token'],
3216 : tokenExpiresAtMs == null
3217 : ? null
3218 0 : : DateTime.fromMillisecondsSinceEpoch(tokenExpiresAtMs),
3219 1 : migrateClient['refresh_token'],
3220 1 : migrateClient['user_id'],
3221 1 : migrateClient['device_id'],
3222 1 : migrateClient['device_name'],
3223 : null,
3224 1 : migrateClient['olm_account'],
3225 : );
3226 2 : Logs().d('Migrate SSSSCache...');
3227 2 : for (final type in cacheTypes) {
3228 1 : final ssssCache = await legacyDatabase.getSSSSCache(type);
3229 : if (ssssCache != null) {
3230 0 : Logs().d('Migrate $type...');
3231 0 : await database.storeSSSSCache(
3232 : type,
3233 0 : ssssCache.keyId ?? '',
3234 0 : ssssCache.ciphertext ?? '',
3235 0 : ssssCache.content ?? '',
3236 : );
3237 : }
3238 : }
3239 2 : Logs().d('Migrate OLM sessions...');
3240 : try {
3241 1 : final olmSessions = await legacyDatabase.getAllOlmSessions();
3242 2 : for (final identityKey in olmSessions.keys) {
3243 1 : final sessions = olmSessions[identityKey]!;
3244 2 : for (final sessionId in sessions.keys) {
3245 1 : final session = sessions[sessionId]!;
3246 1 : await database.storeOlmSession(
3247 : identityKey,
3248 1 : session['session_id'] as String,
3249 1 : session['pickle'] as String,
3250 1 : session['last_received'] as int,
3251 : );
3252 : }
3253 : }
3254 : } catch (e, s) {
3255 0 : Logs().e('Unable to migrate OLM sessions!', e, s);
3256 : }
3257 2 : Logs().d('Migrate Device Keys...');
3258 1 : final userDeviceKeys = await legacyDatabase.getUserDeviceKeys(this);
3259 2 : for (final userId in userDeviceKeys.keys) {
3260 3 : Logs().d('Migrate Device Keys of user $userId...');
3261 1 : final deviceKeysList = userDeviceKeys[userId];
3262 : for (final crossSigningKey
3263 4 : in deviceKeysList?.crossSigningKeys.values ?? <CrossSigningKey>[]) {
3264 1 : final pubKey = crossSigningKey.publicKey;
3265 : if (pubKey != null) {
3266 2 : Logs().d(
3267 3 : 'Migrate cross signing key with usage ${crossSigningKey.usage} and verified ${crossSigningKey.directVerified}...');
3268 1 : await database.storeUserCrossSigningKey(
3269 : userId,
3270 : pubKey,
3271 2 : jsonEncode(crossSigningKey.toJson()),
3272 1 : crossSigningKey.directVerified,
3273 1 : crossSigningKey.blocked,
3274 : );
3275 : }
3276 : }
3277 :
3278 : if (deviceKeysList != null) {
3279 3 : for (final deviceKeys in deviceKeysList.deviceKeys.values) {
3280 1 : final deviceId = deviceKeys.deviceId;
3281 : if (deviceId != null) {
3282 4 : Logs().d('Migrate device keys for ${deviceKeys.deviceId}...');
3283 1 : await database.storeUserDeviceKey(
3284 : userId,
3285 : deviceId,
3286 2 : jsonEncode(deviceKeys.toJson()),
3287 1 : deviceKeys.directVerified,
3288 1 : deviceKeys.blocked,
3289 2 : deviceKeys.lastActive.millisecondsSinceEpoch,
3290 : );
3291 : }
3292 : }
3293 2 : Logs().d('Migrate user device keys info...');
3294 2 : await database.storeUserDeviceKeysInfo(userId, deviceKeysList.outdated);
3295 : }
3296 : }
3297 2 : Logs().d('Migrate inbound group sessions...');
3298 : try {
3299 1 : final sessions = await legacyDatabase.getAllInboundGroupSessions();
3300 3 : for (var i = 0; i < sessions.length; i++) {
3301 4 : Logs().d('$i / ${sessions.length}');
3302 1 : final session = sessions[i];
3303 1 : await database.storeInboundGroupSession(
3304 1 : session.roomId,
3305 1 : session.sessionId,
3306 1 : session.pickle,
3307 1 : session.content,
3308 1 : session.indexes,
3309 1 : session.allowedAtIndex,
3310 1 : session.senderKey,
3311 1 : session.senderClaimedKeys,
3312 : );
3313 : }
3314 : } catch (e, s) {
3315 0 : Logs().e('Unable to migrate inbound group sessions!', e, s);
3316 : }
3317 :
3318 1 : await legacyDatabase.delete();
3319 :
3320 1 : _initLock = false;
3321 1 : return init(
3322 : waitForFirstSync: false,
3323 : waitUntilLoadCompletedLoaded: false,
3324 : );
3325 : }
3326 : }
3327 :
3328 : class SdkError {
3329 : dynamic exception;
3330 : StackTrace? stackTrace;
3331 :
3332 6 : SdkError({this.exception, this.stackTrace});
3333 : }
3334 :
3335 : class SyncConnectionException implements Exception {
3336 : final Object originalException;
3337 0 : SyncConnectionException(this.originalException);
3338 : }
3339 :
3340 : class SyncStatusUpdate {
3341 : final SyncStatus status;
3342 : final SdkError? error;
3343 : final double? progress;
3344 :
3345 31 : const SyncStatusUpdate(this.status, {this.error, this.progress});
3346 : }
3347 :
3348 : enum SyncStatus {
3349 : waitingForResponse,
3350 : processing,
3351 : cleaningUp,
3352 : finished,
3353 : error,
3354 : }
3355 :
3356 : class BadServerVersionsException implements Exception {
3357 : final Set<String> serverVersions, supportedVersions;
3358 :
3359 0 : BadServerVersionsException(this.serverVersions, this.supportedVersions);
3360 :
3361 0 : @override
3362 : String toString() =>
3363 0 : 'Server supports the versions: ${serverVersions.toString()} but this application is only compatible with ${supportedVersions.toString()}.';
3364 : }
3365 :
3366 : class BadServerLoginTypesException implements Exception {
3367 : final Set<String> serverLoginTypes, supportedLoginTypes;
3368 :
3369 0 : BadServerLoginTypesException(this.serverLoginTypes, this.supportedLoginTypes);
3370 :
3371 0 : @override
3372 : String toString() =>
3373 0 : 'Server supports the Login Types: ${serverLoginTypes.toString()} but this application is only compatible with ${supportedLoginTypes.toString()}.';
3374 : }
3375 :
3376 : class FileTooBigMatrixException extends MatrixException {
3377 : int actualFileSize;
3378 : int maxFileSize;
3379 :
3380 0 : static String _formatFileSize(int size) {
3381 0 : if (size < 1024) return '$size B';
3382 0 : final i = (log(size) / log(1024)).floor();
3383 0 : final num = (size / pow(1024, i));
3384 0 : final round = num.round();
3385 0 : final numString = round < 10
3386 0 : ? num.toStringAsFixed(2)
3387 0 : : round < 100
3388 0 : ? num.toStringAsFixed(1)
3389 0 : : round.toString();
3390 0 : return '$numString ${'kMGTPEZY'[i - 1]}B';
3391 : }
3392 :
3393 0 : FileTooBigMatrixException(this.actualFileSize, this.maxFileSize)
3394 0 : : super.fromJson({
3395 : 'errcode': MatrixError.M_TOO_LARGE,
3396 : 'error':
3397 0 : 'File size ${_formatFileSize(actualFileSize)} exceeds allowed maximum of ${_formatFileSize(maxFileSize)}'
3398 0 : });
3399 :
3400 0 : @override
3401 : String toString() =>
3402 0 : 'File size ${_formatFileSize(actualFileSize)} exceeds allowed maximum of ${_formatFileSize(maxFileSize)}';
3403 : }
3404 :
3405 : class ArchivedRoom {
3406 : final Room room;
3407 : final Timeline timeline;
3408 :
3409 3 : ArchivedRoom({required this.room, required this.timeline});
3410 : }
3411 :
3412 : /// An event that is waiting for a key to arrive to decrypt. Times out after some time.
3413 : class _EventPendingDecryption {
3414 : DateTime addedAt = DateTime.now();
3415 :
3416 : EventUpdate event;
3417 :
3418 0 : bool get timedOut =>
3419 0 : addedAt.add(Duration(minutes: 5)).isBefore(DateTime.now());
3420 :
3421 2 : _EventPendingDecryption(this.event);
3422 : }
|