From d89952c29585698f9f57ab9b338284b74c92aee0 Mon Sep 17 00:00:00 2001 From: otsmr Date: Sun, 30 Aug 2026 09:25:15 +0200 Subject: [PATCH] fixing multiple issues --- .../reports/problems/problems-report.html | 663 ++++++++++++++++++ ios/Runner/AppDelegate.swift | 79 ++- lib/app.dart | 5 + lib/core/bridge/api.dart | 22 + lib/core/bridge/callbacks/log.dart | 14 + lib/core/bridge/groups.dart | 13 +- lib/core/frb_generated.dart | 363 +++++++--- lib/core/frb_generated.io.dart | 1 + lib/core/frb_generated.web.dart | 1 + .../notifications/native.notifications.dart | 76 ++ .../views/chats/chat_messages.view.dart | 14 + .../views/contact/add_new_contact.view.dart | 4 + lib/src/visual/views/groups/group.view.dart | 14 +- .../views/groups/group_member.context.dart | 7 +- ...d2279469d8645feca81bdadb717b39ad7ccd1.json | 12 + ...13c679ef0361348c17d2ee740d30ad8c5a424.json | 12 - ...b30e96adf6a2704e98310de21fc832f7a187f.json | 26 + ...fb0b2b7a14f18fa0ed0c66e5c9cac5678f4d4.json | 12 + ...f8db55945d8230b5450aacc1620c975a59387.json | 26 + ...1410100258a0a5fe894233d63b6ac770368f.json} | 6 +- rust/src/api/messages/incoming.rs | 102 ++- rust/src/api/messages/incoming/errors.rs | 21 +- rust/src/api/messages/incoming/groups.rs | 8 +- rust/src/api/messages/incoming/messages.rs | 56 +- rust/src/api/server/contacts.rs | 5 +- rust/src/bridge/api.rs | 20 + rust/src/bridge/groups.rs | 19 +- rust/src/database/app/tables/groups.rs | 52 +- rust/src/frb_generated.rs | 358 +++++++--- rust/src/services/contacts.rs | 4 +- rust/src/services/groups/mod.rs | 330 +++++++-- rust/src/services/notifications.rs | 60 +- rust/tests/api.rs | 22 +- rust/tests/api/group_resilience.rs | 13 +- rust/tests/api/notifications.rs | 28 + rust/tests/api/tester.rs | 9 + scripts/run_emulators.sh | 163 +++++ 37 files changed, 2211 insertions(+), 429 deletions(-) create mode 100644 android/build/reports/problems/problems-report.html create mode 100644 lib/core/bridge/callbacks/log.dart create mode 100644 rust/.sqlx/query-2b5ae0640585d4a0c5c1c521103d2279469d8645feca81bdadb717b39ad7ccd1.json delete mode 100644 rust/.sqlx/query-411a854abb8d23c104b7290c25313c679ef0361348c17d2ee740d30ad8c5a424.json create mode 100644 rust/.sqlx/query-5164aba96e6ee8f761644482263b30e96adf6a2704e98310de21fc832f7a187f.json create mode 100644 rust/.sqlx/query-7cc39bf45c3395a1f71a40d027afb0b2b7a14f18fa0ed0c66e5c9cac5678f4d4.json create mode 100644 rust/.sqlx/query-9841b7394adf2125090839e8381f8db55945d8230b5450aacc1620c975a59387.json rename rust/.sqlx/{query-6d6a5fb10742309ab6e4242179ef86b64ac9f751f1c6d647d4ecfa5ecac5d332.json => query-e15b811d31ca59847e3d4ee2eff71410100258a0a5fe894233d63b6ac770368f.json} (51%) create mode 100755 scripts/run_emulators.sh diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html new file mode 100644 index 00000000..e633b03e --- /dev/null +++ b/android/build/reports/problems/problems-report.html @@ -0,0 +1,663 @@ + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index d5bb7aef..c0f7cd81 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -81,12 +81,13 @@ import workmanager_apple } /// Withdraws only the native notifications whose message IDs Dart reports as -/// opened. The notification service extension's final alert keeps APNs' request -/// identifier, so both the identifier and our `notification_id` user-info field -/// have to be considered. +/// opened, and keeps the app icon badge in step with them. The notification +/// service extension's final alert keeps APNs' request identifier, so both the +/// identifier and our `notification_id` user-info field have to be considered. class NativeNotificationChannel { private static let channelName = "eu.twonly/notificationTap" private static let notificationIdsKey = "notification_ids" + private static let badgeCountKey = "badge_count" static func register(with registry: FlutterPluginRegistry) { guard let registrar = registry.registrar(forPlugin: "TwonlyNativeNotifications") else { @@ -97,23 +98,67 @@ class NativeNotificationChannel { binaryMessenger: registrar.messenger() ) channel.setMethodCallHandler { call, result in - guard call.method == "cancelNotifications" else { + switch call.method { + case "cancelNotifications": + guard + let arguments = call.arguments as? [String: Any], + let values = arguments[notificationIdsKey] as? [String] + else { + result( + FlutterError( + code: "invalid_notification_ids", + message: "notification_ids must be a list of strings", + details: nil + )) + return + } + removeNotifications(Set(values), completion: result) + case "setBadgeCount": + guard + let arguments = call.arguments as? [String: Any], + let count = arguments[badgeCountKey] as? Int + else { + result( + FlutterError( + code: "invalid_badge_count", + message: "badge_count must be an integer", + details: nil + )) + return + } + setBadgeCount(count, completion: result) + default: result(FlutterMethodNotImplemented) - return } - guard - let arguments = call.arguments as? [String: Any], - let values = arguments[notificationIdsKey] as? [String] - else { - result( - FlutterError( - code: "invalid_notification_ids", - message: "notification_ids must be a list of strings", - details: nil - )) - return + } + } + + /// iOS only takes an app icon badge from a notification payload, so a badge + /// set by the notification service extension survives until the app itself + /// overwrites it. Dart pushes the pending event count here whenever the + /// notification outbox changes and on every resume. + private static func setBadgeCount(_ count: Int, completion: @escaping FlutterResult) { + let badge = max(0, count) + guard #available(iOS 16.0, *) else { + DispatchQueue.main.async { + UIApplication.shared.applicationIconBadgeNumber = badge + completion(nil) + } + return + } + UNUserNotificationCenter.current().setBadgeCount(badge) { error in + DispatchQueue.main.async { + if let error { + completion( + FlutterError( + code: "badge_count_failed", + message: error.localizedDescription, + details: nil + )) + } else { + completion(nil) + } } - removeNotifications(Set(values), completion: result) } } diff --git a/lib/app.dart b/lib/app.dart index db0bc7ae..b6786418 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -17,6 +17,7 @@ import 'package:twonly/src/model/json/onboarding_state.model.dart'; import 'package:twonly/src/providers/routing.provider.dart'; import 'package:twonly/src/providers/settings.provider.dart'; import 'package:twonly/src/services/intent/links.intent.dart'; +import 'package:twonly/src/services/notifications/native.notifications.dart'; import 'package:twonly/src/utils/keyvalue.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/pow.dart'; @@ -63,6 +64,10 @@ class _AppState extends State with WidgetsBindingObserver { unawaited( rust_api.RustApi.setBackground(inBackground: false), ); + // The notification service extension wrote to the outbox while the app + // was suspended, and its Rust change broadcast never reached this + // process, so the badge has to be re-read on the way back in. + unawaited(NativeNotificationService.refreshBadgeCount()); } } else if (state == AppLifecycleState.paused) { _wasPaused = true; diff --git a/lib/core/bridge/api.dart b/lib/core/bridge/api.dart index eb9016b3..848fabbe 100644 --- a/lib/core/bridge/api.dart +++ b/lib/core/bridge/api.dart @@ -293,6 +293,22 @@ class RustApi { alreadyReceivedMessageIds: alreadyReceivedMessageIds, ); + /// Acknowledges the pending contact-request notifications and returns the + /// native notification IDs to withdraw. + static Future> clearContactRequestNotifications() => RustLib + .instance + .api + .crateBridgeApiRustApiClearContactRequestNotifications(); + + /// Acknowledges every pending notification of a conversation the user just + /// opened and returns the native notification IDs to withdraw. + static Future> clearConversationNotifications({ + required String conversationId, + }) => + RustLib.instance.api.crateBridgeApiRustApiClearConversationNotifications( + conversationId: conversationId, + ); + static Future close() => RustLib.instance.api.crateBridgeApiRustApiClose(); @@ -432,6 +448,12 @@ class RustApi { static Future loadPlanBalance() => RustLib.instance.api.crateBridgeApiRustApiLoadPlanBalance(); + /// The number of pending notification events. iOS cannot derive its app + /// icon badge from the delivered alerts, so the running app pushes this + /// into `UNUserNotificationCenter` whenever the outbox changes. + static Future notificationBadgeCount() => + RustLib.instance.api.crateBridgeApiRustApiNotificationBadgeCount(); + static Future notifyMessagesOpened({ required PlatformInt64 contactId, required List messageIds, diff --git a/lib/core/bridge/callbacks/log.dart b/lib/core/bridge/callbacks/log.dart new file mode 100644 index 00000000..42fb1c69 --- /dev/null +++ b/lib/core/bridge/callbacks/log.dart @@ -0,0 +1,14 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.12.0. + +// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import + +import '../../frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; + +// These functions are ignored because they are not marked as `pub`: `set_dart_sink` +// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `DartWriter` +// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `flush`, `make_writer`, `write` + +Future stripAnsi({required String input}) => + RustLib.instance.api.crateBridgeCallbacksLogStripAnsi(input: input); diff --git a/lib/core/bridge/groups.dart b/lib/core/bridge/groups.dart index e1126d60..a4d8e5e1 100644 --- a/lib/core/bridge/groups.dart +++ b/lib/core/bridge/groups.dart @@ -25,17 +25,20 @@ Future addHiddenContact({required PlatformInt64 contactId}) => RustLib Future fetchGroupStatesForUnjoinedGroups() => RustLib.instance.api.crateBridgeGroupsFetchGroupStatesForUnjoinedGroups(); -Future fetchMissingGroupPublicKeys() => - RustLib.instance.api.crateBridgeGroupsFetchMissingGroupPublicKeys(); +Future fetchMissingGroupPublicKeys({ + String? groupId, + required bool force, +}) => RustLib.instance.api.crateBridgeGroupsFetchMissingGroupPublicKeys( + groupId: groupId, + force: force, +); Future manageAdminState({ required String groupId, - required List groupPublicKey, required PlatformInt64 contactId, required bool remove, }) => RustLib.instance.api.crateBridgeGroupsManageAdminState( groupId: groupId, - groupPublicKey: groupPublicKey, contactId: contactId, remove: remove, ); @@ -66,11 +69,9 @@ Future addNewGroupMembers({ Future removeMemberFromGroup({ required String groupId, - required List groupPublicKey, required PlatformInt64 contactId, }) => RustLib.instance.api.crateBridgeGroupsRemoveMemberFromGroup( groupId: groupId, - groupPublicKey: groupPublicKey, contactId: contactId, ); diff --git a/lib/core/frb_generated.dart b/lib/core/frb_generated.dart index c681f6ec..2dce09d2 100644 --- a/lib/core/frb_generated.dart +++ b/lib/core/frb_generated.dart @@ -7,6 +7,7 @@ import 'api/server/prekeys.dart'; import 'bridge.dart'; import 'bridge/api.dart'; import 'bridge/callbacks.dart'; +import 'bridge/callbacks/log.dart'; import 'bridge/groups.dart'; import 'bridge/user_config.dart'; import 'bridge/wrapper.dart'; @@ -84,7 +85,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.12.0'; @override - int get rustContentHash => -1780439173; + int get rustContentHash => 463166747; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -114,7 +115,10 @@ abstract class RustLibApi extends BaseApi { Future crateBridgeGroupsFetchGroupStatesForUnjoinedGroups(); - Future crateBridgeGroupsFetchMissingGroupPublicKeys(); + Future crateBridgeGroupsFetchMissingGroupPublicKeys({ + String? groupId, + required bool force, + }); Future crateBridgeWrapperUserDiscoveryFlutterUserDiscoveryChangeExclusionForContact({ @@ -157,14 +161,12 @@ abstract class RustLibApi extends BaseApi { Future crateBridgeGroupsManageAdminState({ required String groupId, - required List groupPublicKey, required PlatformInt64 contactId, required bool remove, }); Future crateBridgeGroupsRemoveMemberFromGroup({ required String groupId, - required List groupPublicKey, required PlatformInt64 contactId, }); @@ -187,6 +189,12 @@ abstract class RustLibApi extends BaseApi { required Int64List alreadyReceivedMessageIds, }); + Future> crateBridgeApiRustApiClearContactRequestNotifications(); + + Future> crateBridgeApiRustApiClearConversationNotifications({ + required String conversationId, + }); + Future crateBridgeApiRustApiClose(); Future crateBridgeApiRustApiConfirmMemoriesUpload({ @@ -279,6 +287,8 @@ abstract class RustLibApi extends BaseApi { Future crateBridgeApiRustApiLoadPlanBalance(); + Future crateBridgeApiRustApiNotificationBadgeCount(); + Future crateBridgeApiRustApiNotifyMessagesOpened({ required PlatformInt64 contactId, required List messageIds, @@ -541,6 +551,8 @@ abstract class RustLibApi extends BaseApi { required int threshold, }); + Future crateBridgeCallbacksLogStripAnsi({required String input}); + Future crateBridgeGroupsUpdateChatDeletionTime({ required String groupId, required PlatformInt64 deleteMessagesAfterMilliseconds, @@ -753,11 +765,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); @override - Future crateBridgeGroupsFetchMissingGroupPublicKeys() { + Future crateBridgeGroupsFetchMissingGroupPublicKeys({ + String? groupId, + required bool force, + }) { return handler.executeNormal( NormalTask( callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_opt_String(groupId, serializer); + sse_encode_bool(force, serializer); pdeCallFfi( generalizedFrbRustBinding, serializer, @@ -770,7 +787,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { decodeErrorData: sse_decode_AnyhowException, ), constMeta: kCrateBridgeGroupsFetchMissingGroupPublicKeysConstMeta, - argValues: [], + argValues: [groupId, force], apiImpl: this, ), ); @@ -779,7 +796,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateBridgeGroupsFetchMissingGroupPublicKeysConstMeta => const TaskConstMeta( debugName: "fetch_missing_group_public_keys", - argNames: [], + argNames: ["groupId", "force"], ); @override @@ -1082,7 +1099,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { @override Future crateBridgeGroupsManageAdminState({ required String groupId, - required List groupPublicKey, required PlatformInt64 contactId, required bool remove, }) { @@ -1091,7 +1107,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(groupId, serializer); - sse_encode_list_prim_u_8_loose(groupPublicKey, serializer); sse_encode_i_64(contactId, serializer); sse_encode_bool(remove, serializer); pdeCallFfi( @@ -1106,7 +1121,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { decodeErrorData: sse_decode_AnyhowException, ), constMeta: kCrateBridgeGroupsManageAdminStateConstMeta, - argValues: [groupId, groupPublicKey, contactId, remove], + argValues: [groupId, contactId, remove], apiImpl: this, ), ); @@ -1115,13 +1130,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateBridgeGroupsManageAdminStateConstMeta => const TaskConstMeta( debugName: "manage_admin_state", - argNames: ["groupId", "groupPublicKey", "contactId", "remove"], + argNames: ["groupId", "contactId", "remove"], ); @override Future crateBridgeGroupsRemoveMemberFromGroup({ required String groupId, - required List groupPublicKey, required PlatformInt64 contactId, }) { return handler.executeNormal( @@ -1129,7 +1143,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: (port_) { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(groupId, serializer); - sse_encode_list_prim_u_8_loose(groupPublicKey, serializer); sse_encode_i_64(contactId, serializer); pdeCallFfi( generalizedFrbRustBinding, @@ -1143,7 +1156,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { decodeErrorData: sse_decode_AnyhowException, ), constMeta: kCrateBridgeGroupsRemoveMemberFromGroupConstMeta, - argValues: [groupId, groupPublicKey, contactId], + argValues: [groupId, contactId], apiImpl: this, ), ); @@ -1152,7 +1165,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateBridgeGroupsRemoveMemberFromGroupConstMeta => const TaskConstMeta( debugName: "remove_member_from_group", - argNames: ["groupId", "groupPublicKey", "contactId"], + argNames: ["groupId", "contactId"], ); @override @@ -1358,7 +1371,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); @override - Future crateBridgeApiRustApiClose() { + Future> crateBridgeApiRustApiClearContactRequestNotifications() { return handler.executeNormal( NormalTask( callFfi: (port_) { @@ -1370,6 +1383,73 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { port: port_, ); }, + codec: SseCodec( + decodeSuccessData: sse_decode_list_String, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: + kCrateBridgeApiRustApiClearContactRequestNotificationsConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta + get kCrateBridgeApiRustApiClearContactRequestNotificationsConstMeta => + const TaskConstMeta( + debugName: "rust_api_clear_contact_request_notifications", + argNames: [], + ); + + @override + Future> crateBridgeApiRustApiClearConversationNotifications({ + required String conversationId, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(conversationId, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 23, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_list_String, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: + kCrateBridgeApiRustApiClearConversationNotificationsConstMeta, + argValues: [conversationId], + apiImpl: this, + ), + ); + } + + TaskConstMeta + get kCrateBridgeApiRustApiClearConversationNotificationsConstMeta => + const TaskConstMeta( + debugName: "rust_api_clear_conversation_notifications", + argNames: ["conversationId"], + ); + + @override + Future crateBridgeApiRustApiClose() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 24, + port: port_, + ); + }, codec: SseCodec( decodeSuccessData: sse_decode_unit, decodeErrorData: sse_decode_AnyhowException, @@ -1398,7 +1478,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 23, + funcId: 25, port: port_, ); }, @@ -1428,7 +1508,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 24, + funcId: 26, port: port_, ); }, @@ -1458,7 +1538,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 25, + funcId: 27, port: port_, ); }, @@ -1488,7 +1568,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 26, + funcId: 28, port: port_, ); }, @@ -1519,7 +1599,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 27, + funcId: 29, port: port_, ); }, @@ -1549,7 +1629,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 28, + funcId: 30, port: port_, ); }, @@ -1580,7 +1660,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 29, + funcId: 31, port: port_, ); }, @@ -1611,7 +1691,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 30, + funcId: 32, port: port_, ); }, @@ -1641,7 +1721,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 31, + funcId: 33, port: port_, ); }, @@ -1676,7 +1756,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 32, + funcId: 34, port: port_, ); }, @@ -1709,7 +1789,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 33, + funcId: 35, port: port_, ); }, @@ -1741,7 +1821,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 34, + funcId: 36, port: port_, ); }, @@ -1776,7 +1856,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 35, + funcId: 37, port: port_, ); }, @@ -1806,7 +1886,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 36, + funcId: 38, port: port_, ); }, @@ -1836,7 +1916,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 37, + funcId: 39, port: port_, ); }, @@ -1866,7 +1946,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 38, + funcId: 40, port: port_, ); }, @@ -1907,7 +1987,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 39, + funcId: 41, port: port_, ); }, @@ -1954,7 +2034,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 40, + funcId: 42, port: port_, ); }, @@ -1987,7 +2067,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 41, + funcId: 43, port: port_, ); }, @@ -2020,7 +2100,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 42, + funcId: 44, port: port_, ); }, @@ -2057,7 +2137,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 43, + funcId: 45, port: port_, ); }, @@ -2093,7 +2173,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 44, + funcId: 46, port: port_, ); }, @@ -2128,7 +2208,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 45, + funcId: 47, port: port_, ); }, @@ -2165,7 +2245,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 46, + funcId: 48, port: port_, ); }, @@ -2202,7 +2282,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 47, + funcId: 49, port: port_, ); }, @@ -2232,7 +2312,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 48, + funcId: 50, port: port_, ); }, @@ -2253,6 +2333,36 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: [], ); + @override + Future crateBridgeApiRustApiNotificationBadgeCount() { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 51, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_i_64, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateBridgeApiRustApiNotificationBadgeCountConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeApiRustApiNotificationBadgeCountConstMeta => + const TaskConstMeta( + debugName: "rust_api_notification_badge_count", + argNames: [], + ); + @override Future crateBridgeApiRustApiNotifyMessagesOpened({ required PlatformInt64 contactId, @@ -2267,7 +2377,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 49, + funcId: 52, port: port_, ); }, @@ -2297,7 +2407,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 50, + funcId: 53, port: port_, ); }, @@ -2332,7 +2442,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 51, + funcId: 54, port: port_, ); }, @@ -2371,7 +2481,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 52, + funcId: 55, port: port_, ); }, @@ -2410,7 +2520,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 53, + funcId: 56, port: port_, ); }, @@ -2452,7 +2562,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 54, + funcId: 57, port: port_, ); }, @@ -2483,7 +2593,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 55, + funcId: 58, port: port_, ); }, @@ -2516,7 +2626,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 56, + funcId: 59, port: port_, ); }, @@ -2551,7 +2661,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 57, + funcId: 60, port: port_, ); }, @@ -2584,7 +2694,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 58, + funcId: 61, port: port_, ); }, @@ -2617,7 +2727,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 59, + funcId: 62, port: port_, ); }, @@ -2650,7 +2760,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 60, + funcId: 63, port: port_, ); }, @@ -2687,7 +2797,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 61, + funcId: 64, port: port_, ); }, @@ -2717,7 +2827,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 62, + funcId: 65, port: port_, ); }, @@ -2748,7 +2858,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 63, + funcId: 66, port: port_, ); }, @@ -2781,7 +2891,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 64, + funcId: 67, port: port_, ); }, @@ -2827,7 +2937,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 65, + funcId: 68, port: port_, ); }, @@ -2883,7 +2993,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 66, + funcId: 69, port: port_, ); }, @@ -2922,7 +3032,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 67, + funcId: 70, port: port_, ); }, @@ -2957,7 +3067,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 68, + funcId: 71, port: port_, ); }, @@ -2992,7 +3102,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 69, + funcId: 72, port: port_, ); }, @@ -3025,7 +3135,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 70, + funcId: 73, port: port_, ); }, @@ -3056,7 +3166,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 71, + funcId: 74, port: port_, ); }, @@ -3089,7 +3199,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 72, + funcId: 75, port: port_, ); }, @@ -3124,7 +3234,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 73, + funcId: 76, port: port_, ); }, @@ -3155,7 +3265,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 74, + funcId: 77, port: port_, ); }, @@ -3192,7 +3302,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 75, + funcId: 78, port: port_, ); }, @@ -3244,7 +3354,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 76, + funcId: 79, port: port_, ); }, @@ -3297,7 +3407,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 77, + funcId: 80, port: port_, ); }, @@ -3337,7 +3447,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 78, + funcId: 81, port: port_, ); }, @@ -3370,7 +3480,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 79, + funcId: 82, port: port_, ); }, @@ -3403,7 +3513,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 80, + funcId: 83, port: port_, ); }, @@ -3440,7 +3550,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 81, + funcId: 84, port: port_, ); }, @@ -3472,7 +3582,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 82, + funcId: 85, port: port_, ); }, @@ -3505,7 +3615,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 83, + funcId: 86, port: port_, ); }, @@ -3540,7 +3650,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 84, + funcId: 87, port: port_, ); }, @@ -3572,7 +3682,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 85, + funcId: 88, port: port_, ); }, @@ -3610,7 +3720,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 86, + funcId: 89, port: port_, ); }, @@ -3643,7 +3753,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 87, + funcId: 90, port: port_, ); }, @@ -3681,7 +3791,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 88, + funcId: 91, port: port_, ); }, @@ -3718,7 +3828,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 89, + funcId: 92, port: port_, ); }, @@ -3755,7 +3865,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 90, + funcId: 93, port: port_, ); }, @@ -3793,7 +3903,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 91, + funcId: 94, port: port_, ); }, @@ -3831,7 +3941,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 92, + funcId: 95, port: port_, ); }, @@ -3863,7 +3973,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 93, + funcId: 96, port: port_, ); }, @@ -3896,7 +4006,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 94, + funcId: 97, port: port_, ); }, @@ -3928,7 +4038,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 95, + funcId: 98, port: port_, ); }, @@ -3963,7 +4073,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 96, + funcId: 99, port: port_, ); }, @@ -4000,7 +4110,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 97, + funcId: 100, port: port_, ); }, @@ -4032,7 +4142,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 98, + funcId: 101, port: port_, ); }, @@ -4064,7 +4174,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 99, + funcId: 102, port: port_, ); }, @@ -4099,7 +4209,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 100, + funcId: 103, port: port_, ); }, @@ -4138,7 +4248,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 101, + funcId: 104, port: port_, ); }, @@ -4175,7 +4285,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 102, + funcId: 105, port: port_, ); }, @@ -4205,7 +4315,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 103, + funcId: 106, port: port_, ); }, @@ -4237,7 +4347,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 104, + funcId: 107, port: port_, ); }, @@ -4272,7 +4382,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 105, + funcId: 108, port: port_, ); }, @@ -4304,7 +4414,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 106, + funcId: 109, port: port_, ); }, @@ -4342,7 +4452,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 107, + funcId: 110, port: port_, ); }, @@ -4381,7 +4491,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 108, + funcId: 111, port: port_, ); }, @@ -4416,7 +4526,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 109, + funcId: 112, port: port_, ); }, @@ -4437,6 +4547,37 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["shares", "threshold"], ); + @override + Future crateBridgeCallbacksLogStripAnsi({required String input}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(input, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 113, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_String, + decodeErrorData: null, + ), + constMeta: kCrateBridgeCallbacksLogStripAnsiConstMeta, + argValues: [input], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateBridgeCallbacksLogStripAnsiConstMeta => + const TaskConstMeta( + debugName: "strip_ansi", + argNames: ["input"], + ); + @override Future crateBridgeGroupsUpdateChatDeletionTime({ required String groupId, @@ -4451,7 +4592,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 110, + funcId: 114, port: port_, ); }, @@ -4486,7 +4627,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 111, + funcId: 115, port: port_, ); }, @@ -4519,7 +4660,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 112, + funcId: 116, )!; }, codec: SseCodec( @@ -4559,7 +4700,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 113, + funcId: 117, port: port_, ); }, @@ -4604,7 +4745,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 114, + funcId: 118, port: port_, ); }, @@ -4634,7 +4775,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 115, + funcId: 119, port: port_, ); }, @@ -4667,7 +4808,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 116, + funcId: 120, port: port_, ); }, @@ -4702,7 +4843,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 117, + funcId: 121, port: port_, ); }, diff --git a/lib/core/frb_generated.io.dart b/lib/core/frb_generated.io.dart index 3d1c6a89..d1e685b0 100644 --- a/lib/core/frb_generated.io.dart +++ b/lib/core/frb_generated.io.dart @@ -7,6 +7,7 @@ import 'api/server/prekeys.dart'; import 'bridge.dart'; import 'bridge/api.dart'; import 'bridge/callbacks.dart'; +import 'bridge/callbacks/log.dart'; import 'bridge/groups.dart'; import 'bridge/user_config.dart'; import 'bridge/wrapper.dart'; diff --git a/lib/core/frb_generated.web.dart b/lib/core/frb_generated.web.dart index fa5079d9..5efa4165 100644 --- a/lib/core/frb_generated.web.dart +++ b/lib/core/frb_generated.web.dart @@ -10,6 +10,7 @@ import 'api/server/prekeys.dart'; import 'bridge.dart'; import 'bridge/api.dart'; import 'bridge/callbacks.dart'; +import 'bridge/callbacks/log.dart'; import 'bridge/groups.dart'; import 'bridge/user_config.dart'; import 'bridge/wrapper.dart'; diff --git a/lib/src/services/notifications/native.notifications.dart b/lib/src/services/notifications/native.notifications.dart index 80bdb905..83b2b08b 100644 --- a/lib/src/services/notifications/native.notifications.dart +++ b/lib/src/services/notifications/native.notifications.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'dart:io' show Platform; import 'package:flutter/services.dart'; +import 'package:twonly/core/bridge/api.dart'; +import 'package:twonly/core/bridge/wrapper/app_database.dart'; import 'package:twonly/src/utils/log.dart'; /// Taps on notifications rendered natively (Android `MessagingStyle`) arrive @@ -17,16 +19,27 @@ class NativeNotificationService { static const String _conversationIdKey = 'conversation_id'; static const String _notificationIdsKey = 'notification_ids'; + static const String _badgeCountKey = 'badge_count'; + + /// The table Rust commits to whenever a notification event is recorded, + /// delivered or cleared. + static const String _outboxTable = 'notification_outbox'; static final StreamController _taps = StreamController.broadcast(); + /// Lives for the whole process: the badge has to follow the outbox for as + /// long as the app runs. + // ignore: cancel_subscriptions + static StreamSubscription>? _outboxChanges; + /// Emits the conversation id of every notification tapped while the app is /// running. A `null` value means the notification had no specific /// conversation and should only open the chats tab. static Stream get taps => _taps.stream; static void init() { + _startBadgeSync(); if (!Platform.isAndroid) return; _channel.setMethodCallHandler((call) async { if (call.method != 'onNotificationTapped') return; @@ -34,6 +47,69 @@ class NativeNotificationService { }); } + /// Keeps the iOS app icon badge in sync with the pending notification + /// events. Only the notification service extension ever sets a badge on a + /// delivered alert, so without this the number stays at whatever the last + /// push reported even after every message has been read. + static void _startBadgeSync() { + if (!Platform.isIOS || _outboxChanges != null) return; + try { + _outboxChanges = RustAppDatabase.changes().listen( + (tables) { + // An empty batch means Rust could not say what changed, so the + // outbox has to be assumed stale as well. + if (tables.isNotEmpty && !tables.contains(_outboxTable)) return; + unawaited(refreshBadgeCount()); + }, + onError: (Object error) { + Log.error('Notification badge change stream failed: $error'); + }, + ); + } catch (e) { + Log.error('Could not watch the notification outbox: $e'); + return; + } + unawaited(refreshBadgeCount()); + } + + /// Reads the pending event count from Rust and writes it to the app icon. + static Future refreshBadgeCount() async { + if (!Platform.isIOS) return; + try { + final count = await RustApi.notificationBadgeCount(); + await _channel.invokeMethod('setBadgeCount', { + _badgeCountKey: count, + }); + } catch (e) { + Log.error('Could not update the app icon badge: $e'); + } + } + + /// Acknowledges every pending notification of a conversation the user just + /// opened and withdraws the alerts still on screen. + static Future clearConversation(String conversationId) async { + if (conversationId.isEmpty) return; + try { + final notificationIds = await RustApi.clearConversationNotifications( + conversationId: conversationId, + ); + await cancelNotifications(notificationIds); + } catch (e) { + Log.error('Could not clear the notifications of a conversation: $e'); + } + } + + /// Acknowledges the contact requests the user just looked at. They belong to + /// no conversation, so this is the only moment they can be cleared. + static Future clearContactRequests() async { + try { + final notificationIds = await RustApi.clearContactRequestNotifications(); + await cancelNotifications(notificationIds); + } catch (e) { + Log.error('Could not clear the contact request notifications: $e'); + } + } + /// Returns the tap that launched the app, or `null` when the app was not /// started from a native notification. The result is consumed once. static Future<({String? conversationId})?> consumeInitialTap() async { diff --git a/lib/src/visual/views/chats/chat_messages.view.dart b/lib/src/visual/views/chats/chat_messages.view.dart index 541d9669..7050d979 100644 --- a/lib/src/visual/views/chats/chat_messages.view.dart +++ b/lib/src/visual/views/chats/chat_messages.view.dart @@ -118,9 +118,23 @@ class _ChatMessagesViewState extends State textFieldFocus = FocusNode(); WidgetsBinding.instance.addObserver(this); itemPositionsListener.itemPositions.addListener(_loadOlderWhenNeeded); + // Opening a conversation acknowledges everything it has pending, including + // the events that survive `notifyMessagesOpened` such as reactions and + // media status updates. Without this they keep inflating the app badge. + unawaited(NativeNotificationService.clearConversation(widget.groupId)); initStreams(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + if (state != AppLifecycleState.resumed) return; + if (!mounted || !(ModalRoute.of(context)?.isCurrent ?? false)) return; + // Notifications that arrived while this chat sat in the background are + // acknowledged as soon as the user looks at it again. + unawaited(NativeNotificationService.clearConversation(widget.groupId)); + } + @override void dispose() { _subscriptions.cancelAll(); diff --git a/lib/src/visual/views/contact/add_new_contact.view.dart b/lib/src/visual/views/contact/add_new_contact.view.dart index c9b11f40..1b85c17e 100644 --- a/lib/src/visual/views/contact/add_new_contact.view.dart +++ b/lib/src/visual/views/contact/add_new_contact.view.dart @@ -15,6 +15,7 @@ import 'package:twonly/src/database/daos/user_discovery.dao.dart'; import 'package:twonly/src/database/tables/contacts.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/services/api/utils.api.dart'; +import 'package:twonly/src/services/notifications/native.notifications.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/alert.dialog.dart'; import 'package:twonly/src/visual/components/profile_qr_code.comp.dart'; @@ -55,6 +56,9 @@ class _SearchUsernameView extends State { @override void initState() { super.initState(); + // Contact requests belong to no conversation, so this list is the only + // place they can be acknowledged and taken off the app badge. + unawaited(NativeNotificationService.clearContactRequests()); _contactsStream = twonlyDB.contactsDao.watchNotAcceptedContacts().listen( (update) { if (mounted) { diff --git a/lib/src/visual/views/groups/group.view.dart b/lib/src/visual/views/groups/group.view.dart index 1eeead3e..57225a81 100644 --- a/lib/src/visual/views/groups/group.view.dart +++ b/lib/src/visual/views/groups/group.view.dart @@ -54,6 +54,16 @@ class _GroupViewState extends State { } Future initAsync() async { + // Opening the group is the user's way of retrying: ask for every member + // key that is still missing, ignoring the per-member request interval. + // Without a member's key the admin actions on them stay hidden. + unawaited( + rust_groups.fetchMissingGroupPublicKeys( + groupId: widget.groupId, + force: true, + ), + ); + final groupStream = twonlyDB.groupsDao.watchGroup(widget.groupId); groupSub = groupStream.listen((update) { if (update != null) { @@ -67,7 +77,9 @@ class _GroupViewState extends State { setState(() { members = update; members.sort( - (b, a) => a.$2.memberState!.index.compareTo(b.$2.memberState!.index), + (b, a) => (a.$2.memberState ?? MemberState.normal).index.compareTo( + (b.$2.memberState ?? MemberState.normal).index, + ), ); }); }); diff --git a/lib/src/visual/views/groups/group_member.context.dart b/lib/src/visual/views/groups/group_member.context.dart index c3c9bfae..f6b4f24e 100644 --- a/lib/src/visual/views/groups/group_member.context.dart +++ b/lib/src/visual/views/groups/group_member.context.dart @@ -38,7 +38,6 @@ class GroupMemberContextMenu extends StatelessWidget { if (ok) { if (!await rust_groups.manageAdminState( groupId: group.groupId, - groupPublicKey: member.groupPublicKey!, contactId: contact.userId, remove: false, )) { @@ -59,7 +58,6 @@ class GroupMemberContextMenu extends StatelessWidget { if (ok) { if (!await rust_groups.manageAdminState( groupId: group.groupId, - groupPublicKey: member.groupPublicKey!, contactId: contact.userId, remove: true, )) { @@ -79,7 +77,6 @@ class GroupMemberContextMenu extends StatelessWidget { if (ok) { if (!await rust_groups.removeMemberFromGroup( groupId: group.groupId, - groupPublicKey: member.groupPublicKey!, contactId: contact.userId, )) { if (context.mounted) { @@ -149,7 +146,7 @@ class GroupMemberContextMenu extends StatelessWidget { ), if (member.groupPublicKey != null && group.isGroupAdmin && - member.memberState == MemberState.normal) + (member.memberState ?? MemberState.normal) == MemberState.normal) ContextMenuItem( title: context.lang.makeAdmin, onTap: () => _makeContactAdmin(navigator.context), @@ -163,7 +160,7 @@ class GroupMemberContextMenu extends StatelessWidget { onTap: () => _removeContactAsAdmin(navigator.context), icon: FontAwesomeIcons.key, ), - if (group.isGroupAdmin && member.groupPublicKey != null) + if (group.isGroupAdmin) ContextMenuItem( title: context.lang.removeFromGroup, onTap: () => _removeContactFromGroup(navigator.context), diff --git a/rust/.sqlx/query-2b5ae0640585d4a0c5c1c521103d2279469d8645feca81bdadb717b39ad7ccd1.json b/rust/.sqlx/query-2b5ae0640585d4a0c5c1c521103d2279469d8645feca81bdadb717b39ad7ccd1.json new file mode 100644 index 00000000..1a14c3ae --- /dev/null +++ b/rust/.sqlx/query-2b5ae0640585d4a0c5c1c521103d2279469d8645feca81bdadb717b39ad7ccd1.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n UPDATE notification_outbox SET cleared_at = ?\n WHERE kind IN ('contact_request', 'accept_request') AND cleared_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "2b5ae0640585d4a0c5c1c521103d2279469d8645feca81bdadb717b39ad7ccd1" +} diff --git a/rust/.sqlx/query-411a854abb8d23c104b7290c25313c679ef0361348c17d2ee740d30ad8c5a424.json b/rust/.sqlx/query-411a854abb8d23c104b7290c25313c679ef0361348c17d2ee740d30ad8c5a424.json deleted file mode 100644 index 52de7ccd..00000000 --- a/rust/.sqlx/query-411a854abb8d23c104b7290c25313c679ef0361348c17d2ee740d30ad8c5a424.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE group_members SET last_message = CAST(strftime('%s','now') AS INTEGER) WHERE group_id = ? AND contact_id = ?", - "describe": { - "columns": [], - "parameters": { - "Right": 2 - }, - "nullable": [] - }, - "hash": "411a854abb8d23c104b7290c25313c679ef0361348c17d2ee740d30ad8c5a424" -} diff --git a/rust/.sqlx/query-5164aba96e6ee8f761644482263b30e96adf6a2704e98310de21fc832f7a187f.json b/rust/.sqlx/query-5164aba96e6ee8f761644482263b30e96adf6a2704e98310de21fc832f7a187f.json new file mode 100644 index 00000000..d09f1226 --- /dev/null +++ b/rust/.sqlx/query-5164aba96e6ee8f761644482263b30e96adf6a2704e98310de21fc832f7a187f.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT signal_version FROM contacts WHERE user_id = ?", + "describe": { + "columns": [ + { + "name": "signal_version", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "contacts", + "name": "signal_version" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "5164aba96e6ee8f761644482263b30e96adf6a2704e98310de21fc832f7a187f" +} diff --git a/rust/.sqlx/query-7cc39bf45c3395a1f71a40d027afb0b2b7a14f18fa0ed0c66e5c9cac5678f4d4.json b/rust/.sqlx/query-7cc39bf45c3395a1f71a40d027afb0b2b7a14f18fa0ed0c66e5c9cac5678f4d4.json new file mode 100644 index 00000000..61e62cec --- /dev/null +++ b/rust/.sqlx/query-7cc39bf45c3395a1f71a40d027afb0b2b7a14f18fa0ed0c66e5c9cac5678f4d4.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE contacts SET signal_version = 'v2', account_deleted = 0 WHERE user_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "7cc39bf45c3395a1f71a40d027afb0b2b7a14f18fa0ed0c66e5c9cac5678f4d4" +} diff --git a/rust/.sqlx/query-9841b7394adf2125090839e8381f8db55945d8230b5450aacc1620c975a59387.json b/rust/.sqlx/query-9841b7394adf2125090839e8381f8db55945d8230b5450aacc1620c975a59387.json new file mode 100644 index 00000000..6a35564a --- /dev/null +++ b/rust/.sqlx/query-9841b7394adf2125090839e8381f8db55945d8230b5450aacc1620c975a59387.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT notification_id FROM notification_outbox\n WHERE kind IN ('contact_request', 'accept_request') AND cleared_at IS NULL\n ", + "describe": { + "columns": [ + { + "name": "notification_id", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "notification_outbox", + "name": "notification_id" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false + ] + }, + "hash": "9841b7394adf2125090839e8381f8db55945d8230b5450aacc1620c975a59387" +} diff --git a/rust/.sqlx/query-6d6a5fb10742309ab6e4242179ef86b64ac9f751f1c6d647d4ecfa5ecac5d332.json b/rust/.sqlx/query-e15b811d31ca59847e3d4ee2eff71410100258a0a5fe894233d63b6ac770368f.json similarity index 51% rename from rust/.sqlx/query-6d6a5fb10742309ab6e4242179ef86b64ac9f751f1c6d647d4ecfa5ecac5d332.json rename to rust/.sqlx/query-e15b811d31ca59847e3d4ee2eff71410100258a0a5fe894233d63b6ac770368f.json index 98aec940..d05ed6ef 100644 --- a/rust/.sqlx/query-6d6a5fb10742309ab6e4242179ef86b64ac9f751f1c6d647d4ecfa5ecac5d332.json +++ b/rust/.sqlx/query-e15b811d31ca59847e3d4ee2eff71410100258a0a5fe894233d63b6ac770368f.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT group_id, contact_id \n FROM group_members \n WHERE group_public_key IS NULL \n AND last_message >= CAST(strftime('%s','now','-2 days') AS INTEGER)\n ", + "query": "\n SELECT members.group_id, members.contact_id\n FROM group_members AS members\n JOIN groups ON groups.group_id = members.group_id\n WHERE members.group_public_key IS NULL\n AND (members.member_state IS NULL OR members.member_state != 'leftGroup')\n AND groups.is_direct_chat = 0\n AND groups.left_group = 0\n AND (? IS NULL OR members.group_id = ?)\n ", "describe": { "columns": [ { @@ -27,12 +27,12 @@ } ], "parameters": { - "Right": 0 + "Right": 2 }, "nullable": [ false, false ] }, - "hash": "6d6a5fb10742309ab6e4242179ef86b64ac9f751f1c6d647d4ecfa5ecac5d332" + "hash": "e15b811d31ca59847e3d4ee2eff71410100258a0a5fe894233d63b6ac770368f" } diff --git a/rust/src/api/messages/incoming.rs b/rust/src/api/messages/incoming.rs index ce44d193..caca9e6c 100644 --- a/rust/src/api/messages/incoming.rs +++ b/rust/src/api/messages/incoming.rs @@ -203,6 +203,65 @@ pub(crate) async fn handle_request_new_pqc_prekeys( )) } +/// Rebuilds a v2 Signal session for a peer that still speaks the legacy +/// protocol, so the decryption error queued for the message can be answered +/// with a session the peer can upgrade to. +/// +/// Runs before the inbound transaction opens: it awaits a server round-trip and +/// `establish_signal_session` writes through the pool, and the app database +/// allows a single connection, so doing this under an open transaction would +/// deadlock until the acquire timeout. +async fn upgrade_legacy_session_to_v2( + ctx: &Arc, + database: &Arc, + from_user_id: i64, +) -> Result<()> { + tracing::info!("Received legacy signal message; rejecting and upgrading session to v2"); + + let has_v2_session = { + let rust_database = ctx.rust_db.read().await.clone(); + sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM signal_sessions WHERE name = ? AND device_id = 1)", + from_user_id.to_string(), + ) + .fetch_one(&rust_database.pool) + .await? + != 0 + }; + + let is_v2_contact = sqlx::query_scalar!( + "SELECT signal_version FROM contacts WHERE user_id = ?", + from_user_id, + ) + .fetch_optional(&database.pool) + .await? + .is_some_and(|version| version == "v2"); + + if has_v2_session && is_v2_contact { + return Ok(()); + } + + if let Err(error) = ContactService::new(ctx) + .establish_signal_session(from_user_id, None) + .await + { + tracing::warn!( + from_user_id, + "failed to establish v2 signal session from server: {error}" + ); + } + + sqlx::query!( + "UPDATE contacts SET signal_version = 'v2' WHERE user_id = ?", + from_user_id, + ) + .execute(&database.pool) + .await?; + database.notify_committed(["contacts"]); + + Ok(()) +} + #[tracing::instrument( skip_all, fields( @@ -242,6 +301,13 @@ pub(crate) async fn handle_decoded_server_message( let database = ctx.app_db.read().await.clone(); + // Upgrading a legacy peer to a v2 session needs a server round-trip and + // writes through the pool itself, so it has to finish before the + // transaction below claims the single app-database connection. + if matches!(message_type, Type::Ciphertext | Type::PrekeyBundle) { + upgrade_legacy_session_to_v2(ctx, &database, from_user_id).await?; + } + let mut t = database.pool.begin().await?; let claimed = Receipt::claim_received(&mut t, &message.receipt_id).await?; @@ -277,42 +343,6 @@ pub(crate) async fn handle_decoded_server_message( handle_sender_delivery_receipt(&mut t, from_user_id, &message.receipt_id).await?; } Type::Ciphertext | Type::PrekeyBundle => { - tracing::info!("Received legacy signal message; rejecting and upgrading session to v2"); - - let has_v2_session = { - let rust_database = ctx.rust_db.read().await.clone(); - sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM signal_sessions WHERE name = ? AND device_id = 1)", - from_user_id.to_string(), - ) - .fetch_one(&rust_database.pool) - .await? - != 0 - }; - - let is_v2_contact = { - let contact = Contact::get_contact_by_id(&mut t, from_user_id).await?; - contact.as_ref().is_some_and(|c| c.signal_version == "v2") - }; - - if !has_v2_session || !is_v2_contact { - if let Err(error) = ContactService::new(ctx) - .establish_signal_session(from_user_id, None) - .await - { - tracing::warn!( - from_user_id, - "failed to establish v2 signal session from server: {error}" - ); - } - sqlx::query!( - "UPDATE contacts SET signal_version = 'v2' WHERE user_id = ?", - from_user_id, - ) - .execute(&mut *t) - .await?; - } - queue_decryption_error(&mut t, from_user_id, &message.receipt_id, 0).await?; sends_error_response = true; } diff --git a/rust/src/api/messages/incoming/errors.rs b/rust/src/api/messages/incoming/errors.rs index e0ce33fe..12dd14b8 100644 --- a/rust/src/api/messages/incoming/errors.rs +++ b/rust/src/api/messages/incoming/errors.rs @@ -46,14 +46,19 @@ pub(crate) async fn handle_error_message( } Type::GroupNotFoundOrNotAMember => { if let Some(group_id) = group_id { - GroupService::new(ctx) - .handle_membership_error( - t, - from_user_id, - group_id.to_owned(), - error.related_receipt_id, - ) - .await?; + // The repair refreshes the group from the server and owns its + // own transaction, so it has to run once this one commits. + let ctx = ctx.clone(); + let group_id = group_id.to_owned(); + let related_receipt_id = error.related_receipt_id; + tokio::spawn(async move { + if let Err(error) = GroupService::new(&ctx) + .handle_membership_error(from_user_id, group_id, related_receipt_id) + .await + { + tracing::warn!("group membership repair failed: {error}"); + } + }); } } Type::SessionOutOfSync | Type::UnknownMessageType => {} diff --git a/rust/src/api/messages/incoming/groups.rs b/rust/src/api/messages/incoming/groups.rs index 3baecbf5..c69824c3 100644 --- a/rust/src/api/messages/incoming/groups.rs +++ b/rust/src/api/messages/incoming/groups.rs @@ -91,9 +91,7 @@ pub(crate) async fn handle_group_create( .execute(&mut **t) .await?; - GroupService::new(ctx) - .refresh_group_state(t, group_id.to_owned(), true) - .await; + GroupService::spawn_state_refresh_and_announce(ctx, group_id.to_owned()); Ok(()) } @@ -175,9 +173,7 @@ pub(crate) async fn handle_group_update( let is_direct = Group::is_direct_chat(t, group_id).await?; if !is_direct { - GroupService::new(ctx) - .refresh_group_state(t, group_id.to_owned(), false) - .await; + GroupService::spawn_state_refresh(ctx, Some(group_id.to_owned())); } if update.group_action_type == "updatedGroupName" { diff --git a/rust/src/api/messages/incoming/messages.rs b/rust/src/api/messages/incoming/messages.rs index 219f2a01..1c96acfb 100644 --- a/rust/src/api/messages/incoming/messages.rs +++ b/rust/src/api/messages/incoming/messages.rs @@ -298,7 +298,6 @@ pub(crate) struct PreparedQueuedReceipt { pub contact_id: i64, pub message_id: Option, pub contact_will_sends_receipt: i64, - pub account_deleted: i64, pub payload: Vec, pub wake_receiver: bool, } @@ -313,12 +312,11 @@ struct PreparedQueuedReceiptRow { signal_version: String, } -pub(crate) async fn prepare_queued_receipt_details( - ctx: &Arc, +async fn load_queued_receipt_row( + pool: &sqlx::SqlitePool, receipt_id: &str, -) -> Result> { - let app_db = ctx.app_db.read().await.clone(); - let row = sqlx::query_as!( +) -> Result> { + Ok(sqlx::query_as!( PreparedQueuedReceiptRow, r#" SELECT r.contact_id, r.message, r.message_id, r.contact_will_sends_receipt, @@ -329,13 +327,39 @@ pub(crate) async fn prepare_queued_receipt_details( "#, receipt_id, ) - .fetch_optional(&app_db.pool) - .await?; + .fetch_optional(pool) + .await?) +} - let Some(row) = row else { +pub(crate) async fn prepare_queued_receipt_details( + ctx: &Arc, + receipt_id: &str, +) -> Result> { + let app_db = ctx.app_db.read().await.clone(); + let Some(row) = load_queued_receipt_row(&app_db.pool, receipt_id).await? else { return Ok(None); }; + if row.account_deleted != 0 { + return Err(TwonlyError::Generic(format!( + "contact {} deleted their account", + row.contact_id + ))); + } + + prepare_queued_receipt_from_row(ctx, receipt_id, row) + .await + .map(Some) +} + +/// Encrypts the queued payload. Fetches a prekey bundle from the server when +/// the contact has no v2 session yet, so the caller must have ruled out a +/// deleted account first. +async fn prepare_queued_receipt_from_row( + ctx: &Arc, + receipt_id: &str, + row: PreparedQueuedReceiptRow, +) -> Result { let mut message = proto::Message::decode(row.message.as_slice()) .map_err(|error| TwonlyError::Generic(format!("invalid queued message: {error}")))?; message.receipt_id = receipt_id.to_owned(); @@ -366,14 +390,13 @@ pub(crate) async fn prepare_queued_receipt_details( message.r#type = proto::message::Type::CiphertextV2 as i32; } - Ok(Some(PreparedQueuedReceipt { + Ok(PreparedQueuedReceipt { contact_id: row.contact_id, message_id: row.message_id, contact_will_sends_receipt: row.contact_will_sends_receipt, - account_deleted: row.account_deleted, payload: message.encode_to_vec(), wake_receiver: row.wake_receiver != 0, - })) + }) } pub(crate) async fn prepare_queued_receipt( @@ -402,16 +425,21 @@ pub(crate) async fn send_queued_receipt(ctx: &Arc, receipt_id: &str) -> } let app_db = ctx.app_db.read().await.clone(); - let Some(receipt) = prepare_queued_receipt_details(ctx, receipt_id).await? else { + let Some(row) = load_queued_receipt_row(&app_db.pool, receipt_id).await? else { return Ok(()); }; - if receipt.account_deleted != 0 { + // The server has no account for this contact any more, so preparing the + // payload would only fetch a prekey bundle that answers `UserIdNotFound` + // on every retry. Drop the receipt instead of queueing it forever. + if row.account_deleted != 0 { Receipt::delete(&app_db.pool, receipt_id).await?; app_db.notify_committed(["receipts"]); return Ok(()); } + let receipt = prepare_queued_receipt_from_row(ctx, receipt_id, row).await?; + match Server::send_text_message( ctx, receipt.contact_id, diff --git a/rust/src/api/server/contacts.rs b/rust/src/api/server/contacts.rs index 5c0e2232..2db814fe 100644 --- a/rust/src/api/server/contacts.rs +++ b/rust/src/api/server/contacts.rs @@ -15,11 +15,14 @@ use std::sync::Arc; impl Server { pub async fn get_user_by_id_response(ctx: &Arc, user_id: i64) -> Result> { - Self::application( + // Sent for the contact so that a `UserIdNotFound` answer marks the + // account as deleted instead of failing every prekey bundle fetch. + Self::application_for_contact( ctx, client_to_server::application_data::ApplicationData::GetUserById( client_to_server::application_data::GetUserById { user_id }, ), + user_id, ) .await } diff --git a/rust/src/bridge/api.rs b/rust/src/bridge/api.rs index dfe3ff1c..08db0534 100644 --- a/rust/src/bridge/api.rs +++ b/rust/src/bridge/api.rs @@ -667,6 +667,26 @@ impl RustApi { .await } + /// The number of pending notification events. iOS cannot derive its app + /// icon badge from the delivered alerts, so the running app pushes this + /// into `UNUserNotificationCenter` whenever the outbox changes. + pub async fn notification_badge_count() -> Result { + crate::services::notifications::badge_count(Context::get_static()?).await + } + + /// Acknowledges every pending notification of a conversation the user just + /// opened and returns the native notification IDs to withdraw. + pub async fn clear_conversation_notifications(conversation_id: String) -> Result> { + crate::services::notifications::clear_conversation(Context::get_static()?, &conversation_id) + .await + } + + /// Acknowledges the pending contact-request notifications and returns the + /// native notification IDs to withdraw. + pub async fn clear_contact_request_notifications() -> Result> { + crate::services::notifications::clear_contact_requests(Context::get_static()?).await + } + pub async fn send_contact_profile(contact_id: i64) -> Result<()> { let ctx = Context::get_static()?; ContactService::new(ctx).send_profile(contact_id).await diff --git a/rust/src/bridge/groups.rs b/rust/src/bridge/groups.rs index 71cc6e97..2dd89111 100644 --- a/rust/src/bridge/groups.rs +++ b/rust/src/bridge/groups.rs @@ -29,19 +29,14 @@ pub async fn fetch_group_states_for_unjoined_groups() -> Result<()> { .await } -pub async fn fetch_missing_group_public_keys() -> Result<()> { +pub async fn fetch_missing_group_public_keys(group_id: Option, force: bool) -> Result<()> { let ctx = crate::context::Context::get_static()?; GroupService::new(ctx) - .fetch_missing_group_public_keys() + .fetch_missing_group_public_keys(group_id, force) .await } -pub async fn manage_admin_state( - group_id: String, - _group_public_key: Vec, - contact_id: i64, - remove: bool, -) -> Result { +pub async fn manage_admin_state(group_id: String, contact_id: i64, remove: bool) -> Result { let ctx = crate::context::Context::get_static()?; GroupService::new(ctx) .manage_admin(group_id, contact_id, remove) @@ -72,14 +67,10 @@ pub async fn add_new_group_members(group_id: String, member_ids: Vec) -> Re .await } -pub async fn remove_member_from_group( - group_id: String, - group_public_key: Vec, - contact_id: i64, -) -> Result { +pub async fn remove_member_from_group(group_id: String, contact_id: i64) -> Result { let ctx = crate::context::Context::get_static()?; GroupService::new(ctx) - .remove_member(group_id, group_public_key, contact_id) + .remove_member(group_id, contact_id) .await } diff --git a/rust/src/database/app/tables/groups.rs b/rust/src/database/app/tables/groups.rs index 32568044..8a4f76ef 100644 --- a/rust/src/database/app/tables/groups.rs +++ b/rust/src/database/app/tables/groups.rs @@ -472,27 +472,67 @@ pub struct MissingGroupPublicKeyRow { pub contact_id: i64, } +/// Lists the members whose group public key is still unknown. +/// +/// Direct chats are excluded: they never carry a group identity, so the peer +/// has no key to resend and would answer every request with an error. Left +/// groups and members who left are excluded for the same reason -- their key +/// is of no use any more. #[derive(bon::Builder)] -pub struct GetMissingGroupPublicKeys {} +pub struct GetMissingGroupPublicKeys { + group_id: Option, +} impl GetMissingGroupPublicKeys { pub async fn fetch_all( self, pool: &sqlx::Pool, ) -> Result> { + let group_id = self.group_id.as_deref(); let rows = sqlx::query_as!( MissingGroupPublicKeyRow, r#" - SELECT group_id, contact_id - FROM group_members - WHERE group_public_key IS NULL - AND last_message >= CAST(strftime('%s','now','-2 days') AS INTEGER) - "# + SELECT members.group_id, members.contact_id + FROM group_members AS members + JOIN groups ON groups.group_id = members.group_id + WHERE members.group_public_key IS NULL + AND (members.member_state IS NULL OR members.member_state != 'leftGroup') + AND groups.is_direct_chat = 0 + AND groups.left_group = 0 + AND (? IS NULL OR members.group_id = ?) + "#, + group_id, + group_id, ) .fetch_all(pool) .await?; Ok(rows) } + + pub async fn fetch_all_in_transaction( + self, + tr: &mut Transaction<'_, Sqlite>, + ) -> Result> { + let group_id = self.group_id.as_deref(); + let rows = sqlx::query_as!( + MissingGroupPublicKeyRow, + r#" + SELECT members.group_id, members.contact_id + FROM group_members AS members + JOIN groups ON groups.group_id = members.group_id + WHERE members.group_public_key IS NULL + AND (members.member_state IS NULL OR members.member_state != 'leftGroup') + AND groups.is_direct_chat = 0 + AND groups.left_group = 0 + AND (? IS NULL OR members.group_id = ?) + "#, + group_id, + group_id, + ) + .fetch_all(&mut **tr) + .await?; + Ok(rows) + } } fn current_unix_timestamp() -> Result { diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index 938f111b..0cf674eb 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1780439173; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 463166747; // Section: executor @@ -258,12 +258,17 @@ fn wire__crate__bridge__groups__fetch_missing_group_public_keys_impl( }; let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_group_id = >::sse_decode(&mut deserializer); + let api_force = ::sse_decode(&mut deserializer); deserializer.end(); move |context| async move { transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( (move || async move { - let output_ok = - crate::bridge::groups::fetch_missing_group_public_keys().await?; + let output_ok = crate::bridge::groups::fetch_missing_group_public_keys( + api_group_id, + api_force, + ) + .await?; Ok(output_ok) })() .await, @@ -520,7 +525,6 @@ fn wire__crate__bridge__groups__manage_admin_state_impl( let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); let api_group_id = ::sse_decode(&mut deserializer); - let api__group_public_key = >::sse_decode(&mut deserializer); let api_contact_id = ::sse_decode(&mut deserializer); let api_remove = ::sse_decode(&mut deserializer); deserializer.end(); @@ -529,7 +533,6 @@ fn wire__crate__bridge__groups__manage_admin_state_impl( (move || async move { let output_ok = crate::bridge::groups::manage_admin_state( api_group_id, - api__group_public_key, api_contact_id, api_remove, ) @@ -565,7 +568,6 @@ fn wire__crate__bridge__groups__remove_member_from_group_impl( let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); let api_group_id = ::sse_decode(&mut deserializer); - let api_group_public_key = >::sse_decode(&mut deserializer); let api_contact_id = ::sse_decode(&mut deserializer); deserializer.end(); move |context| async move { @@ -573,7 +575,6 @@ fn wire__crate__bridge__groups__remove_member_from_group_impl( (move || async move { let output_ok = crate::bridge::groups::remove_member_from_group( api_group_id, - api_group_public_key, api_contact_id, ) .await?; @@ -805,6 +806,83 @@ fn wire__crate__bridge__api__rust_api_check_for_passwordless_notification_impl( }, ) } +fn wire__crate__bridge__api__rust_api_clear_contact_request_notifications_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_clear_contact_request_notifications", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::api::RustApi::clear_contact_request_notifications() + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__bridge__api__rust_api_clear_conversation_notifications_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_clear_conversation_notifications", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_conversation_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::api::RustApi::clear_conversation_notifications( + api_conversation_id, + ) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__api__rust_api_close_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -1835,6 +1913,42 @@ fn wire__crate__bridge__api__rust_api_load_plan_balance_impl( }, ) } +fn wire__crate__bridge__api__rust_api_notification_badge_count_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_api_notification_badge_count", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::api::RustApi::notification_badge_count().await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__api__rust_api_notify_messages_opened_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -3915,6 +4029,40 @@ fn wire__crate__bridge__wrapper__rust_utils_recover_secret_impl( }, ) } +fn wire__crate__bridge__callbacks__log__strip_ansi_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "strip_ansi", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_input = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let output_ok = + Result::<_, ()>::Ok(crate::bridge::callbacks::log::strip_ansi(&api_input))?; + Ok(output_ok) + })()) + } + }, + ) +} fn wire__crate__bridge__groups__update_chat_deletion_time_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -5596,101 +5744,105 @@ fn pde_ffi_dispatcher_primary_impl( 19 => wire__crate__bridge__api__rust_api_change_username_impl(port, ptr, rust_vec_len, data_len), 20 => wire__crate__bridge__api__rust_api_check_for_deleted_usernames_impl(port, ptr, rust_vec_len, data_len), 21 => wire__crate__bridge__api__rust_api_check_for_passwordless_notification_impl(port, ptr, rust_vec_len, data_len), -22 => wire__crate__bridge__api__rust_api_close_impl(port, ptr, rust_vec_len, data_len), -23 => wire__crate__bridge__api__rust_api_confirm_memories_upload_impl(port, ptr, rust_vec_len, data_len), -24 => wire__crate__bridge__api__rust_api_connect_impl(port, ptr, rust_vec_len, data_len), -25 => wire__crate__bridge__api__rust_api_connection_state_impl(port, ptr, rust_vec_len, data_len), -26 => wire__crate__bridge__api__rust_api_delete_account_impl(port, ptr, rust_vec_len, data_len), -27 => wire__crate__bridge__api__rust_api_delete_memory_impl(port, ptr, rust_vec_len, data_len), -28 => wire__crate__bridge__api__rust_api_disable_memories_backup_impl(port, ptr, rust_vec_len, data_len), -29 => wire__crate__bridge__api__rust_api_download_done_impl(port, ptr, rust_vec_len, data_len), -30 => wire__crate__bridge__api__rust_api_download_media_impl(port, ptr, rust_vec_len, data_len), -31 => wire__crate__bridge__api__rust_api_download_pending_media_impl(port, ptr, rust_vec_len, data_len), -32 => wire__crate__bridge__api__rust_api_establish_signal_session_impl(port, ptr, rust_vec_len, data_len), -33 => wire__crate__bridge__api__rust_api_events_impl(port, ptr, rust_vec_len, data_len), -34 => wire__crate__bridge__api__rust_api_force_ipa_check_impl(port, ptr, rust_vec_len, data_len), -35 => wire__crate__bridge__api__rust_api_get_memories_url_impl(port, ptr, rust_vec_len, data_len), -36 => wire__crate__bridge__api__rust_api_get_memories_usage_impl(port, ptr, rust_vec_len, data_len), -37 => wire__crate__bridge__api__rust_api_get_plan_balance_impl(port, ptr, rust_vec_len, data_len), -38 => wire__crate__bridge__api__rust_api_get_proof_of_work_impl(port, ptr, rust_vec_len, data_len), -39 => wire__crate__bridge__api__rust_api_get_server_key_for_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len), -40 => wire__crate__bridge__api__rust_api_get_user_by_id_impl(port, ptr, rust_vec_len, data_len), -41 => wire__crate__bridge__api__rust_api_get_user_data_impl(port, ptr, rust_vec_len, data_len), -42 => wire__crate__bridge__api__rust_api_get_user_id_from_username_impl(port, ptr, rust_vec_len, data_len), -43 => wire__crate__bridge__api__rust_api_insert_and_send_additional_data_impl(port, ptr, rust_vec_len, data_len), -44 => wire__crate__bridge__api__rust_api_insert_and_send_ask_about_user_impl(port, ptr, rust_vec_len, data_len), -45 => wire__crate__bridge__api__rust_api_insert_and_send_contact_share_impl(port, ptr, rust_vec_len, data_len), -46 => wire__crate__bridge__api__rust_api_insert_and_send_text_impl(port, ptr, rust_vec_len, data_len), -47 => wire__crate__bridge__api__rust_api_ipa_purchase_impl(port, ptr, rust_vec_len, data_len), -48 => wire__crate__bridge__api__rust_api_load_plan_balance_impl(port, ptr, rust_vec_len, data_len), -49 => wire__crate__bridge__api__rust_api_notify_messages_opened_impl(port, ptr, rust_vec_len, data_len), -50 => wire__crate__bridge__api__rust_api_perform_passwordless_recovery_heartbeat_impl(port, ptr, rust_vec_len, data_len), -51 => wire__crate__bridge__api__rust_api_prepare_queued_message_impl(port, ptr, rust_vec_len, data_len), -52 => wire__crate__bridge__api__rust_api_register_impl(port, ptr, rust_vec_len, data_len), -53 => wire__crate__bridge__api__rust_api_register_passwordless_notification_impl(port, ptr, rust_vec_len, data_len), -54 => wire__crate__bridge__api__rust_api_register_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len), -55 => wire__crate__bridge__api__rust_api_reload_configuration_impl(port, ptr, rust_vec_len, data_len), -56 => wire__crate__bridge__api__rust_api_remove_additional_user_impl(port, ptr, rust_vec_len, data_len), -57 => wire__crate__bridge__api__rust_api_report_user_impl(port, ptr, rust_vec_len, data_len), -58 => wire__crate__bridge__api__rust_api_request_binary_impl(port, ptr, rust_vec_len, data_len), -59 => wire__crate__bridge__api__rust_api_request_contact_by_username_impl(port, ptr, rust_vec_len, data_len), -60 => wire__crate__bridge__api__rust_api_request_media_reupload_impl(port, ptr, rust_vec_len, data_len), -61 => wire__crate__bridge__api__rust_api_request_memories_upload_impl(port, ptr, rust_vec_len, data_len), -62 => wire__crate__bridge__api__rust_api_retransmit_all_messages_impl(port, ptr, rust_vec_len, data_len), -63 => wire__crate__bridge__api__rust_api_send_binary_impl(port, ptr, rust_vec_len, data_len), -64 => wire__crate__bridge__api__rust_api_send_contact_profile_impl(port, ptr, rust_vec_len, data_len), -65 => wire__crate__bridge__api__rust_api_send_encrypted_content_impl(port, ptr, rust_vec_len, data_len), -66 => wire__crate__bridge__api__rust_api_send_encrypted_content_to_group_impl(port, ptr, rust_vec_len, data_len), -67 => wire__crate__bridge__api__rust_api_send_queued_message_impl(port, ptr, rust_vec_len, data_len), -68 => wire__crate__bridge__api__rust_api_send_text_message_impl(port, ptr, rust_vec_len, data_len), -69 => wire__crate__bridge__api__rust_api_send_typing_impl(port, ptr, rust_vec_len, data_len), -70 => wire__crate__bridge__api__rust_api_set_background_impl(port, ptr, rust_vec_len, data_len), -71 => wire__crate__bridge__api__rust_api_set_login_token_impl(port, ptr, rust_vec_len, data_len), -72 => wire__crate__bridge__api__rust_api_set_network_available_impl(port, ptr, rust_vec_len, data_len), -73 => wire__crate__bridge__api__rust_api_submit_recovery_share_impl(port, ptr, rust_vec_len, data_len), -74 => wire__crate__bridge__api__rust_api_update_fcm_token_impl(port, ptr, rust_vec_len, data_len), -75 => wire__crate__bridge__api__rust_api_update_signed_pre_key_impl(port, ptr, rust_vec_len, data_len), -76 => wire__crate__bridge__api__rust_api_upload_pqc_pre_keys_impl(port, ptr, rust_vec_len, data_len), -77 => wire__crate__bridge__wrapper__app_database__rust_app_database_changes_impl(port, ptr, rust_vec_len, data_len), -78 => wire__crate__bridge__wrapper__app_database__rust_app_database_execute_impl(port, ptr, rust_vec_len, data_len), -79 => wire__crate__bridge__wrapper__app_database__rust_app_database_legacy_import_complete_impl(port, ptr, rust_vec_len, data_len), -80 => wire__crate__bridge__wrapper__app_database__rust_app_database_migrate_legacy_database_impl(port, ptr, rust_vec_len, data_len), -81 => wire__crate__bridge__wrapper__app_database__rust_app_database_select_impl(port, ptr, rust_vec_len, data_len), -82 => wire__crate__bridge__wrapper__backup__rust_backup_archive_create_backup_archive_impl(port, ptr, rust_vec_len, data_len), -83 => wire__crate__bridge__wrapper__backup__rust_backup_archive_get_backup_download_token_impl(port, ptr, rust_vec_len, data_len), -84 => wire__crate__bridge__wrapper__backup__rust_backup_archive_restore_backup_archive_impl(port, ptr, rust_vec_len, data_len), -85 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_id_impl(port, ptr, rust_vec_len, data_len), -86 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), -87 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_identity_backup_bytes_impl(port, ptr, rust_vec_len, data_len), -88 => wire__crate__bridge__wrapper__backup__rust_backup_identity_import_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), -89 => wire__crate__bridge__wrapper__backup__rust_backup_identity_restore_identity_backup_impl(port, ptr, rust_vec_len, data_len), -90 => wire__crate__bridge__wrapper__backup__rust_backup_identity_set_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), -91 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_decrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len), -92 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_encrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len), -93 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_login_token_impl(port, ptr, rust_vec_len, data_len), -94 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_signal_identity_impl(port, ptr, rust_vec_len, data_len), -95 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_user_id_impl(port, ptr, rust_vec_len, data_len), -96 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_serialized_impl(port, ptr, rust_vec_len, data_len), -97 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(port, ptr, rust_vec_len, data_len), -98 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_key_manager_impl(port, ptr, rust_vec_len, data_len), -99 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_serialize_impl(port, ptr, rust_vec_len, data_len), -100 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_set_user_id_impl(port, ptr, rust_vec_len, data_len), -101 => wire__crate__bridge__wrapper__signal__rust_signal_decrypt_impl(port, ptr, rust_vec_len, data_len), -102 => wire__crate__bridge__wrapper__signal__rust_signal_encrypt_impl(port, ptr, rust_vec_len, data_len), -103 => wire__crate__bridge__wrapper__signal__rust_signal_generate_bundle_impl(port, ptr, rust_vec_len, data_len), -104 => wire__crate__bridge__wrapper__signal__rust_signal_generate_pqc_prekeys_impl(port, ptr, rust_vec_len, data_len), -105 => wire__crate__bridge__wrapper__signal__rust_signal_get_contact_public_key_impl(port, ptr, rust_vec_len, data_len), -106 => wire__crate__bridge__wrapper__signal__rust_signal_get_user_public_key_impl(port, ptr, rust_vec_len, data_len), -107 => wire__crate__bridge__wrapper__signal__rust_signal_process_prekey_bundle_impl(port, ptr, rust_vec_len, data_len), -108 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len), -109 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len), -110 => wire__crate__bridge__groups__update_chat_deletion_time_impl(port, ptr, rust_vec_len, data_len), -111 => wire__crate__bridge__groups__update_group_name_impl(port, ptr, rust_vec_len, data_len), -113 => wire__crate__bridge__user_config__user_config_api_create_impl(port, ptr, rust_vec_len, data_len), -114 => wire__crate__bridge__user_config__user_config_api_import_json_impl(port, ptr, rust_vec_len, data_len), -115 => wire__crate__bridge__user_config__user_config_api_load_impl(port, ptr, rust_vec_len, data_len), -116 => wire__crate__bridge__user_config__user_config_api_save_impl(port, ptr, rust_vec_len, data_len), -117 => wire__crate__bridge__user_config__user_config_api_update_impl(port, ptr, rust_vec_len, data_len), +22 => wire__crate__bridge__api__rust_api_clear_contact_request_notifications_impl(port, ptr, rust_vec_len, data_len), +23 => wire__crate__bridge__api__rust_api_clear_conversation_notifications_impl(port, ptr, rust_vec_len, data_len), +24 => wire__crate__bridge__api__rust_api_close_impl(port, ptr, rust_vec_len, data_len), +25 => wire__crate__bridge__api__rust_api_confirm_memories_upload_impl(port, ptr, rust_vec_len, data_len), +26 => wire__crate__bridge__api__rust_api_connect_impl(port, ptr, rust_vec_len, data_len), +27 => wire__crate__bridge__api__rust_api_connection_state_impl(port, ptr, rust_vec_len, data_len), +28 => wire__crate__bridge__api__rust_api_delete_account_impl(port, ptr, rust_vec_len, data_len), +29 => wire__crate__bridge__api__rust_api_delete_memory_impl(port, ptr, rust_vec_len, data_len), +30 => wire__crate__bridge__api__rust_api_disable_memories_backup_impl(port, ptr, rust_vec_len, data_len), +31 => wire__crate__bridge__api__rust_api_download_done_impl(port, ptr, rust_vec_len, data_len), +32 => wire__crate__bridge__api__rust_api_download_media_impl(port, ptr, rust_vec_len, data_len), +33 => wire__crate__bridge__api__rust_api_download_pending_media_impl(port, ptr, rust_vec_len, data_len), +34 => wire__crate__bridge__api__rust_api_establish_signal_session_impl(port, ptr, rust_vec_len, data_len), +35 => wire__crate__bridge__api__rust_api_events_impl(port, ptr, rust_vec_len, data_len), +36 => wire__crate__bridge__api__rust_api_force_ipa_check_impl(port, ptr, rust_vec_len, data_len), +37 => wire__crate__bridge__api__rust_api_get_memories_url_impl(port, ptr, rust_vec_len, data_len), +38 => wire__crate__bridge__api__rust_api_get_memories_usage_impl(port, ptr, rust_vec_len, data_len), +39 => wire__crate__bridge__api__rust_api_get_plan_balance_impl(port, ptr, rust_vec_len, data_len), +40 => wire__crate__bridge__api__rust_api_get_proof_of_work_impl(port, ptr, rust_vec_len, data_len), +41 => wire__crate__bridge__api__rust_api_get_server_key_for_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len), +42 => wire__crate__bridge__api__rust_api_get_user_by_id_impl(port, ptr, rust_vec_len, data_len), +43 => wire__crate__bridge__api__rust_api_get_user_data_impl(port, ptr, rust_vec_len, data_len), +44 => wire__crate__bridge__api__rust_api_get_user_id_from_username_impl(port, ptr, rust_vec_len, data_len), +45 => wire__crate__bridge__api__rust_api_insert_and_send_additional_data_impl(port, ptr, rust_vec_len, data_len), +46 => wire__crate__bridge__api__rust_api_insert_and_send_ask_about_user_impl(port, ptr, rust_vec_len, data_len), +47 => wire__crate__bridge__api__rust_api_insert_and_send_contact_share_impl(port, ptr, rust_vec_len, data_len), +48 => wire__crate__bridge__api__rust_api_insert_and_send_text_impl(port, ptr, rust_vec_len, data_len), +49 => wire__crate__bridge__api__rust_api_ipa_purchase_impl(port, ptr, rust_vec_len, data_len), +50 => wire__crate__bridge__api__rust_api_load_plan_balance_impl(port, ptr, rust_vec_len, data_len), +51 => wire__crate__bridge__api__rust_api_notification_badge_count_impl(port, ptr, rust_vec_len, data_len), +52 => wire__crate__bridge__api__rust_api_notify_messages_opened_impl(port, ptr, rust_vec_len, data_len), +53 => wire__crate__bridge__api__rust_api_perform_passwordless_recovery_heartbeat_impl(port, ptr, rust_vec_len, data_len), +54 => wire__crate__bridge__api__rust_api_prepare_queued_message_impl(port, ptr, rust_vec_len, data_len), +55 => wire__crate__bridge__api__rust_api_register_impl(port, ptr, rust_vec_len, data_len), +56 => wire__crate__bridge__api__rust_api_register_passwordless_notification_impl(port, ptr, rust_vec_len, data_len), +57 => wire__crate__bridge__api__rust_api_register_passwordless_recovery_impl(port, ptr, rust_vec_len, data_len), +58 => wire__crate__bridge__api__rust_api_reload_configuration_impl(port, ptr, rust_vec_len, data_len), +59 => wire__crate__bridge__api__rust_api_remove_additional_user_impl(port, ptr, rust_vec_len, data_len), +60 => wire__crate__bridge__api__rust_api_report_user_impl(port, ptr, rust_vec_len, data_len), +61 => wire__crate__bridge__api__rust_api_request_binary_impl(port, ptr, rust_vec_len, data_len), +62 => wire__crate__bridge__api__rust_api_request_contact_by_username_impl(port, ptr, rust_vec_len, data_len), +63 => wire__crate__bridge__api__rust_api_request_media_reupload_impl(port, ptr, rust_vec_len, data_len), +64 => wire__crate__bridge__api__rust_api_request_memories_upload_impl(port, ptr, rust_vec_len, data_len), +65 => wire__crate__bridge__api__rust_api_retransmit_all_messages_impl(port, ptr, rust_vec_len, data_len), +66 => wire__crate__bridge__api__rust_api_send_binary_impl(port, ptr, rust_vec_len, data_len), +67 => wire__crate__bridge__api__rust_api_send_contact_profile_impl(port, ptr, rust_vec_len, data_len), +68 => wire__crate__bridge__api__rust_api_send_encrypted_content_impl(port, ptr, rust_vec_len, data_len), +69 => wire__crate__bridge__api__rust_api_send_encrypted_content_to_group_impl(port, ptr, rust_vec_len, data_len), +70 => wire__crate__bridge__api__rust_api_send_queued_message_impl(port, ptr, rust_vec_len, data_len), +71 => wire__crate__bridge__api__rust_api_send_text_message_impl(port, ptr, rust_vec_len, data_len), +72 => wire__crate__bridge__api__rust_api_send_typing_impl(port, ptr, rust_vec_len, data_len), +73 => wire__crate__bridge__api__rust_api_set_background_impl(port, ptr, rust_vec_len, data_len), +74 => wire__crate__bridge__api__rust_api_set_login_token_impl(port, ptr, rust_vec_len, data_len), +75 => wire__crate__bridge__api__rust_api_set_network_available_impl(port, ptr, rust_vec_len, data_len), +76 => wire__crate__bridge__api__rust_api_submit_recovery_share_impl(port, ptr, rust_vec_len, data_len), +77 => wire__crate__bridge__api__rust_api_update_fcm_token_impl(port, ptr, rust_vec_len, data_len), +78 => wire__crate__bridge__api__rust_api_update_signed_pre_key_impl(port, ptr, rust_vec_len, data_len), +79 => wire__crate__bridge__api__rust_api_upload_pqc_pre_keys_impl(port, ptr, rust_vec_len, data_len), +80 => wire__crate__bridge__wrapper__app_database__rust_app_database_changes_impl(port, ptr, rust_vec_len, data_len), +81 => wire__crate__bridge__wrapper__app_database__rust_app_database_execute_impl(port, ptr, rust_vec_len, data_len), +82 => wire__crate__bridge__wrapper__app_database__rust_app_database_legacy_import_complete_impl(port, ptr, rust_vec_len, data_len), +83 => wire__crate__bridge__wrapper__app_database__rust_app_database_migrate_legacy_database_impl(port, ptr, rust_vec_len, data_len), +84 => wire__crate__bridge__wrapper__app_database__rust_app_database_select_impl(port, ptr, rust_vec_len, data_len), +85 => wire__crate__bridge__wrapper__backup__rust_backup_archive_create_backup_archive_impl(port, ptr, rust_vec_len, data_len), +86 => wire__crate__bridge__wrapper__backup__rust_backup_archive_get_backup_download_token_impl(port, ptr, rust_vec_len, data_len), +87 => wire__crate__bridge__wrapper__backup__rust_backup_archive_restore_backup_archive_impl(port, ptr, rust_vec_len, data_len), +88 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_id_impl(port, ptr, rust_vec_len, data_len), +89 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), +90 => wire__crate__bridge__wrapper__backup__rust_backup_identity_get_identity_backup_bytes_impl(port, ptr, rust_vec_len, data_len), +91 => wire__crate__bridge__wrapper__backup__rust_backup_identity_import_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), +92 => wire__crate__bridge__wrapper__backup__rust_backup_identity_restore_identity_backup_impl(port, ptr, rust_vec_len, data_len), +93 => wire__crate__bridge__wrapper__backup__rust_backup_identity_set_backup_password_keys_impl(port, ptr, rust_vec_len, data_len), +94 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_decrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len), +95 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_encrypt_cloud_media_key_impl(port, ptr, rust_vec_len, data_len), +96 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_login_token_impl(port, ptr, rust_vec_len, data_len), +97 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_signal_identity_impl(port, ptr, rust_vec_len, data_len), +98 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_user_id_impl(port, ptr, rust_vec_len, data_len), +99 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_serialized_impl(port, ptr, rust_vec_len, data_len), +100 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(port, ptr, rust_vec_len, data_len), +101 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_key_manager_impl(port, ptr, rust_vec_len, data_len), +102 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_serialize_impl(port, ptr, rust_vec_len, data_len), +103 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_set_user_id_impl(port, ptr, rust_vec_len, data_len), +104 => wire__crate__bridge__wrapper__signal__rust_signal_decrypt_impl(port, ptr, rust_vec_len, data_len), +105 => wire__crate__bridge__wrapper__signal__rust_signal_encrypt_impl(port, ptr, rust_vec_len, data_len), +106 => wire__crate__bridge__wrapper__signal__rust_signal_generate_bundle_impl(port, ptr, rust_vec_len, data_len), +107 => wire__crate__bridge__wrapper__signal__rust_signal_generate_pqc_prekeys_impl(port, ptr, rust_vec_len, data_len), +108 => wire__crate__bridge__wrapper__signal__rust_signal_get_contact_public_key_impl(port, ptr, rust_vec_len, data_len), +109 => wire__crate__bridge__wrapper__signal__rust_signal_get_user_public_key_impl(port, ptr, rust_vec_len, data_len), +110 => wire__crate__bridge__wrapper__signal__rust_signal_process_prekey_bundle_impl(port, ptr, rust_vec_len, data_len), +111 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len), +112 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len), +113 => wire__crate__bridge__callbacks__log__strip_ansi_impl(port, ptr, rust_vec_len, data_len), +114 => wire__crate__bridge__groups__update_chat_deletion_time_impl(port, ptr, rust_vec_len, data_len), +115 => wire__crate__bridge__groups__update_group_name_impl(port, ptr, rust_vec_len, data_len), +117 => wire__crate__bridge__user_config__user_config_api_create_impl(port, ptr, rust_vec_len, data_len), +118 => wire__crate__bridge__user_config__user_config_api_import_json_impl(port, ptr, rust_vec_len, data_len), +119 => wire__crate__bridge__user_config__user_config_api_load_impl(port, ptr, rust_vec_len, data_len), +120 => wire__crate__bridge__user_config__user_config_api_save_impl(port, ptr, rust_vec_len, data_len), +121 => wire__crate__bridge__user_config__user_config_api_update_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -5704,7 +5856,7 @@ fn pde_ffi_dispatcher_sync_impl( // Codec=Pde (Serialization + dispatch), see doc to use other codecs match func_id { 18 => wire__crate__bridge__api__rust_api_api_base_url_impl(ptr, rust_vec_len, data_len), - 112 => wire__crate__bridge__user_config__user_config_api_clone_impl( + 116 => wire__crate__bridge__user_config__user_config_api_clone_impl( ptr, rust_vec_len, data_len, diff --git a/rust/src/services/contacts.rs b/rust/src/services/contacts.rs index f34041ed..615a5cf7 100644 --- a/rust/src/services/contacts.rs +++ b/rust/src/services/contacts.rs @@ -90,8 +90,10 @@ impl ContactService { self.process_user_prekey_bundle(&user).await?; let database = self.ctx.app_db.read().await.clone(); + // The server answered with a bundle, so a previous `UserIdNotFound` + // (or a manual mark) must not keep the contact blocked. sqlx::query!( - "UPDATE contacts SET signal_version = 'v2' WHERE user_id = ?", + "UPDATE contacts SET signal_version = 'v2', account_deleted = 0 WHERE user_id = ?", user_id ) .execute(&database.pool) diff --git a/rust/src/services/groups/mod.rs b/rust/src/services/groups/mod.rs index de25f838..b8a5ec02 100644 --- a/rust/src/services/groups/mod.rs +++ b/rust/src/services/groups/mod.rs @@ -7,7 +7,9 @@ pub(crate) mod crypto; pub(crate) mod model; use crate::api::groups::GroupApi; -use crate::api::messages::incoming::messages::queue_encrypted_content; +use crate::api::messages::incoming::messages::{ + queue_encrypted_content, retransmit_queued_receipts, +}; use crate::api::messages::outgoing::send_c2c_message_to_contact; use crate::api::proto::client::encrypted_content::GroupJoin; use crate::api::proto::client::{ @@ -28,19 +30,75 @@ use crate::utils::{current_time, new_uuid_v4}; use model::GroupRecord; use prost::Message; use rand::{RngCore, SeedableRng}; -use std::collections::BTreeSet; -use std::sync::Arc; +use std::collections::{BTreeSet, HashMap}; +use std::sync::{Arc, LazyLock, Mutex}; +use std::time::{Duration, Instant}; const DEFAULT_DELETE_MS: i64 = 86_400_000; +/// How long to wait before asking the same member for its group public key +/// again. A peer that cannot answer -- an old client, a member who never comes +/// online -- would otherwise be re-asked on every reconnect and every group +/// update. +const PUBLIC_KEY_REQUEST_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); + pub struct GroupService { ctx: Arc, } +/// Serializes group-state refreshes per group. +/// +/// A refresh reads the server state, may repair it through `update_remote`, and +/// only then writes it locally. Two refreshes running at once read the same +/// `version_id`, so the second repair is rejected by the group server with 409. +/// The single app-database connection used to serialize this by accident, back +/// when the fetch ran inside the transaction; taking the network out of the +/// transaction is what makes an explicit lock necessary. +/// +/// Acquire this *before* a database connection and never while holding one: +/// the reverse order deadlocks against the one-connection pool. +static GROUP_STATE_LOCKS: LazyLock>>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +fn group_state_lock(group_id: &str) -> Arc> { + let mut locks = match GROUP_STATE_LOCKS.lock() { + Ok(locks) => locks, + Err(poisoned) => poisoned.into_inner(), + }; + locks.entry(group_id.to_owned()).or_default().clone() +} + +/// When each member was last asked to resend its group public key. +/// +/// Process-local on purpose: the point is to keep one session from re-asking a +/// silent peer on every reconnect, not to remember the attempt across restarts. +/// `force` bypasses it, so the user can always retry by opening the group. +static PUBLIC_KEY_REQUESTS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Records a request and reports whether it should be sent at all. +fn claim_public_key_request(group_id: &str, contact_id: i64, force: bool) -> bool { + let mut requests = match PUBLIC_KEY_REQUESTS.lock() { + Ok(requests) => requests, + Err(poisoned) => poisoned.into_inner(), + }; + let key = (group_id.to_owned(), contact_id); + let now = Instant::now(); + if !force + && requests + .get(&key) + .is_some_and(|sent| now.duration_since(*sent) < PUBLIC_KEY_REQUEST_INTERVAL) + { + return false; + } + requests.insert(key, now); + true +} + impl GroupService { pub async fn on_connected(&self) -> Result<()> { self.fetch_group_states_for_unjoined_groups().await?; - self.fetch_missing_group_public_keys().await?; + self.fetch_missing_group_public_keys(None, false).await?; self.sync_flame_counters().await } @@ -101,14 +159,22 @@ impl GroupService { Self { ctx: ctx.clone() } } + /// Repairs a group after a peer reported that it does not know the group. + /// + /// Owns its transaction instead of borrowing the inbound one: the refresh + /// below reaches the group server, which must not happen while the single + /// app-database connection is held. pub(crate) async fn handle_membership_error( &self, - t: &mut sqlx::Transaction<'_, sqlx::Sqlite>, from_user_id: i64, group_id: String, related_receipt_id: String, ) -> Result<()> { - let _ = self.fetch_group_state_in_transaction(t, &group_id).await; + let _ = self.fetch_group_state(group_id.clone()).await; + + let database = self.ctx.app_db.read().await.clone(); + let mut transaction = database.pool.begin().await?; + let t = &mut transaction; let group = GroupRecord::load_in_transaction(t, &group_id).await?; let is_still_member = sqlx::query_scalar!( @@ -156,6 +222,9 @@ impl GroupService { ) .execute(&mut **t) .await?; + + transaction.commit().await?; + database.notify_committed(["receipts", "groups", "group_members"]); Ok(()) } @@ -229,24 +298,51 @@ impl GroupService { Ok(true) } + /// Refreshes one group from the group server. + /// + /// The round-trip happens before the transaction opens, under the group's + /// [`group_state_lock`]. The app database allows a single connection, so + /// holding it across network I/O starves every other database user until + /// the acquire timeout and surfaces as `pool timed out while waiting for an + /// open connection`. pub async fn fetch_group_state(&self, group_id: String) -> Result { + let lock = group_state_lock(&group_id); + let _guard = lock.lock().await; + + let server = GroupApi::fetch_group_state(&group_id).await?; + let database = self.ctx.app_db.read().await.clone(); let mut t = database.pool.begin().await?; let updated = self - .fetch_group_state_in_transaction(&mut t, &group_id) + .apply_fetched_group_state(&mut t, &group_id, server) .await?; t.commit().await?; - database.notify_committed(["groups", "group_members", "contacts"]); + database.notify_committed(["groups", "group_members", "contacts", "receipts"]); + + // `apply_state` may have queued public-key requests. Nothing else + // flushes them here -- this is not an inbound-message path -- and they + // would otherwise wait for the next reconnect. + let ctx = self.ctx.clone(); + tokio::spawn(async move { + if let Err(error) = retransmit_queued_receipts(&ctx).await { + tracing::warn!("failed to flush messages queued by a group refresh: {error}"); + } + }); + Ok(updated) } - async fn fetch_group_state_in_transaction( + /// Applies a group state that has already been fetched. Database work only, + /// apart from the admin-side `update_remote` repair below, which the + /// caller's group lock keeps free of races. + async fn apply_fetched_group_state( &self, t: &mut sqlx::Transaction<'_, sqlx::Sqlite>, group_id: &str, + server: Option, ) -> Result { let group = GroupRecord::load_in_transaction(t, group_id).await?; - let Some(server) = GroupApi::fetch_group_state(&group_id).await? else { + let Some(server) = server else { UpdateGroup::builder() .group_id(group_id.to_owned()) .left_group(true) @@ -352,6 +448,32 @@ impl GroupService { self.fetch_group_state(group_id).await } + /// Resolves the group public key a member signs its group-state appends + /// with. Our own key lives in the group record; everyone else's arrives + /// through `group_join` and may still be missing. + async fn member_public_key( + &self, + db: &Arc, + group: &GroupRecord, + group_id: &str, + contact_id: i64, + ) -> Result> { + if contact_id == self.ctx.user_id().await? { + return Ok(group.identity()?.identity_key().serialize().to_vec()); + } + GetGroupPublicKey::builder() + .group_id(group_id.to_owned()) + .contact_id(contact_id) + .build() + .fetch_pool(&db.pool) + .await? + .ok_or_else(|| { + TwonlyError::Generic(format!( + "group public key for contact {contact_id} not found" + )) + }) + } + pub async fn manage_admin( &self, group_id: String, @@ -361,17 +483,9 @@ impl GroupService { let (db, g) = self.load_group(&group_id).await?; let (v, mut s) = GroupApi::load_state(&g).await?; - let public_key = GetGroupPublicKey::builder() - .group_id(group_id.clone()) - .contact_id(contact_id) - .build() - .fetch_pool(&db.pool) - .await? - .ok_or_else(|| { - TwonlyError::Generic(format!( - "group public key for contact {contact_id} not found" - )) - })?; + let public_key = self + .member_public_key(&db, &g, &group_id, contact_id) + .await?; if remove { s.admin_ids.retain(|x| *x != contact_id) @@ -436,12 +550,13 @@ impl GroupService { self.fetch_group_state(group_id).await } - pub async fn remove_member( - &self, - group_id: String, - public_key: Vec, - contact_id: i64, - ) -> Result { + /// Removes a member from the group. + /// + /// The member's group public key is only needed to revoke an admin's + /// signing rights, so a plain member can be removed without it. That + /// matters: a key that never arrived would otherwise make the member + /// unremovable. + pub async fn remove_member(&self, group_id: String, contact_id: i64) -> Result { let (db, g) = self.load_group(&group_id).await?; let (v, mut s) = GroupApi::load_state(&g).await?; if !s.member_ids.contains(&contact_id) { @@ -450,7 +565,15 @@ impl GroupService { s.member_ids.retain(|x| *x != contact_id); let was_admin = s.admin_ids.contains(&contact_id); s.admin_ids.retain(|x| *x != contact_id); - GroupApi::update_remote(&g, v, &s, None, was_admin.then_some(public_key)).await?; + let revoked_key = if was_admin { + Some( + self.member_public_key(&db, &g, &group_id, contact_id) + .await?, + ) + } else { + None + }; + GroupApi::update_remote(&g, v, &s, None, revoked_key).await?; self.announce(&group_id, "removedMember", Some(contact_id), None, None) .await?; let mut tr = db.pool.begin().await?; @@ -472,9 +595,7 @@ impl GroupService { let user_id = self.ctx.user_id().await?; let (_, group_state) = GroupApi::load_state(&group).await?; if group_state.admin_ids.contains(&user_id) { - let identity = group.identity()?; - let public_key = identity.identity_key().serialize().to_vec(); - return self.remove_member(group_id, public_key, user_id).await; + return self.remove_member(group_id, user_id).await; } let identity = group.identity()?; @@ -546,20 +667,61 @@ impl GroupService { Ok(true) } - pub async fn refresh_group_state( - &self, - tr: &mut sqlx::Transaction<'_, sqlx::Sqlite>, - group_id: String, - created: bool, - ) { - if created { - let _ = self - .fetch_group_states_for_unjoined_groups_in_transaction(tr) - .await; - let _ = self.broadcast_group_public_key(tr, &group_id).await; - } else { - let _ = self.fetch_group_state_in_transaction(tr, &group_id).await; - } + /// Refreshes one group and then announces our own group public key to it. + /// + /// The order matters: `broadcast_group_public_key` resolves its recipients + /// from `group_members`, which holds nothing but the sender of the + /// `group_create` until the refresh fills in the rest of the group. + /// Announcing first would reach that one member and leave every other + /// member unable to promote or remove us. The announcement still runs when + /// the refresh fails, so an offline group server costs reach, not delivery. + pub fn spawn_state_refresh_and_announce(ctx: &Arc, group_id: String) { + let ctx = ctx.clone(); + tokio::spawn(async move { + let service = Self::new(&ctx); + if let Err(error) = service.fetch_group_state(group_id.clone()).await { + tracing::warn!( + group_id, + "group state refresh before announce failed: {error}" + ); + } + if let Err(error) = service.announce_group_public_key(&group_id).await { + tracing::warn!(group_id, "group public key announcement failed: {error}"); + } + }); + } + + /// Sends our group public key to every current member of `group_id`. + async fn announce_group_public_key(&self, group_id: &str) -> Result<()> { + let database = self.ctx.app_db.read().await.clone(); + let mut transaction = database.pool.begin().await?; + self.broadcast_group_public_key(&mut transaction, group_id) + .await?; + transaction.commit().await?; + database.notify_committed(["receipts"]); + Ok(()) + } + + /// Schedules the server-side half of a group refresh. + /// + /// Callers reach this from inside the inbound transaction, and a refresh + /// talks to the group server, so it cannot run there: it would pin the only + /// app-database connection across the network and, once it takes a group + /// lock, invert the lock order this module depends on. Running it detached + /// lets the caller commit first; every call site treats the refresh as best + /// effort already. + pub fn spawn_state_refresh(ctx: &Arc, group_id: Option) { + let ctx = ctx.clone(); + tokio::spawn(async move { + let service = Self::new(&ctx); + let result = match group_id { + Some(group_id) => service.fetch_group_state(group_id).await.map(|_| ()), + None => service.fetch_group_states_for_unjoined_groups().await, + }; + if let Err(error) = result { + tracing::warn!("scheduled group state refresh failed: {error}"); + } + }); } pub async fn broadcast_group_public_key( @@ -602,38 +764,47 @@ impl GroupService { Ok(()) } + /// Refreshes every group this client has not joined yet. + /// + /// The ids are read on their own connection and each group is then + /// refreshed by [`Self::fetch_group_state`], so no connection is held + /// across the per-group round-trips. pub async fn fetch_group_states_for_unjoined_groups(&self) -> Result<()> { let db = self.ctx.app_db.read().await.clone(); - let mut t = db.pool.begin().await?; - self.fetch_group_states_for_unjoined_groups_in_transaction(&mut t) - .await?; - t.commit().await?; - Ok(()) - } - - pub async fn fetch_group_states_for_unjoined_groups_in_transaction( - &self, - t: &mut sqlx::Transaction<'_, sqlx::Sqlite>, - ) -> Result<()> { let ids = GetUnjoinedGroups::builder() .build() - .fetch_all(&mut **t) + .fetch_all(&db.pool) .await?; for id in ids { - if let Err(e) = self.fetch_group_state_in_transaction(t, &id).await { + if let Err(e) = self.fetch_group_state(id.clone()).await { tracing::warn!(group_id = id, "group state refresh failed: {e}") } } Ok(()) } - pub async fn fetch_missing_group_public_keys(&self) -> Result<()> { + /// Asks every member whose group public key is still unknown to resend it. + /// + /// Without the key an admin cannot promote or remove that member, because + /// the group server needs it to authorize the member's future signed + /// appends. Pass a `group_id` to limit the sweep to one group, and `force` + /// to ignore [`PUBLIC_KEY_REQUEST_INTERVAL`] -- the group view does, so the + /// user always has a way to retry by hand. + pub async fn fetch_missing_group_public_keys( + &self, + group_id: Option, + force: bool, + ) -> Result<()> { let db = self.ctx.app_db.read().await.clone(); let rows = GetMissingGroupPublicKeys::builder() + .maybe_group_id(group_id) .build() .fetch_all(&db.pool) .await?; for row in rows { + if !claim_public_key_request(&row.group_id, row.contact_id, force) { + continue; + } send_c2c_message_to_contact() .ctx(&self.ctx) .contact_id(row.contact_id) @@ -651,6 +822,39 @@ impl GroupService { Ok(()) } + /// Same request, queued inside the caller's transaction. + /// + /// [`Self::fetch_missing_group_public_keys`] sends immediately, which needs + /// its own database access and so cannot run while a transaction holds the + /// single app-database connection. + async fn queue_missing_group_public_key_requests( + t: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + group_id: &str, + ) -> Result<()> { + let rows = GetMissingGroupPublicKeys::builder() + .group_id(group_id.to_owned()) + .build() + .fetch_all_in_transaction(t) + .await?; + for row in rows { + if !claim_public_key_request(&row.group_id, row.contact_id, false) { + continue; + } + queue_encrypted_content( + t, + row.contact_id, + EncryptedContent { + group_id: Some(row.group_id), + resend_group_public_key: Some(encrypted_content::ResendGroupPublicKey {}), + ..Default::default() + }, + true, + ) + .await?; + } + Ok(()) + } + // --- Private helpers --- async fn load_group( @@ -783,6 +987,14 @@ impl GroupService { .execute(t) .await?; } + + // The server state carries member ids but no per-member keys, and a + // member announces its own key only once, when it first learns the + // group exists -- so nobody announces themselves to a member who joined + // later. Asking here is what closes that gap for both sides: whoever + // refreshes first discovers the other with an empty key and requests it. + Self::queue_missing_group_public_key_requests(t, group_id).await?; + Ok(()) } } diff --git a/rust/src/services/notifications.rs b/rust/src/services/notifications.rs index ff665902..19a5d318 100644 --- a/rust/src/services/notifications.rs +++ b/rust/src/services/notifications.rs @@ -7,6 +7,7 @@ use crate::api::proto::client::{self as proto, encrypted_content}; use crate::api::runtime::ApiRuntime; use crate::bridge::InitConfig; use crate::context::{Context, RuntimeMode}; +use crate::database::app::AppDatabase; use crate::error::Result; use crate::user_config::UserConfig; use crate::utils::{current_time, milliseconds_to_seconds}; @@ -273,11 +274,10 @@ pub(crate) async fn record_incoming_event( Ok(()) } -pub async fn pending_batch(ctx: &Arc, locale: &str) -> Result { - let database = ctx.app_db.read().await.clone(); - // A foreground chat can mark a message as opened before the native push - // worker gets around to rendering its durable outbox row. Clear those - // stale rows first so they neither produce an alert nor inflate the badge. +/// A foreground chat can mark a message as opened before the native push +/// worker gets around to rendering its durable outbox row. Clearing those +/// stale rows keeps them from producing an alert or inflating the badge. +async fn clear_stale_opened(database: &Arc) -> Result<()> { let cleared_at = current_time().timestamp(); let cleared = sqlx::query( r#" @@ -299,6 +299,26 @@ pub async fn pending_batch(ctx: &Arc, locale: &str) -> Result) -> Result { + let database = ctx.app_db.read().await.clone(); + clear_stale_opened(&database).await?; + let count = sqlx::query_scalar!( + r#"SELECT COUNT(*) AS "count: i64" FROM notification_outbox WHERE cleared_at IS NULL"# + ) + .fetch_one(&database.pool) + .await?; + Ok(count) +} + +pub async fn pending_batch(ctx: &Arc, locale: &str) -> Result { + let database = ctx.app_db.read().await.clone(); + clear_stale_opened(&database).await?; let rows = sqlx::query_as!( PendingRow, r#" @@ -487,6 +507,36 @@ pub async fn clear_conversation(ctx: &Arc, conversation_id: &str) -> Re Ok(notification_ids) } +/// Clears the contact-request notifications. They carry no conversation, so +/// opening the request list is the only moment the user acknowledges them. +pub async fn clear_contact_requests(ctx: &Arc) -> Result> { + let database = ctx.app_db.read().await.clone(); + let mut transaction = database.pool.begin().await?; + let notification_ids = sqlx::query_scalar!( + r#" + SELECT notification_id FROM notification_outbox + WHERE kind IN ('contact_request', 'accept_request') AND cleared_at IS NULL + "#, + ) + .fetch_all(&mut *transaction) + .await?; + let cleared_at = current_time().timestamp(); + sqlx::query!( + r#" + UPDATE notification_outbox SET cleared_at = ? + WHERE kind IN ('contact_request', 'accept_request') AND cleared_at IS NULL + "#, + cleared_at, + ) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + if !notification_ids.is_empty() { + database.notify_committed(["notification_outbox"]); + } + Ok(notification_ids) +} + /// Clears only notifications representing the messages that were actually /// opened. Events that merely refer to the same message, such as reactions or /// media-status updates, remain independent notifications. diff --git a/rust/tests/api.rs b/rust/tests/api.rs index 251c2627..835d8318 100644 --- a/rust/tests/api.rs +++ b/rust/tests/api.rs @@ -389,22 +389,22 @@ async fn test_connect_to_dev_server() -> anyhow::Result<()> { tracing::info!("Group chat deletion timer updated to 1 hour"); // 6. Promote tester_b to admin - // tester_a needs tester_b's public key to promote them. We simulate a message from tester_b - // so that tester_a can request the missing public key. + // tester_a needs tester_b's public key to promote them. tester_b + // announces it when it learns of the group, so this asserts the + // announcement arrived rather than repairing the state by hand. { let db_a = tester_a.context.app_db.read().await.clone(); - sqlx::query!( - "UPDATE group_members SET last_message = CAST(strftime('%s','now') AS INTEGER) WHERE group_id = ? AND contact_id = ?", + let public_key = sqlx::query_scalar!( + "SELECT group_public_key FROM group_members WHERE group_id = ? AND contact_id = ?", group_id, tester_b.user_id ) - .execute(&db_a.pool) + .fetch_one(&db_a.pool) .await?; - group_service_a.fetch_missing_group_public_keys().await?; - - // Wait for tester_b to respond with the group join containing the public key - // (We just wait a moment to let the messages exchange) - tokio::time::sleep(std::time::Duration::from_millis(500)).await; + assert!( + public_key.is_some(), + "tester_a should have tester_b's group public key without asking for it" + ); } group_service_a @@ -453,7 +453,7 @@ async fn test_connect_to_dev_server() -> anyhow::Result<()> { // Note: tester_c is not an admin, so their public key is not needed to remove them. // We pass an empty vec![] instead of waiting for a key exchange. group_service_a - .remove_member(group_id.clone(), vec![], tester_c.user_id) + .remove_member(group_id.clone(), tester_c.user_id) .await?; // tester_b should see tester_c removed diff --git a/rust/tests/api/group_resilience.rs b/rust/tests/api/group_resilience.rs index d42de826..6ee20f3b 100644 --- a/rust/tests/api/group_resilience.rs +++ b/rust/tests/api/group_resilience.rs @@ -173,18 +173,11 @@ async fn test_admin_and_non_admin_leave_group() -> anyhow::Result<()> { .wait_for_group_exists(&group_id, group_name) .await?; - // Fetch missing public key for tester_b + // Ask for anything the announcements did not deliver. Forced, so the + // per-member request interval cannot skip it. { - let db_a = tester_a.context.app_db.read().await.clone(); - sqlx::query!( - "UPDATE group_members SET last_message = CAST(strftime('%s','now') AS INTEGER) WHERE group_id = ? AND contact_id = ?", - group_id, - tester_b.user_id - ) - .execute(&db_a.pool) - .await?; GroupService::new(&tester_a.context) - .fetch_missing_group_public_keys() + .fetch_missing_group_public_keys(Some(group_id.clone()), true) .await?; tokio::time::sleep(std::time::Duration::from_millis(500)).await; } diff --git a/rust/tests/api/notifications.rs b/rust/tests/api/notifications.rs index 7d6896bb..87ed0736 100644 --- a/rust/tests/api/notifications.rs +++ b/rust/tests/api/notifications.rs @@ -440,6 +440,34 @@ async fn test_notification_outbox_end_to_end() -> anyhow::Result<()> { .is_empty()); } + // + // The badge the running app pushes into iOS is the same number the native + // extension renders, and contact requests are cleared by looking at the + // request list rather than by opening a conversation. + // + { + let before = tester_b.notification_batch("en").await?; + assert_eq!( + tester_b.notification_badge_count().await?, + before.badge_count + ); + + let removed = tester_b.clear_contact_request_notifications().await?; + assert!( + !removed.is_empty(), + "the pending contact request must be reported for withdrawal" + ); + assert_eq!( + tester_b.notification_badge_count().await?, + before.badge_count - removed.len() as i64, + "acknowledging contact requests must reduce the badge" + ); + assert!(tester_b + .clear_contact_request_notifications() + .await? + .is_empty()); + } + // // A blocked contact is still decrypted and committed, but must never // produce a notification. diff --git a/rust/tests/api/tester.rs b/rust/tests/api/tester.rs index 4627009a..af56d468 100644 --- a/rust/tests/api/tester.rs +++ b/rust/tests/api/tester.rs @@ -588,6 +588,15 @@ impl Tester { Ok(notifications::clear_conversation(&self.context, conversation_id).await?) } + pub async fn clear_contact_request_notifications(&self) -> anyhow::Result> { + Ok(notifications::clear_contact_requests(&self.context).await?) + } + + /// The value the running app pushes into the iOS app icon badge. + pub async fn notification_badge_count(&self) -> anyhow::Result { + Ok(notifications::badge_count(&self.context).await?) + } + /// Number of rows the outbox holds for one receipt, used to prove that a /// redelivered envelope cannot notify twice. pub async fn notification_rows_for_event(&self, event_id: &str) -> anyhow::Result { diff --git a/scripts/run_emulators.sh b/scripts/run_emulators.sh new file mode 100755 index 00000000..6084e116 --- /dev/null +++ b/scripts/run_emulators.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# +# Start `flutter run` for every currently running iOS/Android emulator, +# each in its own zellij tab of a fresh session, so the logs stay separate. +# +# Usage: scripts/run_emulators.sh [-s SESSION] [-w SECONDS] [-f] [-- ] +# +# -s SESSION zellij session name (default: twonly-run) +# -w SECONDS stagger between device starts, avoids build-dir races (default: 15) +# -f kill an existing session with the same name instead of aborting +# +# Everything after `--` is appended to each `flutter run` invocation, +# e.g. `scripts/run_emulators.sh -- --flavor dev --dart-define=FOO=bar` + +set -euo pipefail + +SESSION="twonly-run" +STAGGER=15 +FORCE=0 + +while getopts ":s:w:fh" opt; do + case "$opt" in + s) SESSION="$OPTARG" ;; + w) STAGGER="$OPTARG" ;; + f) FORCE=1 ;; + h) sed -n '2,15p' "$0"; exit 0 ;; + \?) echo "unknown option: -$OPTARG" >&2; exit 2 ;; + :) echo "option -$OPTARG needs an argument" >&2; exit 2 ;; + esac +done +shift $((OPTIND - 1)) + +case "$STAGGER" in + ''|*[!0-9]*) echo "-w needs a non-negative whole number of seconds" >&2; exit 2 ;; +esac + +for bin in zellij flutter jq; do + command -v "$bin" >/dev/null || { echo "missing required tool: $bin" >&2; exit 1; } +done + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ZELLIJ_CONFIG="${ZELLIJ_CONFIG_FILE:-${XDG_CONFIG_HOME:-$HOME/.config}/zellij/config.kdl}" + +if zellij list-sessions -s 2>/dev/null | grep -qx "$SESSION"; then + if [ "$FORCE" -eq 1 ]; then + echo "deleting existing session '$SESSION'..." + zellij delete-session --force "$SESSION" >/dev/null + else + echo "zellij session '$SESSION' already exists. Attach with:" >&2 + echo " zellij attach $SESSION" >&2 + echo "or re-run with -f to replace it." >&2 + exit 1 + fi +fi + +echo "querying flutter devices..." +DEVICES_JSON="$(flutter devices --machine)" + +# Only running emulators/simulators on the ios or android platforms. +# bash 3.2 (macOS default) has no mapfile, so read the lines manually. +DEVICES=() +while IFS= read -r line; do + [ -n "$line" ] && DEVICES[${#DEVICES[@]}]="$line" +done < <( + printf '%s' "$DEVICES_JSON" | jq -r ' + .[] + | select(.emulator == true and .isSupported == true) + | select(.targetPlatform | test("^(ios|android)")) + | "\(.id)\t\(.name)\t\(.targetPlatform)" + ' +) + +if [ "${#DEVICES[@]}" -eq 0 ]; then + echo "no running iOS or Android emulators found." >&2 + echo "start one first, e.g. 'flutter emulators --launch ' or open Simulator.app." >&2 + exit 1 +fi + +RUNDIR="$(mktemp -d "${TMPDIR:-/tmp}/twonly-run-XXXXXX")" +LAYOUT="$RUNDIR/layout.kdl" + +# A custom layout replaces zellij's default UI, so re-declare the default tab +# template -- without it the tab bar and status bar are gone and the tabs, +# although they exist, are invisible and unswitchable-looking. +if grep -qE '^[[:space:]]*default_layout[[:space:]]+"compact"' "$ZELLIJ_CONFIG" 2>/dev/null; then + TAB_TEMPLATE=' default_tab_template { + children + pane size=1 borderless=true { + plugin location="zellij:compact-bar" + } + }' +else + TAB_TEMPLATE=' default_tab_template { + pane size=1 borderless=true { + plugin location="zellij:tab-bar" + } + children + pane size=2 borderless=true { + plugin location="zellij:status-bar" + } + }' +fi + +{ + echo 'layout {' + echo "$TAB_TEMPLATE" +} > "$LAYOUT" + +index=0 +USED_NAMES=() +for entry in "${DEVICES[@]}"; do + id="${entry%% *}" + rest="${entry#* }" + name="${rest%% *}" + platform="${rest#* }" + + # Two clones of the same emulator image share a name, so identify Android + # tabs by their (unique) adb id and fall back to a counter elsewhere. + case "$platform" in + ios*) label="ios-$name" ;; + *) label="android-$id" ;; + esac + # tab names must survive KDL quoting; keep them boring + tab_name="$(printf '%s' "$label" | tr -c 'A-Za-z0-9._-' '-' | cut -c1-30)" + suffix=2 + while printf '%s\n' ${USED_NAMES+"${USED_NAMES[@]}"} | grep -qx "$tab_name"; do + tab_name="$(printf '%s' "$label" | tr -c 'A-Za-z0-9._-' '-' | cut -c1-27)-$suffix" + suffix=$((suffix + 1)) + done + USED_NAMES[${#USED_NAMES[@]}]="$tab_name" + + wrapper="$RUNDIR/run-$index.sh" + { + echo '#!/usr/bin/env bash' + printf 'cd %q\n' "$PROJECT_ROOT" + if [ "$index" -gt 0 ] && [ "$STAGGER" -gt 0 ]; then + delay=$((index * STAGGER)) + printf 'echo "waiting %ss so the builds do not fight over the build directory..."\n' "$delay" + printf 'sleep %s\n' "$delay" + fi + printf 'echo "=== %s (%s) ==="\n' "$name" "$id" + printf 'exec flutter run -d %q' "$id" + for arg in "$@"; do printf ' %q' "$arg"; done + printf '\n' + } > "$wrapper" + chmod +x "$wrapper" + + { + printf ' tab name="%s" {\n' "$tab_name" + printf ' pane command="bash" {\n' + printf ' args "%s"\n' "$wrapper" + printf ' }\n' + printf ' }\n' + } >> "$LAYOUT" + + echo " tab '$tab_name' -> $id" + index=$((index + 1)) +done + +echo '}' >> "$LAYOUT" + +echo "starting zellij session '$SESSION' with $index tab(s)..." +exec zellij --session "$SESSION" --new-session-with-layout "$LAYOUT"