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