mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-09-01 08:04:07 +00:00
fixing multiple issues
This commit is contained in:
parent
d80494c206
commit
d89952c295
37 changed files with 2211 additions and 429 deletions
663
android/build/reports/problems/problems-report.html
Normal file
663
android/build/reports/problems/problems-report.html
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<App> 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;
|
||||
|
|
|
|||
|
|
@ -293,6 +293,22 @@ class RustApi {
|
|||
alreadyReceivedMessageIds: alreadyReceivedMessageIds,
|
||||
);
|
||||
|
||||
/// Acknowledges the pending contact-request notifications and returns the
|
||||
/// native notification IDs to withdraw.
|
||||
static Future<List<String>> 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<List<String>> clearConversationNotifications({
|
||||
required String conversationId,
|
||||
}) =>
|
||||
RustLib.instance.api.crateBridgeApiRustApiClearConversationNotifications(
|
||||
conversationId: conversationId,
|
||||
);
|
||||
|
||||
static Future<void> close() =>
|
||||
RustLib.instance.api.crateBridgeApiRustApiClose();
|
||||
|
||||
|
|
@ -432,6 +448,12 @@ class RustApi {
|
|||
static Future<FrbPlanBalance> 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<PlatformInt64> notificationBadgeCount() =>
|
||||
RustLib.instance.api.crateBridgeApiRustApiNotificationBadgeCount();
|
||||
|
||||
static Future<void> notifyMessagesOpened({
|
||||
required PlatformInt64 contactId,
|
||||
required List<String> messageIds,
|
||||
|
|
|
|||
14
lib/core/bridge/callbacks/log.dart
Normal file
14
lib/core/bridge/callbacks/log.dart
Normal file
|
|
@ -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<String> stripAnsi({required String input}) =>
|
||||
RustLib.instance.api.crateBridgeCallbacksLogStripAnsi(input: input);
|
||||
|
|
@ -25,17 +25,20 @@ Future<bool> addHiddenContact({required PlatformInt64 contactId}) => RustLib
|
|||
Future<void> fetchGroupStatesForUnjoinedGroups() =>
|
||||
RustLib.instance.api.crateBridgeGroupsFetchGroupStatesForUnjoinedGroups();
|
||||
|
||||
Future<void> fetchMissingGroupPublicKeys() =>
|
||||
RustLib.instance.api.crateBridgeGroupsFetchMissingGroupPublicKeys();
|
||||
Future<void> fetchMissingGroupPublicKeys({
|
||||
String? groupId,
|
||||
required bool force,
|
||||
}) => RustLib.instance.api.crateBridgeGroupsFetchMissingGroupPublicKeys(
|
||||
groupId: groupId,
|
||||
force: force,
|
||||
);
|
||||
|
||||
Future<bool> manageAdminState({
|
||||
required String groupId,
|
||||
required List<int> groupPublicKey,
|
||||
required PlatformInt64 contactId,
|
||||
required bool remove,
|
||||
}) => RustLib.instance.api.crateBridgeGroupsManageAdminState(
|
||||
groupId: groupId,
|
||||
groupPublicKey: groupPublicKey,
|
||||
contactId: contactId,
|
||||
remove: remove,
|
||||
);
|
||||
|
|
@ -66,11 +69,9 @@ Future<bool> addNewGroupMembers({
|
|||
|
||||
Future<bool> removeMemberFromGroup({
|
||||
required String groupId,
|
||||
required List<int> groupPublicKey,
|
||||
required PlatformInt64 contactId,
|
||||
}) => RustLib.instance.api.crateBridgeGroupsRemoveMemberFromGroup(
|
||||
groupId: groupId,
|
||||
groupPublicKey: groupPublicKey,
|
||||
contactId: contactId,
|
||||
);
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<String?> _taps =
|
||||
StreamController<String?>.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<List<String>>? _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<String?> 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<void> refreshBadgeCount() async {
|
||||
if (!Platform.isIOS) return;
|
||||
try {
|
||||
final count = await RustApi.notificationBadgeCount();
|
||||
await _channel.invokeMethod<void>('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<void> 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<void> 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 {
|
||||
|
|
|
|||
|
|
@ -118,9 +118,23 @@ class _ChatMessagesViewState extends State<ChatMessagesView>
|
|||
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();
|
||||
|
|
|
|||
|
|
@ -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<AddNewUserView> {
|
|||
@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) {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,16 @@ class _GroupViewState extends State<GroupView> {
|
|||
}
|
||||
|
||||
Future<void> 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<GroupView> {
|
|||
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,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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<Context>,
|
||||
database: &Arc<crate::database::app::AppDatabase>,
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 => {}
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -298,7 +298,6 @@ pub(crate) struct PreparedQueuedReceipt {
|
|||
pub contact_id: i64,
|
||||
pub message_id: Option<String>,
|
||||
pub contact_will_sends_receipt: i64,
|
||||
pub account_deleted: i64,
|
||||
pub payload: Vec<u8>,
|
||||
pub wake_receiver: bool,
|
||||
}
|
||||
|
|
@ -313,12 +312,11 @@ struct PreparedQueuedReceiptRow {
|
|||
signal_version: String,
|
||||
}
|
||||
|
||||
pub(crate) async fn prepare_queued_receipt_details(
|
||||
ctx: &Arc<Context>,
|
||||
async fn load_queued_receipt_row(
|
||||
pool: &sqlx::SqlitePool,
|
||||
receipt_id: &str,
|
||||
) -> Result<Option<PreparedQueuedReceipt>> {
|
||||
let app_db = ctx.app_db.read().await.clone();
|
||||
let row = sqlx::query_as!(
|
||||
) -> Result<Option<PreparedQueuedReceiptRow>> {
|
||||
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<Context>,
|
||||
receipt_id: &str,
|
||||
) -> Result<Option<PreparedQueuedReceipt>> {
|
||||
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<Context>,
|
||||
receipt_id: &str,
|
||||
row: PreparedQueuedReceiptRow,
|
||||
) -> Result<PreparedQueuedReceipt> {
|
||||
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<Context>, 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,
|
||||
|
|
|
|||
|
|
@ -15,11 +15,14 @@ use std::sync::Arc;
|
|||
|
||||
impl Server {
|
||||
pub async fn get_user_by_id_response(ctx: &Arc<Context>, user_id: i64) -> Result<Vec<u8>> {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<i64> {
|
||||
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<Vec<String>> {
|
||||
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<Vec<String>> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<String>, 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<u8>,
|
||||
contact_id: i64,
|
||||
remove: bool,
|
||||
) -> Result<bool> {
|
||||
pub async fn manage_admin_state(group_id: String, contact_id: i64, remove: bool) -> Result<bool> {
|
||||
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<i64>) -> Re
|
|||
.await
|
||||
}
|
||||
|
||||
pub async fn remove_member_from_group(
|
||||
group_id: String,
|
||||
group_public_key: Vec<u8>,
|
||||
contact_id: i64,
|
||||
) -> Result<bool> {
|
||||
pub async fn remove_member_from_group(group_id: String, contact_id: i64) -> Result<bool> {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
impl GetMissingGroupPublicKeys {
|
||||
pub async fn fetch_all(
|
||||
self,
|
||||
pool: &sqlx::Pool<Sqlite>,
|
||||
) -> Result<Vec<MissingGroupPublicKeyRow>> {
|
||||
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<Vec<MissingGroupPublicKeyRow>> {
|
||||
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<i64> {
|
||||
|
|
|
|||
|
|
@ -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 = <Option<String>>::sse_decode(&mut deserializer);
|
||||
let api_force = <bool>::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 = <String>::sse_decode(&mut deserializer);
|
||||
let api__group_public_key = <Vec<u8>>::sse_decode(&mut deserializer);
|
||||
let api_contact_id = <i64>::sse_decode(&mut deserializer);
|
||||
let api_remove = <bool>::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 = <String>::sse_decode(&mut deserializer);
|
||||
let api_group_public_key = <Vec<u8>>::sse_decode(&mut deserializer);
|
||||
let api_contact_id = <i64>::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::SseCodec, _, _, _>(
|
||||
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::SseCodec, _, _, _>(
|
||||
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 = <String>::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::SseCodec, _, _, _>(
|
||||
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::SseCodec, _, _>(
|
||||
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 = <String>::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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<Context>,
|
||||
}
|
||||
|
||||
/// 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<Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
fn group_state_lock(group_id: &str) -> Arc<tokio::sync::Mutex<()>> {
|
||||
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<Mutex<HashMap<(String, i64), Instant>>> =
|
||||
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<bool> {
|
||||
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<crate::api::proto::http_requests::GroupState>,
|
||||
) -> Result<bool> {
|
||||
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<crate::database::app::AppDatabase>,
|
||||
group: &GroupRecord,
|
||||
group_id: &str,
|
||||
contact_id: i64,
|
||||
) -> Result<Vec<u8>> {
|
||||
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<u8>,
|
||||
contact_id: i64,
|
||||
) -> Result<bool> {
|
||||
/// 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<bool> {
|
||||
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<Context>, 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<Context>, group_id: Option<String>) {
|
||||
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<String>,
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Context>, locale: &str) -> Result<NotificationBatch> {
|
||||
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<AppDatabase>) -> Result<()> {
|
||||
let cleared_at = current_time().timestamp();
|
||||
let cleared = sqlx::query(
|
||||
r#"
|
||||
|
|
@ -299,6 +299,26 @@ pub async fn pending_batch(ctx: &Arc<Context>, locale: &str) -> Result<Notificat
|
|||
if cleared.rows_affected() != 0 {
|
||||
database.notify_committed(["notification_outbox"]);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The number of events the user has not dealt with yet. iOS has no way to
|
||||
/// derive an app icon badge from the delivered alerts, so the running app has
|
||||
/// to push this value into `UNUserNotificationCenter` itself.
|
||||
pub async fn badge_count(ctx: &Arc<Context>) -> Result<i64> {
|
||||
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<Context>, locale: &str) -> Result<NotificationBatch> {
|
||||
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<Context>, 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<Context>) -> Result<Vec<String>> {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<Vec<String>> {
|
||||
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<i64> {
|
||||
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<i64> {
|
||||
|
|
|
|||
163
scripts/run_emulators.sh
Executable file
163
scripts/run_emulators.sh
Executable file
|
|
@ -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] [-- <extra flutter run args>]
|
||||
#
|
||||
# -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 <id>' 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"
|
||||
Loading…
Reference in a new issue