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