diff --git a/CHANGELOG.md b/CHANGELOG.md index 953efe2c..d7cc8caa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## [0.22.7] - 16 November 2023 +- chore: incrementally add left rooms to archive (The one with the braid) +- chore: remove archived room on forget (#2) (Clemens-Toegel) +- chore: store states to archived rooms (#1) (Clemens-Toegel) +- chore: upgrade lints (Nicolas Werner) +- chore: use our custom reusable workflow to avoid manually configuring each publish job (td) +- fix: Code style (The one with the braid) +- fix: call hangup on timeout race condition (Karthikeyan S) +- fix: clear local database on logout even if server timesout (td) +- fix: hangup on call crash (Mohammad Reza Moradi) +- fix: stale call checker leaks memory (Nicolas Werner) + ## [0.22.6] - 23 October 2023 - fix: Do not convert linebreaks in pre blocks on markdown parsing (Krille) - refactor: Wait for room in sync until sync process and trigger cleanup call not before actually start clean up. (Krille) diff --git a/lib/src/client.dart b/lib/src/client.dart index c7515195..4a869079 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -554,10 +554,9 @@ class Client extends MatrixApi { /// including all persistent data from the store. @override Future logout() async { - // Upload keys to make sure all are cached on the next login. - await encryption?.keyManager.uploadInboundGroupSessions(); - try { + // Upload keys to make sure all are cached on the next login. + await encryption?.keyManager.uploadInboundGroupSessions(); await super.logout(); } catch (e, s) { Logs().e('Logout failed', e, s); @@ -934,6 +933,7 @@ class Client extends MatrixApi { final syncResp = await sync( filter: '{"room":{"include_leave":true,"timeline":{"limit":10}}}', timeout: _archiveCacheBusterTimeout, + setPresence: syncPresence, ); // wrap around and hope there are not more than 30 leaves in 2 minutes :) _archiveCacheBusterTimeout = (_archiveCacheBusterTimeout + 1) % 30; @@ -1742,6 +1742,8 @@ class Client extends MatrixApi { await processToDeviceQueue(); } catch (_) {} // we want to dispose any errors this throws + await singleShotStaleCallChecker(); + _retryDelay = Future.value(); onSyncStatus.add(SyncStatusUpdate(SyncStatus.finished)); } on MatrixException catch (e, s) { @@ -1791,12 +1793,13 @@ class Client extends MatrixApi { await _handleRooms(leave, direction: direction); } } - for (final newPresence in sync.presence ?? []) { + for (final newPresence in sync.presence ?? []) { final cachedPresence = CachedPresence.fromMatrixEvent(newPresence); presences[newPresence.senderId] = cachedPresence; // ignore: deprecated_member_use_from_same_package onPresence.add(newPresence); onPresenceChanged.add(cachedPresence); + await database?.storePresence(newPresence.senderId, cachedPresence); } for (final newAccountData in sync.accountData ?? []) { await database?.storeAccountData( @@ -2149,6 +2152,9 @@ class Client extends MatrixApi { } } + /// stores when we last checked for stale calls + DateTime lastStaleCallRun = DateTime(0); + Future _updateRoomsByRoomUpdate( String roomId, SyncRoomUpdate chatUpdate) async { // Update the chat list item. @@ -2189,9 +2195,6 @@ class Client extends MatrixApi { } // If the membership is "leave" then remove the item and stop here else if (found && membership == Membership.leave) { - // stop stale group call checker for left room. - room.stopStaleCallsChecker(room.id); - rooms.removeAt(roomIndex); // in order to keep the archive in sync, add left room to archive @@ -2924,6 +2927,25 @@ class Client extends MatrixApi { return; } + /// The newest presence of this user if there is any. Fetches it from the + /// database first and then from the server if necessary or returns offline. + Future fetchCurrentPresence(String userId) async { + final cachedPresence = presences[userId]; + if (cachedPresence != null) { + return cachedPresence; + } + + final dbPresence = await database?.getPresence(userId); + if (dbPresence != null) return presences[userId] = dbPresence; + + try { + final newPresence = await getPresence(userId); + return CachedPresence.fromPresenceResponse(newPresence, userId); + } catch (e) { + return CachedPresence.neverSeen(userId); + } + } + bool _disposed = false; bool _aborted = false; Future _currentTransaction = Future.sync(() => {}); diff --git a/lib/src/database/database_api.dart b/lib/src/database/database_api.dart index 589e7102..b33f3072 100644 --- a/lib/src/database/database_api.dart +++ b/lib/src/database/database_api.dart @@ -312,4 +312,8 @@ abstract class DatabaseApi { Future exportDump(); Future importDump(String export); + + Future storePresence(String userId, CachedPresence presence); + + Future getPresence(String userId); } diff --git a/lib/src/database/hive_collections_database.dart b/lib/src/database/hive_collections_database.dart index 117ce23f..e40262ba 100644 --- a/lib/src/database/hive_collections_database.dart +++ b/lib/src/database/hive_collections_database.dart @@ -1478,6 +1478,18 @@ class HiveCollectionsDatabase extends DatabaseApi { return raw; } + @override + Future storePresence(String userId, CachedPresence presence) => + _presencesBox.put(userId, presence.toJson()); + + @override + Future getPresence(String userId) async { + final rawPresence = await _presencesBox.get(userId); + if (rawPresence == null) return null; + + return CachedPresence.fromJson(copyMap(rawPresence)); + } + @override Future exportDump() async { final dataMap = { diff --git a/lib/src/database/hive_database.dart b/lib/src/database/hive_database.dart index f3bfa9c7..21ed897e 100644 --- a/lib/src/database/hive_database.dart +++ b/lib/src/database/hive_database.dart @@ -1450,6 +1450,18 @@ class FamedlySdkHiveDatabase extends DatabaseApi { return raw as String; } + @override + Future storePresence(String userId, CachedPresence presence) => + _presencesBox.put(userId, presence.toJson()); + + @override + Future getPresence(String userId) async { + final rawPresence = await _presencesBox.get(userId); + if (rawPresence == null) return null; + + return CachedPresence.fromJson(copyMap(rawPresence)); + } + @override Future exportDump() { // see no need to implement this in a deprecated part diff --git a/lib/src/presence.dart b/lib/src/presence.dart index b0a7086c..e249322e 100644 --- a/lib/src/presence.dart +++ b/lib/src/presence.dart @@ -25,6 +25,36 @@ class CachedPresence { bool? currentlyActive; String userid; + factory CachedPresence.fromJson(Map json) => + CachedPresence._( + presence: PresenceType.values + .singleWhere((type) => type.name == json['presence']), + lastActiveTimestamp: json['last_active_timestamp'] != null + ? DateTime.fromMillisecondsSinceEpoch( + json['last_active_timestamp'] as int) + : null, + statusMsg: json['status_msg'] as String?, + currentlyActive: json['currently_active'] as bool?, + userid: json['user_id'] as String, + ); + + Map toJson() => { + 'user_id': userid, + 'presence': presence.name, + if (lastActiveTimestamp != null) + 'last_active_timestamp': lastActiveTimestamp?.millisecondsSinceEpoch, + if (statusMsg != null) 'status_msg': statusMsg, + if (currentlyActive != null) 'currently_active': currentlyActive, + }; + + CachedPresence._({ + required this.userid, + required this.presence, + this.lastActiveTimestamp, + this.statusMsg, + this.currentlyActive, + }); + CachedPresence(this.presence, int? lastActiveAgo, this.statusMsg, this.currentlyActive, this.userid) { if (lastActiveAgo != null) { @@ -49,7 +79,7 @@ class CachedPresence { Presence toPresence() { final content = { - 'presence': presence.toString(), + 'presence': presence.name.toString(), }; if (currentlyActive != null) content['currently_active'] = currentlyActive!; if (lastActiveTimestamp != null) { diff --git a/lib/src/room.dart b/lib/src/room.dart index d65d2690..3c3b2443 100644 --- a/lib/src/room.dart +++ b/lib/src/room.dart @@ -90,9 +90,6 @@ class Room { /// Key-Value store for private account data only visible for this user. Map roomAccountData = {}; - /// stores stale group call checking timers for rooms. - Map staleGroupCallsTimer = {}; - final _sendingQueue = []; Map toJson() => { @@ -137,9 +134,6 @@ class Room { setState(state); } } - if (!isArchived) { - startStaleCallsChecker(id); - } partial = false; } diff --git a/lib/src/user.dart b/lib/src/user.dart index 5842cfe7..5948e69e 100644 --- a/lib/src/user.dart +++ b/lib/src/user.dart @@ -155,20 +155,10 @@ class User extends Event { @Deprecated('Use fetchCurrentPresence() instead') Future get currentPresence => fetchCurrentPresence(); - /// The newest presence of this user if there is any. Fetches it from the server if necessary or returns offline. - Future fetchCurrentPresence() async { - final cachedPresence = room.client.presences[id]; - if (cachedPresence != null) { - return cachedPresence; - } - - try { - final newPresence = await room.client.getPresence(id); - return CachedPresence.fromPresenceResponse(newPresence, id); - } catch (e) { - return CachedPresence.neverSeen(id); - } - } + /// The newest presence of this user if there is any. Fetches it from the + /// database first and then from the server if necessary or returns offline. + Future fetchCurrentPresence() => + room.client.fetchCurrentPresence(id); /// Whether the client is able to ban/unban this user. bool get canBan => room.canBan && powerLevel < room.ownPowerLevel; diff --git a/lib/src/voip/call.dart b/lib/src/voip/call.dart index 69fb3897..e71cc42c 100644 --- a/lib/src/voip/call.dart +++ b/lib/src/voip/call.dart @@ -1200,7 +1200,9 @@ class CallSession { setCallState(CallState.kEnded); if (!isGroupCall) { - if (callId != voip.currentCID) return; + // when a call crash and this call is already terminated the currentCId is null. + // So don't return bc the hangup or reject will not proceed anymore. + if (callId != voip.currentCID && voip.currentCID != null) return; voip.currentCID = null; voip.incomingCallRoomId.removeWhere((key, value) => value == callId); } @@ -1286,7 +1288,7 @@ class CallSession { inviteTimer = Timer(Duration(seconds: Timeouts.callTimeoutSec), () { if (state == CallState.kInviteSent) { - hangup(CallErrorCode.InviteTimeout, false); + hangup(CallErrorCode.InviteTimeout); } inviteTimer?.cancel(); inviteTimer = null; diff --git a/lib/src/voip/voip_room_extension.dart b/lib/src/voip/voip_room_extension.dart index 707d84e0..c707222d 100644 --- a/lib/src/voip/voip_room_extension.dart +++ b/lib/src/voip/voip_room_extension.dart @@ -43,17 +43,6 @@ extension GroupCallUtils on Room { return []; } - /// stops the stale call checker timer - void stopStaleCallsChecker(String roomId) { - if (staleGroupCallsTimer.tryGet(roomId) != null) { - staleGroupCallsTimer[roomId]!.cancel(); - staleGroupCallsTimer.remove(roomId); - Logs().d('[VOIP] stopped stale group calls checker for room $id'); - } else { - Logs().d('[VOIP] no stale call checker for room found'); - } - } - static const staleCallCheckerDuration = Duration(seconds: 30); bool callMemberStateIsExpired( @@ -89,23 +78,16 @@ extension GroupCallUtils on Room { } /// checks for stale calls in a room and sends `m.terminated` if all the - /// expires_ts are expired. Call when opening a room - void startStaleCallsChecker(String roomId) async { - stopStaleCallsChecker(roomId); - await singleShotStaleCallCheckerOnRoom(); - staleGroupCallsTimer[roomId] = Timer.periodic( - staleCallCheckerDuration, - (timer) async => await singleShotStaleCallCheckerOnRoom(), - ); - } - + /// expires_ts are expired. Called regularly on sync. Future singleShotStaleCallCheckerOnRoom() async { - Logs().d('[VOIP] checking for stale group calls in room $id'); - // make sure we have all the to-device messages we are supposed to have - await client.oneShotSync(); + if (partial) return; + final copyGroupCallIds = states.tryGetMap(EventTypes.GroupCallPrefix); if (copyGroupCallIds == null) return; + + Logs().d('[VOIP] checking for stale group calls in room $id'); + for (final groupCall in copyGroupCallIds.entries) { final groupCallId = groupCall.key; final groupCallEvent = groupCall.value; @@ -165,3 +147,16 @@ extension GroupCallUtils on Room { } } } + +extension GroupCallClientUtils on Client { + // call after sync + Future singleShotStaleCallChecker() async { + if (lastStaleCallRun + .add(GroupCallUtils.staleCallCheckerDuration) + .isBefore(DateTime.now())) { + await Future.wait(rooms + .where((r) => r.membership == Membership.join) + .map((r) => r.singleShotStaleCallCheckerOnRoom())); + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index c0510568..08f3196b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: matrix description: Matrix Dart SDK -version: 0.22.6 +version: 0.22.7 homepage: https://famedly.com repository: https://github.com/famedly/matrix-dart-sdk.git diff --git a/test/client_test.dart b/test/client_test.dart index 694aa349..a63f3fec 100644 --- a/test/client_test.dart +++ b/test/client_test.dart @@ -335,6 +335,94 @@ void main() { expect(loginState, LoginState.loggedOut); }); + test('Login again but break server when trying to logout', () async { + matrix = Client( + 'testclient', + httpClient: FakeMatrixApi(), + databaseBuilder: getDatabase, + ); + + try { + await olm.init(); + olm.get_library_version(); + } catch (e) { + olmEnabled = false; + Logs().w('[LibOlm] Failed to load LibOlm', e); + } + Logs().w('[LibOlm] Enabled: $olmEnabled'); + + expect(matrix.homeserver, null); + + try { + await matrix + .checkHomeserver(Uri.parse('https://fakeserver.wrongaddress')); + } catch (exception) { + expect(exception.toString().isNotEmpty, true); + } + await matrix.checkHomeserver(Uri.parse('https://fakeserver.notexisting'), + checkWellKnown: false); + expect(matrix.homeserver.toString(), 'https://fakeserver.notexisting'); + + final available = await matrix.checkUsernameAvailability('testuser'); + expect(available, true); + + final loginStateFuture = matrix.onLoginStateChanged.stream.first; + final syncFuture = matrix.onSync.stream.first; + + await matrix.init( + newToken: 'abcd', + newUserID: '@test:fakeServer.notExisting', + newHomeserver: matrix.homeserver, + newDeviceName: 'Text Matrix Client', + newDeviceID: 'GHTYAJCE', + newOlmAccount: pickledOlmAccount, + ); + + await Future.delayed(Duration(milliseconds: 50)); + + final loginState = await loginStateFuture; + final sync = await syncFuture; + + expect(loginState, LoginState.loggedIn); + expect(matrix.onSync.value != null, true); + expect(matrix.encryptionEnabled, olmEnabled); + if (olmEnabled) { + expect(matrix.identityKey, identityKey); + expect(matrix.fingerprintKey, fingerprintKey); + } + expect(sync.nextBatch == matrix.prevBatch, true); + }); + + test('Logout but this time server is dead', () async { + final oldapi = FakeMatrixApi.currentApi?.api; + expect( + (FakeMatrixApi.currentApi?.api['PUT']?.keys.where((element) => + element.startsWith('/client/v3/room_keys/keys?version')))?.length, + 1); + // not a huge fan, open to ideas + FakeMatrixApi.currentApi?.api = {}; + // random sanity test to test if the test to test the breaking server hack works. + expect( + (FakeMatrixApi.currentApi?.api['PUT']?.keys.where((element) => + element.startsWith('/client/v3/room_keys/keys?version')))?.length, + null); + try { + await matrix.logout(); + } catch (e) { + Logs().w( + 'Ignore red warnings for this test, test is to check if database is cleared even if server breaks '); + } + + expect(matrix.accessToken == null, true); + expect(matrix.homeserver == null, true); + expect(matrix.userID == null, true); + expect(matrix.deviceID == null, true); + expect(matrix.deviceName == null, true); + expect(matrix.prevBatch == null, true); + + FakeMatrixApi.currentApi?.api = oldapi!; + }); + test('Login', () async { matrix = Client( 'testclient', diff --git a/test/database_api_test.dart b/test/database_api_test.dart index ecdec026..9d096fe8 100644 --- a/test/database_api_test.dart +++ b/test/database_api_test.dart @@ -457,6 +457,25 @@ void testDatabase( 'deviceId', ); }); + test('getStorePresences', () async { + const userId = '@alice:example.com'; + final presence = CachedPresence( + PresenceType.online, + 100, + 'test message', + true, + '@alice:example.com', + ); + await database.storePresence( + userId, + presence, + ); + final storedPresence = await database.getPresence(userId); + expect( + presence.toJson(), + storedPresence?.toJson(), + ); + }); // Clearing up from here test('clearSSSSCache', () async { diff --git a/test/fake_matrix_api.dart b/test/fake_matrix_api.dart index f92fa357..fd64189d 100644 --- a/test/fake_matrix_api.dart +++ b/test/fake_matrix_api.dart @@ -1051,7 +1051,7 @@ class FakeMatrixApi extends BaseClient { } }; - final Map> api = { + Map> api = { 'GET': { '/path/to/auth/error': (var req) => { 'errcode': 'M_FORBIDDEN',