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