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