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