From ce8eaf1e08bad2e5579514b81e015c1f10d380b1 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:46:24 -0700 Subject: [PATCH 01/33] Add iPhone relay health monitoring --- .../settings/pages/profile/profile_panel.dart | 78 +++++++++++ .../notifications/notifications_service.dart | 58 ++++++++ lib/services/rustpush/rustpush_service.dart | 125 ++++++++++++++++++ 3 files changed, 261 insertions(+) diff --git a/lib/app/layouts/settings/pages/profile/profile_panel.dart b/lib/app/layouts/settings/pages/profile/profile_panel.dart index 1b54ce0e9c..e8336f1626 100644 --- a/lib/app/layouts/settings/pages/profile/profile_panel.dart +++ b/lib/app/layouts/settings/pages/profile/profile_panel.dart @@ -63,6 +63,29 @@ class _ProfilePanelState extends OptimizedState with WidgetsBindin Rxn quotaInfo = Rxn(null); Rxn googleCreds = Rxn(null); + String relayHealthSubtitle() { + if (pushService.relayHealthChecking.value) { + return "Testing the iPhone relay..."; + } + + final checked = pushService.relayLastChecked.value; + final lastSuccess = pushService.relayLastSuccess.value; + final checkedText = + checked == null ? null : buildChatListDateMaterial(checked); + final successText = + lastSuccess == null ? null : buildChatListDateMaterial(lastSuccess); + + if (pushService.relayReachable.value == true) { + return "Reachable${checkedText == null ? "" : " as of $checkedText"}. Tap to test again."; + } + if (pushService.relayReachable.value == false) { + final lastSuccessSuffix = + successText == null ? "" : " Last successful check: $successText."; + return "Unavailable${checkedText == null ? "" : " as of $checkedText"}.$lastSuccessSuffix Turn on the relay and tap to retry."; + } + return "Not checked yet. Tap to verify the iPhone is online before registration renewal."; + } + Future handleSubscriptionToken(String subscription) async { var activated = await http.dio.post("https://hw.openbubbles.app/ticket/${ticket!}/activate", data: {"purchase_token": subscription}); var useTicket = activated.data["ticket"]; @@ -664,6 +687,61 @@ class _ProfilePanelState extends OptimizedState with WidgetsBindin ), )); }), + if ((accountInfo["can_pnr"] ?? false) && + !ss.settings.deviceIsHosted.value) + Obx(() { + final reachable = + pushService.relayReachable.value; + final checking = + pushService.relayHealthChecking.value; + final color = checking + ? context.theme.colorScheme.outline + : reachable == true + ? getIndicatorColor( + SocketState.connected) + : reachable == false + ? getIndicatorColor( + SocketState.disconnected) + : context.theme.colorScheme.outline; + + return SettingsTile( + title: "iPhone Relay", + subtitle: relayHealthSubtitle(), + isThreeLine: true, + leading: + Icon(Icons.phone_iphone, color: color), + trailing: checking + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: + AlwaysStoppedAnimation( + color), + ), + ) + : Icon( + reachable == true + ? Icons.check_circle + : reachable == false + ? Icons.error + : Icons.help_outline, + color: color, + ), + onTap: () async { + final result = + await pushService.checkRelayHealth(); + if (result == true) { + showSnackbar("iPhone Relay", + "The relay is online and responding."); + } else if (result == false) { + showSnackbar("iPhone Relay", + "The relay could not be reached. Check its power, Wi-Fi, and ValidationRelay status."); + } + }, + ); + }), if (accountInfo['login_status_message']?.startsWith("Deregistered") ?? false) Container( color: tileColor, diff --git a/lib/services/backend/notifications/notifications_service.dart b/lib/services/backend/notifications/notifications_service.dart index b042d45c9e..5355da2b4f 100644 --- a/lib/services/backend/notifications/notifications_service.dart +++ b/lib/services/backend/notifications/notifications_service.dart @@ -55,6 +55,7 @@ class NotificationsService extends GetxService { /// For desktop use only static LocalNotification? allToast; static LocalNotification? failedToast; + static LocalNotification? relayToast; static LocalNotification? socketToast; static LocalNotification? aliasesToast; static Map> notifications = {}; @@ -1070,6 +1071,63 @@ class NotificationsService extends GetxService { ); } + Future clearRelayUnavailable() async { + if (kIsWeb) { + return; + } + if (kIsDesktop) { + await relayToast?.close(); + relayToast = null; + return; + } + await flnp.cancel(-6 - 50); + } + + Future createRelayUnavailable() async { + if (kIsWeb) { + return; + } + const title = "iPhone relay unavailable"; + const subtitle = + "OpenBubbles may lose phone number registration. Turn on the relay, then tap Test Relay."; + if (kIsDesktop) { + relayToast = LocalNotification( + title: title, + body: subtitle, + actions: [], + ); + + relayToast!.onClick = () async { + relayToast = null; + await windowManager.show(); + if (ss.settings.finishedSetup.value) { + ns.pushLeft(Get.context!, ProfilePanel()); + } + }; + + await relayToast!.show(); + return; + } + + await flnp.show( + -6 - 50, + title, + subtitle, + NotificationDetails( + android: AndroidNotificationDetails( + ERROR_CHANNEL, + "Errors", + channelDescription: + "Displays message send failures, connection failures, and more", + priority: Priority.max, + importance: Importance.max, + color: HexColor("4990de"), + ), + ), + payload: "-51", + ); + } + Future createSubscriptionFailed() async { const title = "Your subscription is no longer active!"; const subtitle = diff --git a/lib/services/rustpush/rustpush_service.dart b/lib/services/rustpush/rustpush_service.dart index 6deb3c1e42..1d5f4e8342 100644 --- a/lib/services/rustpush/rustpush_service.dart +++ b/lib/services/rustpush/rustpush_service.dart @@ -1313,6 +1313,107 @@ class RustPushService extends GetxService { var disableOutgoingSms = false; + final RxBool relayHealthChecking = false.obs; + final RxnBool relayReachable = RxnBool(); + final Rxn relayLastChecked = Rxn(); + final Rxn relayLastSuccess = Rxn(); + Timer? relayHealthTimer; + + void restoreRelayHealthState() { + final lastChecked = ss.prefs.getInt("relay-health-last-checked"); + final lastSuccess = ss.prefs.getInt("relay-health-last-success"); + relayReachable.value = ss.prefs.getBool("relay-health-reachable"); + relayLastChecked.value = lastChecked == null + ? null + : DateTime.fromMillisecondsSinceEpoch(lastChecked); + relayLastSuccess.value = lastSuccess == null + ? null + : DateTime.fromMillisecondsSinceEpoch(lastSuccess); + } + + Future usesUserManagedIPhoneRelay() async { + if (state == null || ss.settings.deviceIsHosted.value) { + return false; + } + + final device = await api.getDeviceInfo(config: state!.osConfig); + return device.name.contains("iPhone") || + device.name.contains("iPod") || + device.name.contains("iPad"); + } + + Future checkRelayHealth({bool notifyOnFailure = false}) async { + if (relayHealthChecking.value) { + return relayReachable.value; + } + try { + if (!await usesUserManagedIPhoneRelay()) { + return null; + } + } catch (e, s) { + Logger.warn("Failed to identify iPhone relay", + error: e, trace: s); + return null; + } + + relayHealthChecking.value = true; + final checkedAt = DateTime.now(); + relayLastChecked.value = checkedAt; + ss.prefs.setInt( + "relay-health-last-checked", checkedAt.millisecondsSinceEpoch); + + try { + final relayCode = + await api.validateRelay(configRef: state!.osConfig); + final reachable = relayCode != null; + relayReachable.value = reachable; + ss.prefs.setBool("relay-health-reachable", reachable); + + if (reachable) { + relayLastSuccess.value = checkedAt; + ss.prefs.setInt( + "relay-health-last-success", checkedAt.millisecondsSinceEpoch); + await notif.clearRelayUnavailable(); + } else if (notifyOnFailure) { + await notif.createRelayUnavailable(); + } + + return reachable; + } catch (e, s) { + relayReachable.value = false; + ss.prefs.setBool("relay-health-reachable", false); + Logger.warn("iPhone relay health check failed", error: e, trace: s); + if (notifyOnFailure) { + await notif.createRelayUnavailable(); + } + return false; + } finally { + relayHealthChecking.value = false; + } + } + + Future scheduleRelayHealthCheck(int secondsUntilRenewal) async { + relayHealthTimer?.cancel(); + relayHealthTimer = null; + + try { + if (!await usesUserManagedIPhoneRelay()) { + return; + } + } catch (e, s) { + Logger.warn("Failed to schedule iPhone relay health check", + error: e, trace: s); + return; + } + + const warningLeadTime = Duration(minutes: 15); + final delaySeconds = + max(10, secondsUntilRenewal - warningLeadTime.inSeconds); + relayHealthTimer = Timer(Duration(seconds: delaySeconds), () { + checkRelayHealth(notifyOnFailure: true); + }); + } + Map attachments = {}; Future> doValidateTargets(List targets, String handle) async { @@ -3353,12 +3454,19 @@ class RustPushService extends GetxService { var state = push.field0; if (state is api.RegisterState_Registered) { notifiedFailed = false; + scheduleRelayHealthCheck(state.nextS); if (ss.settings.deviceIsHosted.value) { mixpanel?.track("hosted-register-success"); } handleRegistered(); } + if (state is api.RegisterState_Registering) { + relayHealthTimer?.cancel(); + relayHealthTimer = null; + } if (state is api.RegisterState_Failed && !notifiedFailed) { + relayHealthTimer?.cancel(); + relayHealthTimer = null; if (ss.settings.deviceIsHosted.value) { mixpanel?.track("hosted-register-failure"); } @@ -4877,6 +4985,19 @@ class RustPushService extends GetxService { initAppLinks(); initMixPanel(); await initFuture; + restoreRelayHealthState(); + if (state != null) { + try { + final registrationState = + await api.getRegstate(state: state!.client); + if (registrationState is api.RegisterState_Registered) { + scheduleRelayHealthCheck(registrationState.nextS); + } + } catch (e, s) { + Logger.warn("Failed to schedule iPhone relay health check", + error: e, trace: s); + } + } Timer(const Duration(seconds: 2), checkIncident); // pre-cache next FT link if (pushService.state != null) api.getFtLink(facetime: pushService.state!.ftClient, usage: "next"); @@ -4978,6 +5099,8 @@ class RustPushService extends GetxService { void disposeState(api.SharedPushState state, bool hw, bool setup) { + relayHealthTimer?.cancel(); + relayHealthTimer = null; state.cancelPoll.dispose(); state.localBroadcast.dispose(); state.ftClient.dispose(); @@ -5036,6 +5159,8 @@ class RustPushService extends GetxService { @override void onClose() { + relayHealthTimer?.cancel(); + relayHealthTimer = null; if (state != null) disposeState(state!, true, false); super.onClose(); } From 5db2ec5481b5de5549dc0f3863c44606852f19be Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:04:53 -0700 Subject: [PATCH 02/33] Harden iPhone relay health monitoring --- .../settings/pages/profile/profile_panel.dart | 24 +- .../layouts/setup/pages/rustpush/hw_inp.dart | 50 +++- .../notifications/notifications_service.dart | 67 +++-- lib/services/rustpush/rustpush_service.dart | 240 ++++++++++++++---- 4 files changed, 282 insertions(+), 99 deletions(-) diff --git a/lib/app/layouts/settings/pages/profile/profile_panel.dart b/lib/app/layouts/settings/pages/profile/profile_panel.dart index e8336f1626..04f867ee5a 100644 --- a/lib/app/layouts/settings/pages/profile/profile_panel.dart +++ b/lib/app/layouts/settings/pages/profile/profile_panel.dart @@ -729,17 +729,19 @@ class _ProfilePanelState extends OptimizedState with WidgetsBindin : Icons.help_outline, color: color, ), - onTap: () async { - final result = - await pushService.checkRelayHealth(); - if (result == true) { - showSnackbar("iPhone Relay", - "The relay is online and responding."); - } else if (result == false) { - showSnackbar("iPhone Relay", - "The relay could not be reached. Check its power, Wi-Fi, and ValidationRelay status."); - } - }, + onTap: checking + ? null + : () async { + final result = + await pushService.checkRelayHealth(); + if (result == true) { + showSnackbar("iPhone Relay", + "The relay is online and responding."); + } else if (result == false) { + showSnackbar("iPhone Relay", + "The relay could not be reached. Check its power, Wi-Fi, and ValidationRelay status."); + } + }, ); }), if (accountInfo['login_status_message']?.startsWith("Deregistered") ?? false) diff --git a/lib/app/layouts/setup/pages/rustpush/hw_inp.dart b/lib/app/layouts/setup/pages/rustpush/hw_inp.dart index cbae9a5604..be084b0560 100644 --- a/lib/app/layouts/setup/pages/rustpush/hw_inp.dart +++ b/lib/app/layouts/setup/pages/rustpush/hw_inp.dart @@ -118,12 +118,28 @@ class HwInpState extends OptimizedState { } String lastCheckedCode = ""; - String relayHost = "https://registration-relay.beeper.com"; + String relayHost = registrationRelayHost; + + String normalizeRelayHost(String value) { + final uri = Uri.tryParse(value.trim()); + if (uri == null || + uri.scheme != "https" || + uri.host.isEmpty || + uri.userInfo.isNotEmpty || + (uri.path.isNotEmpty && uri.path != "/") || + uri.query.isNotEmpty || + uri.fragment.isNotEmpty) { + throw const FormatException( + "Relay server must be a secure HTTPS origin without credentials, a path, a query, or a fragment."); + } + return uri.toString().replaceFirst(RegExp(r"/+$"), ""); + } Future handleBeeper(String code) async { if (code == lastCheckedCode) return; lastCheckedCode = code; try { + relayHost = normalizeRelayHost(relayHost); if (staging == null) { FocusManager.instance.primaryFocus?.unfocus(); } @@ -134,7 +150,8 @@ class HwInpState extends OptimizedState { options: Options( headers: { // not a secret; burner account - "X-Beeper-Access-Token": "5c175851953ecaf5209185d897591badb6c3e712", + "X-Beeper-Access-Token": + registrationRelayAccessToken, "Authorization": "Bearer $code", }, ) @@ -143,7 +160,10 @@ class HwInpState extends OptimizedState { api.JoinedOsConfig parsed; if (response2.data["versions"]["software_name"] == "iPhone OS") { Logger.debug("Using as iOS"); - parsed = await api.configFromRelay(code: code, host: relayHost, token: "5c175851953ecaf5209185d897591badb6c3e712"); + parsed = await api.configFromRelay( + code: code, + host: relayHost, + token: registrationRelayAccessToken); usingBeeper = false; } else { final response = await http.dio.post( @@ -152,7 +172,8 @@ class HwInpState extends OptimizedState { options: Options( headers: { // not a secret; burner account - "X-Beeper-Access-Token": "5c175851953ecaf5209185d897591badb6c3e712", + "X-Beeper-Access-Token": + registrationRelayAccessToken, "Authorization": "Bearer $code", }, ) @@ -172,6 +193,8 @@ class HwInpState extends OptimizedState { usingBeeper = true; } showSnackbar("Fetching validation data", "Done"); + await ss.prefs.setString( + "registration-relay-host", relayHost); stagingNonInp = true; select(parsed, true); } catch (e) { @@ -785,10 +808,19 @@ class HwInpState extends OptimizedState { TextButton( child: Text("OK", style: Get.context!.theme.textTheme.bodyLarge!.copyWith(color: Get.context!.theme.colorScheme.primary)), onPressed: () async { - relayHost = server.text; - lastCheckedCode = ""; - Get.back(); - checkCode(codeController.text); + try { + relayHost = + normalizeRelayHost( + server.text); + lastCheckedCode = ""; + Get.back(); + checkCode( + codeController.text); + } on FormatException catch (e) { + showSnackbar( + "Invalid relay URL", + e.message); + } }, ), ], @@ -1087,4 +1119,4 @@ class HwInpState extends OptimizedState { // Get.delete(force: true); } -} \ No newline at end of file +} diff --git a/lib/services/backend/notifications/notifications_service.dart b/lib/services/backend/notifications/notifications_service.dart index 5355da2b4f..0ea5786217 100644 --- a/lib/services/backend/notifications/notifications_service.dart +++ b/lib/services/backend/notifications/notifications_service.dart @@ -51,6 +51,7 @@ class NotificationsService extends GetxService { final FlutterLocalNotificationsPlugin flnp = FlutterLocalNotificationsPlugin(); StreamSubscription? countSub; int currentCount = 0; + Timer? relayReminderTimer; /// For desktop use only static LocalNotification? allToast; @@ -170,6 +171,8 @@ class NotificationsService extends GetxService { @override void onClose() { countSub?.cancel(); + relayReminderTimer?.cancel(); + relayReminderTimer = null; super.onClose(); } @@ -1071,48 +1074,57 @@ class NotificationsService extends GetxService { ); } - Future clearRelayUnavailable() async { - if (kIsWeb) { - return; - } + Future cancelRelayCheckReminder() async { + relayReminderTimer?.cancel(); + relayReminderTimer = null; + if (kIsDesktop) { await relayToast?.close(); relayToast = null; return; } - await flnp.cancel(-6 - 50); + if (!kIsWeb) { + await flnp.cancel(-7 - 50); + } } - Future createRelayUnavailable() async { - if (kIsWeb) { - return; - } - const title = "iPhone relay unavailable"; + Future scheduleRelayCheckReminder(DateTime time) async { + await cancelRelayCheckReminder(); + + const title = "Check your iPhone relay"; const subtitle = - "OpenBubbles may lose phone number registration. Turn on the relay, then tap Test Relay."; + "Phone number registration renews soon. Tap to verify that the relay is online."; if (kIsDesktop) { - relayToast = LocalNotification( - title: title, - body: subtitle, - actions: [], - ); + final delay = time.difference(DateTime.now()); + relayReminderTimer = + Timer(delay.isNegative ? Duration.zero : delay, () async { + relayToast = LocalNotification( + title: title, + body: subtitle, + actions: [], + ); - relayToast!.onClick = () async { - relayToast = null; - await windowManager.show(); - if (ss.settings.finishedSetup.value) { - ns.pushLeft(Get.context!, ProfilePanel()); - } - }; + relayToast!.onClick = () async { + relayToast = null; + await windowManager.show(); + if (ss.settings.finishedSetup.value) { + ns.pushLeft(Get.context!, ProfilePanel()); + } + }; - await relayToast!.show(); + await relayToast!.show(); + }); + return; + } + if (kIsWeb) { return; } - await flnp.show( - -6 - 50, + await flnp.zonedSchedule( + -7 - 50, title, subtitle, + TZDateTime.from(time, local), NotificationDetails( android: AndroidNotificationDetails( ERROR_CHANNEL, @@ -1125,6 +1137,9 @@ class NotificationsService extends GetxService { ), ), payload: "-51", + androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, + uiLocalNotificationDateInterpretation: + UILocalNotificationDateInterpretation.absoluteTime, ); } diff --git a/lib/services/rustpush/rustpush_service.dart b/lib/services/rustpush/rustpush_service.dart index 1d5f4e8342..1249aef832 100644 --- a/lib/services/rustpush/rustpush_service.dart +++ b/lib/services/rustpush/rustpush_service.dart @@ -51,6 +51,7 @@ import 'package:mixpanel_flutter/mixpanel_flutter.dart'; import 'package:bluebubbles/helpers/backend/startup_tasks.dart'; import 'package:flutter_isolate/flutter_isolate.dart'; import 'package:google_sign_in_all_platforms/google_sign_in_all_platforms.dart'; +import 'package:synchronized/synchronized.dart'; var uuid = const Uuid(); RustPushService pushService = @@ -58,6 +59,9 @@ RustPushService pushService = const rpApiRoot = "https://hw.openbubbles.app/code"; +const registrationRelayHost = "https://registration-relay.beeper.com"; +const registrationRelayAccessToken = + "5c175851953ecaf5209185d897591badb6c3e712"; const clientId = '1041242226917-ik21n86fp43e82iu1e5soh6bu6gvuste.apps.googleusercontent.com'; const clientSecret = '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'; @@ -1317,9 +1321,71 @@ class RustPushService extends GetxService { final RxnBool relayReachable = RxnBool(); final Rxn relayLastChecked = Rxn(); final Rxn relayLastSuccess = Rxn(); - Timer? relayHealthTimer; + Future? _relayHealthInFlight; + String? _relayHealthFingerprint; + final Lock _relayReminderLock = Lock(); + + Future getUserManagedIPhoneRelayDevice( + {api.SharedPushState? fromState}) async { + final currentState = fromState ?? state; + if (currentState == null || ss.settings.deviceIsHosted.value) { + return null; + } + + final device = + await api.getDeviceInfo(config: currentState.osConfig); + if (device.name.contains("iPhone") || + device.name.contains("iPod") || + device.name.contains("iPad")) { + return device; + } + return null; + } + + String relayHealthFingerprint(api.DeviceInfo device) { + final relayHost = ss.prefs.getString("registration-relay-host") ?? + registrationRelayHost; + final fingerprintSource = + "${device.serial}|${ss.settings.iCloudAccount.value}|$relayHost"; + return sha256.convert(utf8.encode(fingerprintSource)).toString(); + } + + Future clearRelayHealthState({bool clearPreferences = true}) async { + relayReachable.value = null; + relayLastChecked.value = null; + relayLastSuccess.value = null; + _relayHealthFingerprint = null; + if (!clearPreferences) { + return; + } + + await Future.wait([ + ss.prefs.remove("relay-health-fingerprint"), + ss.prefs.remove("relay-health-last-checked"), + ss.prefs.remove("relay-health-last-success"), + ss.prefs.remove("relay-health-reachable"), + ]); + } + + Future restoreRelayHealthState() async { + final device = await getUserManagedIPhoneRelayDevice(); + if (device == null) { + await clearRelayHealthState(); + return; + } - void restoreRelayHealthState() { + final fingerprint = relayHealthFingerprint(device); + final savedFingerprint = + ss.prefs.getString("relay-health-fingerprint"); + if (savedFingerprint != fingerprint) { + await clearRelayHealthState(); + _relayHealthFingerprint = fingerprint; + await ss.prefs.setString( + "relay-health-fingerprint", fingerprint); + return; + } + + _relayHealthFingerprint = fingerprint; final lastChecked = ss.prefs.getInt("relay-health-last-checked"); final lastSuccess = ss.prefs.getInt("relay-health-last-success"); relayReachable.value = ss.prefs.getBool("relay-health-reachable"); @@ -1332,88 +1398,147 @@ class RustPushService extends GetxService { } Future usesUserManagedIPhoneRelay() async { - if (state == null || ss.settings.deviceIsHosted.value) { - return false; + return await getUserManagedIPhoneRelayDevice() != null; + } + + Future checkRelayHealth() async { + final existingCheck = _relayHealthInFlight; + if (existingCheck != null) { + return await existingCheck; } - final device = await api.getDeviceInfo(config: state!.osConfig); - return device.name.contains("iPhone") || - device.name.contains("iPod") || - device.name.contains("iPad"); + final check = _performRelayHealthCheck(); + _relayHealthInFlight = check; + try { + return await check; + } finally { + if (identical(_relayHealthInFlight, check)) { + _relayHealthInFlight = null; + } + } } - Future checkRelayHealth({bool notifyOnFailure = false}) async { - if (relayHealthChecking.value) { - return relayReachable.value; + Future _performRelayHealthCheck() async { + final currentState = state; + if (currentState == null) { + return null; } + + api.DeviceInfo? relayDevice; try { - if (!await usesUserManagedIPhoneRelay()) { - return null; - } + relayDevice = await getUserManagedIPhoneRelayDevice( + fromState: currentState); } catch (e, s) { Logger.warn("Failed to identify iPhone relay", error: e, trace: s); return null; } + if (relayDevice == null) { + return null; + } + + final fingerprint = relayHealthFingerprint(relayDevice); + if (_relayHealthFingerprint != fingerprint) { + await clearRelayHealthState(); + _relayHealthFingerprint = fingerprint; + await ss.prefs.setString( + "relay-health-fingerprint", fingerprint); + } relayHealthChecking.value = true; final checkedAt = DateTime.now(); relayLastChecked.value = checkedAt; - ss.prefs.setInt( + await ss.prefs.setInt( "relay-health-last-checked", checkedAt.millisecondsSinceEpoch); try { final relayCode = - await api.validateRelay(configRef: state!.osConfig); - final reachable = relayCode != null; + await api.validateRelay(configRef: currentState.osConfig); + var reachable = false; + if (relayCode != null) { + final relayHost = + ss.prefs.getString("registration-relay-host") ?? + registrationRelayHost; + final response = await http.dio.post( + "$relayHost/api/v1/bridge/get-version-info", + data: {}, + options: Options( + headers: { + "X-Beeper-Access-Token": + registrationRelayAccessToken, + "Authorization": "Bearer $relayCode", + }, + ), + ); + final responseData = response.data; + final versions = + responseData is Map ? responseData["versions"] : null; + reachable = response.statusCode == 200 && + versions is Map && + versions["software_name"] == "iPhone OS"; + } + relayReachable.value = reachable; - ss.prefs.setBool("relay-health-reachable", reachable); + await ss.prefs.setBool("relay-health-reachable", reachable); if (reachable) { relayLastSuccess.value = checkedAt; - ss.prefs.setInt( + await ss.prefs.setInt( "relay-health-last-success", checkedAt.millisecondsSinceEpoch); - await notif.clearRelayUnavailable(); - } else if (notifyOnFailure) { - await notif.createRelayUnavailable(); } return reachable; } catch (e, s) { relayReachable.value = false; - ss.prefs.setBool("relay-health-reachable", false); + await ss.prefs.setBool("relay-health-reachable", false); Logger.warn("iPhone relay health check failed", error: e, trace: s); - if (notifyOnFailure) { - await notif.createRelayUnavailable(); - } return false; } finally { relayHealthChecking.value = false; } } - Future scheduleRelayHealthCheck(int secondsUntilRenewal) async { - relayHealthTimer?.cancel(); - relayHealthTimer = null; - - try { - if (!await usesUserManagedIPhoneRelay()) { + Future scheduleRelayHealthReminder( + int secondsUntilRenewal) async { + await _relayReminderLock.synchronized(() async { + await notif.cancelRelayCheckReminder(); + final currentState = state; + if (currentState == null) { + return; + } + try { + if (await getUserManagedIPhoneRelayDevice( + fromState: currentState) == + null) { + return; + } + if ((await api.getMyPhoneHandles( + state: currentState.client)) + .isEmpty) { + return; + } + } catch (e, s) { + Logger.warn("Failed to schedule iPhone relay reminder", + error: e, trace: s); + return; + } + if (!identical(state, currentState)) { return; } - } catch (e, s) { - Logger.warn("Failed to schedule iPhone relay health check", - error: e, trace: s); - return; - } - const warningLeadTime = Duration(minutes: 15); - final delaySeconds = - max(10, secondsUntilRenewal - warningLeadTime.inSeconds); - relayHealthTimer = Timer(Duration(seconds: delaySeconds), () { - checkRelayHealth(notifyOnFailure: true); + const warningLeadTime = Duration(minutes: 15); + final delaySeconds = + max(10, secondsUntilRenewal - warningLeadTime.inSeconds); + await notif.scheduleRelayCheckReminder( + DateTime.now().add(Duration(seconds: delaySeconds))); }); } + Future cancelRelayHealthReminder() async { + await _relayReminderLock.synchronized( + () => notif.cancelRelayCheckReminder()); + } + Map attachments = {}; Future> doValidateTargets(List targets, String handle) async { @@ -3454,19 +3579,17 @@ class RustPushService extends GetxService { var state = push.field0; if (state is api.RegisterState_Registered) { notifiedFailed = false; - scheduleRelayHealthCheck(state.nextS); + unawaited(scheduleRelayHealthReminder(state.nextS)); if (ss.settings.deviceIsHosted.value) { mixpanel?.track("hosted-register-success"); } handleRegistered(); } if (state is api.RegisterState_Registering) { - relayHealthTimer?.cancel(); - relayHealthTimer = null; + unawaited(cancelRelayHealthReminder()); } if (state is api.RegisterState_Failed && !notifiedFailed) { - relayHealthTimer?.cancel(); - relayHealthTimer = null; + unawaited(cancelRelayHealthReminder()); if (ss.settings.deviceIsHosted.value) { mixpanel?.track("hosted-register-failure"); } @@ -4985,13 +5108,19 @@ class RustPushService extends GetxService { initAppLinks(); initMixPanel(); await initFuture; - restoreRelayHealthState(); + try { + await restoreRelayHealthState(); + } catch (e, s) { + Logger.warn("Failed to restore iPhone relay health", + error: e, trace: s); + await clearRelayHealthState(); + } if (state != null) { try { final registrationState = await api.getRegstate(state: state!.client); if (registrationState is api.RegisterState_Registered) { - scheduleRelayHealthCheck(registrationState.nextS); + await scheduleRelayHealthReminder(registrationState.nextS); } } catch (e, s) { Logger.warn("Failed to schedule iPhone relay health check", @@ -5076,6 +5205,14 @@ class RustPushService extends GetxService { var thisState = state; state = null; + final relayHealthCheck = _relayHealthInFlight; + if (relayHealthCheck != null) { + await relayHealthCheck; + } + await cancelRelayHealthReminder(); + if (hw || logout) { + await clearRelayHealthState(); + } if (thisState == null) return; if (logout) { @@ -5099,8 +5236,6 @@ class RustPushService extends GetxService { void disposeState(api.SharedPushState state, bool hw, bool setup) { - relayHealthTimer?.cancel(); - relayHealthTimer = null; state.cancelPoll.dispose(); state.localBroadcast.dispose(); state.ftClient.dispose(); @@ -5159,8 +5294,7 @@ class RustPushService extends GetxService { @override void onClose() { - relayHealthTimer?.cancel(); - relayHealthTimer = null; + unawaited(cancelRelayHealthReminder()); if (state != null) disposeState(state!, true, false); super.onClose(); } From 991b805c4540e639d608c7659d5d74f335c60c30 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:06:21 -0700 Subject: [PATCH 03/33] Show relay health only for iPhone relays --- lib/app/layouts/settings/pages/profile/profile_panel.dart | 3 +++ lib/services/rustpush/rustpush_service.dart | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/lib/app/layouts/settings/pages/profile/profile_panel.dart b/lib/app/layouts/settings/pages/profile/profile_panel.dart index 04f867ee5a..f44bd24d23 100644 --- a/lib/app/layouts/settings/pages/profile/profile_panel.dart +++ b/lib/app/layouts/settings/pages/profile/profile_panel.dart @@ -690,6 +690,9 @@ class _ProfilePanelState extends OptimizedState with WidgetsBindin if ((accountInfo["can_pnr"] ?? false) && !ss.settings.deviceIsHosted.value) Obx(() { + if (!pushService.relayHealthAvailable.value) { + return const SizedBox.shrink(); + } final reachable = pushService.relayReachable.value; final checking = diff --git a/lib/services/rustpush/rustpush_service.dart b/lib/services/rustpush/rustpush_service.dart index 1249aef832..859f4a6dc6 100644 --- a/lib/services/rustpush/rustpush_service.dart +++ b/lib/services/rustpush/rustpush_service.dart @@ -1318,6 +1318,7 @@ class RustPushService extends GetxService { var disableOutgoingSms = false; final RxBool relayHealthChecking = false.obs; + final RxBool relayHealthAvailable = false.obs; final RxnBool relayReachable = RxnBool(); final Rxn relayLastChecked = Rxn(); final Rxn relayLastSuccess = Rxn(); @@ -1351,6 +1352,7 @@ class RustPushService extends GetxService { } Future clearRelayHealthState({bool clearPreferences = true}) async { + relayHealthAvailable.value = false; relayReachable.value = null; relayLastChecked.value = null; relayLastSuccess.value = null; @@ -1375,10 +1377,12 @@ class RustPushService extends GetxService { } final fingerprint = relayHealthFingerprint(device); + relayHealthAvailable.value = true; final savedFingerprint = ss.prefs.getString("relay-health-fingerprint"); if (savedFingerprint != fingerprint) { await clearRelayHealthState(); + relayHealthAvailable.value = true; _relayHealthFingerprint = fingerprint; await ss.prefs.setString( "relay-health-fingerprint", fingerprint); @@ -1436,10 +1440,12 @@ class RustPushService extends GetxService { if (relayDevice == null) { return null; } + relayHealthAvailable.value = true; final fingerprint = relayHealthFingerprint(relayDevice); if (_relayHealthFingerprint != fingerprint) { await clearRelayHealthState(); + relayHealthAvailable.value = true; _relayHealthFingerprint = fingerprint; await ss.prefs.setString( "relay-health-fingerprint", fingerprint); From ab9293a9fe67e1292db5b154a497c154e4e15cde Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:53:19 -0700 Subject: [PATCH 04/33] Clean up stale UI listeners --- .../avatars/contact_avatar_widget.dart | 12 +++++++++- .../widgets/tile/conversation_tile.dart | 22 +++++++++++++++---- .../tile/pinned_conversation_tile.dart | 10 ++++++++- .../pages/messages_view.dart | 11 +++++++--- .../effects/screen_effects_widget.dart | 12 ++++++++-- .../message/attachment/attachment_holder.dart | 9 +++++++- .../interactive/interactive_holder.dart | 10 ++++++++- .../widgets/message/message_holder.dart | 11 +++++++++- .../widgets/message/misc/bubble_effects.dart | 11 +++++++++- .../widgets/message/text/text_bubble.dart | 16 ++++++++++++-- .../timestamp/delivered_indicator.dart | 12 +++++++++- lib/app/wrappers/stateful_boilerplate.dart | 9 +++++++- lib/app/wrappers/tablet_mode_wrapper.dart | 17 +++++++++++--- 13 files changed, 140 insertions(+), 22 deletions(-) diff --git a/lib/app/components/avatars/contact_avatar_widget.dart b/lib/app/components/avatars/contact_avatar_widget.dart index 25e7910556..63a2278933 100644 --- a/lib/app/components/avatars/contact_avatar_widget.dart +++ b/lib/app/components/avatars/contact_avatar_widget.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:bluebubbles/helpers/helpers.dart'; import 'package:bluebubbles/app/wrappers/stateful_boilerplate.dart'; import 'package:bluebubbles/database/models.dart'; @@ -37,11 +39,13 @@ class ContactAvatarWidget extends StatefulWidget { class _ContactAvatarWidgetState extends OptimizedState { Contact? get contact => widget.contact ?? widget.handle?.contact; String get keyPrefix => widget.handle?.address ?? randomString(8); + late final StreamSubscription _avatarRefreshSubscription; @override void initState() { super.initState(); - eventDispatcher.stream.listen((event) { + _avatarRefreshSubscription = eventDispatcher.stream.listen((event) { + if (!mounted) return; if (event.item1 != 'refresh-avatar') return; if (event.item2[0] != widget.handle?.address) return; widget.handle?.color = event.item2[1]; @@ -49,6 +53,12 @@ class _ContactAvatarWidgetState extends OptimizedState { }); } + @override + void dispose() { + _avatarRefreshSubscription.cancel(); + super.dispose(); + } + void onAvatarTap() async { if (!ss.settings.colorfulAvatars.value && !ss.settings.colorfulBubbles.value) return; diff --git a/lib/app/layouts/conversation_list/widgets/tile/conversation_tile.dart b/lib/app/layouts/conversation_list/widgets/tile/conversation_tile.dart index de10e12257..e92f0ca0ce 100644 --- a/lib/app/layouts/conversation_list/widgets/tile/conversation_tile.dart +++ b/lib/app/layouts/conversation_list/widgets/tile/conversation_tile.dart @@ -220,6 +220,7 @@ class ConversationTile extends CustomStateful { class _ConversationTileState extends CustomState with AutomaticKeepAliveClientMixin { ConversationListController get listController => controller.listController; + late final StreamSubscription _highlightSubscription; @override bool get wantKeepAlive => true; @@ -237,7 +238,8 @@ class _ConversationTileState extends CustomState { class _ChatTitleState extends CustomState { String title = "Unknown"; StreamSubscription? sub; + StreamSubscription? eventSub; String? cachedDisplayName = ""; List cachedParticipants = []; @@ -356,7 +365,8 @@ class _ChatTitleState extends CustomState class _PinnedConversationTileState extends CustomState { ConversationListController get listController => controller.listController; Offset? longPressPosition; + late final StreamSubscription _highlightSubscription; @override void initState() { @@ -53,7 +54,8 @@ class _PinnedConversationTileState extends CustomState { final RxBool latestMessageDeliveredState = false.obs; final RxBool jumpingToOldestUnread = false.obs; final Map messageFocusNodes = {}; + late final StreamSubscription _eventSubscription; ConversationViewController get controller => widget.controller; @@ -135,7 +136,8 @@ class MessagesViewState extends OptimizedState { void initState() { super.initState(); - eventDispatcher.stream.listen((e) async { + _eventSubscription = eventDispatcher.stream.listen((e) async { + if (!mounted) return; if (e.item1 == "refresh-messagebloc" && e.item2 == chat.guid) { // Clear state items noMoreMessages = false; @@ -200,9 +202,12 @@ class MessagesViewState extends OptimizedState { @override void dispose() { + _eventSubscription.cancel(); if (!kIsWeb && !kIsDesktop) smartReply.close(); - chat.lastReadMessageGuid = _messages.first.guid; - chat.save(updateLastReadMessageGuid: true); + if (_messages.isNotEmpty) { + chat.lastReadMessageGuid = _messages.first.guid; + chat.save(updateLastReadMessageGuid: true); + } messageService.close(force: widget.customService != null); if (controller.bottomMessageFocusNode != null && messageFocusNodes.containsValue(controller.bottomMessageFocusNode)) { controller.bottomMessageFocusNode = null; diff --git a/lib/app/layouts/conversation_view/widgets/effects/screen_effects_widget.dart b/lib/app/layouts/conversation_view/widgets/effects/screen_effects_widget.dart index 7924e089d2..4354b7e017 100644 --- a/lib/app/layouts/conversation_view/widgets/effects/screen_effects_widget.dart +++ b/lib/app/layouts/conversation_view/widgets/effects/screen_effects_widget.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math'; import 'package:bluebubbles/app/animations/balloon_classes.dart'; @@ -36,6 +37,7 @@ class _ScreenEffectsWidgetState extends OptimizedState with late final SpotlightController spotlightController; late final LaserController laserController; String screenSelected = ""; + late final StreamSubscription _effectSubscription; @override void initState() { @@ -51,7 +53,7 @@ class _ScreenEffectsWidgetState extends OptimizedState with laserController = LaserController(vsync: this, windowSize: Size(ns.width(context), context.height)); }); - eventDispatcher.stream.listen((event) async { + _effectSubscription = eventDispatcher.stream.listen((event) async { if (event.item1 == 'play-effect' && mounted && screenSelected.isEmpty) { setState(() { screenSelected = event.item2['type']; @@ -126,6 +128,12 @@ class _ScreenEffectsWidgetState extends OptimizedState with }); } + @override + void dispose() { + _effectSubscription.cancel(); + super.dispose(); + } + @override Widget build(BuildContext context) { @@ -157,4 +165,4 @@ class _ScreenEffectsWidgetState extends OptimizedState with ), ); } -} \ No newline at end of file +} diff --git a/lib/app/layouts/conversation_view/widgets/message/attachment/attachment_holder.dart b/lib/app/layouts/conversation_view/widgets/message/attachment/attachment_holder.dart index 3c89499897..2f4d62ae2c 100644 --- a/lib/app/layouts/conversation_view/widgets/message/attachment/attachment_holder.dart +++ b/lib/app/layouts/conversation_view/widgets/message/attachment/attachment_holder.dart @@ -44,12 +44,14 @@ class _AttachmentHolderState extends CustomState getAudioTranscriptsFromAttributedBody(message.attributedBody)[part.part]; late dynamic content; late bool selected = controller.cvController?.isSelected(message.guid!) ?? false; + Worker? _selectionWorker; @override void initState() { forceDelete = false; if (controller.cvController != null && !iOS) { - ever>(controller.cvController!.selected, (event) { + _selectionWorker = ever>(controller.cvController!.selected, (event) { + if (!mounted) return; if (controller.cvController!.isSelected(message.guid!) && !selected) { setState(() { selected = true; @@ -65,6 +67,11 @@ class _AttachmentHolderState extends CustomState>(controller.cvController!.selected, (event) { + _selectionWorker = ever>(controller.cvController!.selected, (event) { + if (!mounted) return; if (controller.cvController!.isSelected(message.guid!) && !selected) { setState(() { selected = true; @@ -86,6 +88,12 @@ class _InteractiveHolderState extends CustomState true; diff --git a/lib/app/layouts/conversation_view/widgets/message/message_holder.dart b/lib/app/layouts/conversation_view/widgets/message/message_holder.dart index 1710febcea..1e15e9bba7 100644 --- a/lib/app/layouts/conversation_view/widgets/message/message_holder.dart +++ b/lib/app/layouts/conversation_view/widgets/message/message_holder.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:bluebubbles/app/components/custom/custom_bouncing_scroll_physics.dart'; @@ -84,6 +85,7 @@ class _MessageHolderState extends CustomState keys = []; bool gaveHapticFeedback = false; final RxBool tapped = false.obs; + late final StreamSubscription _avatarRefreshSubscription; @override void initState() { @@ -105,7 +107,8 @@ class _MessageHolderState extends CustomState GlobalKey()); } - eventDispatcher.stream.listen((event) { + _avatarRefreshSubscription = eventDispatcher.stream.listen((event) { + if (!mounted) return; if (event.item1 != 'refresh-avatar') return; if (event.item2[0] != message.handle?.address) return; message.handle?.color = event.item2[1]; @@ -113,6 +116,12 @@ class _MessageHolderState extends CustomState { late MovieTween tween; Control controller = Control.stop; Size size = Size.zero; + late final StreamSubscription _effectSubscription; @override void initState() { getTween(); - eventDispatcher.stream.listen((event) async { + _effectSubscription = eventDispatcher.stream.listen((event) { + if (!mounted) return; if (event.item1 == 'play-bubble-effect' && event.item2 == '${widget.part}/${widget.message.guid}') { size = widget.globalKey?.currentContext?.size ?? Size.zero; setState(() { @@ -58,6 +61,12 @@ class _BubbleEffectsState extends OptimizedState { super.initState(); } + @override + void dispose() { + _effectSubscription.cancel(); + super.dispose(); + } + void getTween() { if (effect == MessageEffect.gentle) { tween = MovieTween() diff --git a/lib/app/layouts/conversation_view/widgets/message/text/text_bubble.dart b/lib/app/layouts/conversation_view/widgets/message/text/text_bubble.dart index 08d497377b..a42e901bac 100644 --- a/lib/app/layouts/conversation_view/widgets/message/text/text_bubble.dart +++ b/lib/app/layouts/conversation_view/widgets/message/text/text_bubble.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:ui'; import 'package:bluebubbles/app/wrappers/stateful_boilerplate.dart'; @@ -36,6 +37,8 @@ class _TextBubbleState extends CustomState>(controller.cvController!.selected, (event) { + _selectionWorker = ever>(controller.cvController!.selected, (event) { + if (!mounted) return; if (controller.cvController!.isSelected(message.guid!) && !selected) { setState(() { selected = true; @@ -76,6 +81,13 @@ class _TextBubbleState extends CustomState getBubbleColors() { if (selected && !iOS) return [context.theme.colorScheme.tertiaryContainer, context.theme.colorScheme.tertiaryContainer]; List bubbleColors = [context.theme.colorScheme.properSurface, context.theme.colorScheme.properSurface]; diff --git a/lib/app/layouts/conversation_view/widgets/message/timestamp/delivered_indicator.dart b/lib/app/layouts/conversation_view/widgets/message/timestamp/delivered_indicator.dart index 1d85e62366..0f5021b724 100644 --- a/lib/app/layouts/conversation_view/widgets/message/timestamp/delivered_indicator.dart +++ b/lib/app/layouts/conversation_view/widgets/message/timestamp/delivered_indicator.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:bluebubbles/app/wrappers/stateful_boilerplate.dart'; import 'package:bluebubbles/helpers/helpers.dart'; import 'package:bluebubbles/database/models.dart'; @@ -22,19 +24,27 @@ class DeliveredIndicator extends CustomStateful { class _DeliveredIndicatorState extends CustomState { Message get message => controller.message; bool get showAvatar => (controller.cvController?.chat ?? cm.activeChat!.chat).isGroup; + late final StreamSubscription _messageUpdateSubscription; @override void initState() { forceDelete = false; super.initState(); - eventDispatcher.stream.listen((event) { + _messageUpdateSubscription = eventDispatcher.stream.listen((event) { + if (!mounted) return; if (event.item1 == "message-updated-${message.guid}") { setState(() {}); } }); } + @override + void dispose() { + _messageUpdateSubscription.cancel(); + super.dispose(); + } + bool get shouldShow { if (controller.audioWasKept.value != null) return true; if (widget.forceShow || message.guid!.contains("temp")) return true; diff --git a/lib/app/wrappers/stateful_boilerplate.dart b/lib/app/wrappers/stateful_boilerplate.dart index f7285a80f3..cfff176107 100644 --- a/lib/app/wrappers/stateful_boilerplate.dart +++ b/lib/app/wrappers/stateful_boilerplate.dart @@ -28,6 +28,7 @@ abstract class CustomStateful extends StatefulWidg abstract class CustomState extends State with ThemeHelpers { // completer to check if the page animation is complete final animCompleted = Completer(); + late final void Function(R) _updateWidgetCallback; @protected /// Convenience getter for the [GetxController] @@ -55,7 +56,8 @@ abstract class CustomState(tag: _tag); super.dispose(); } diff --git a/lib/app/wrappers/tablet_mode_wrapper.dart b/lib/app/wrappers/tablet_mode_wrapper.dart index ba6caed091..bfc88cbe24 100644 --- a/lib/app/wrappers/tablet_mode_wrapper.dart +++ b/lib/app/wrappers/tablet_mode_wrapper.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math'; import 'package:bluebubbles/helpers/ui/theme_helpers.dart'; @@ -43,6 +44,8 @@ class _TabletModeWrapperState extends OptimizedState { late final RxDouble _ratio; double? _maxWidth; bool? altLayoutCache; + late final StreamSubscription _eventSubscription; + late final Worker _ratioWorker; get _width1 => max(min(_ratio * _maxWidth!, widget.maxWidthLeft ?? double.infinity), widget.minWidthLeft ?? double.negativeInfinity); @@ -52,7 +55,8 @@ class _TabletModeWrapperState extends OptimizedState { void initState() { super.initState(); _ratio = RxDouble((ss.prefs.getDouble('splitRatio') ?? widget.initialRatio).clamp(widget.minRatio, widget.maxRatio)); - eventDispatcher.stream.listen((event) { + _eventSubscription = eventDispatcher.stream.listen((event) { + if (!mounted) return; if (event.item1 == 'split-refresh') { _ratio.value = ss.prefs.getDouble('splitRatio') ?? _ratio.value; setState(() {}); @@ -61,12 +65,19 @@ class _TabletModeWrapperState extends OptimizedState { setState(() {}); } }); - debounce(_ratio, (val) async { + _ratioWorker = debounce(_ratio, (val) async { await ss.prefs.setDouble('splitRatio', val); eventDispatcher.emit('split-refresh', null); }); } + @override + void dispose() { + _eventSubscription.cancel(); + _ratioWorker.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { if (!showAltLayout) { @@ -137,4 +148,4 @@ class _TabletModeWrapperState extends OptimizedState { }, ); } -} \ No newline at end of file +} From cf7e5199ecda9e7eb36cd527492534c67b4f0926 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:12:26 -0700 Subject: [PATCH 05/33] Fix UI lifecycle and relay retry edge cases --- .../avatars/contact_avatar_widget.dart | 4 +- .../widgets/conversation_list_fab.dart | 47 +++++++++++------- .../pages/messages_view.dart | 48 +++++++++++-------- .../settings/pages/theming/theming_panel.dart | 4 +- .../layouts/setup/pages/rustpush/hw_inp.dart | 39 +++++++++++---- 5 files changed, 92 insertions(+), 50 deletions(-) diff --git a/lib/app/components/avatars/contact_avatar_widget.dart b/lib/app/components/avatars/contact_avatar_widget.dart index 63a2278933..3a70a84992 100644 --- a/lib/app/components/avatars/contact_avatar_widget.dart +++ b/lib/app/components/avatars/contact_avatar_widget.dart @@ -38,12 +38,14 @@ class ContactAvatarWidget extends StatefulWidget { class _ContactAvatarWidgetState extends OptimizedState { Contact? get contact => widget.contact ?? widget.handle?.contact; - String get keyPrefix => widget.handle?.address ?? randomString(8); + late final String _keyPrefix; + String get keyPrefix => _keyPrefix; late final StreamSubscription _avatarRefreshSubscription; @override void initState() { super.initState(); + _keyPrefix = widget.handle?.address ?? randomString(8); _avatarRefreshSubscription = eventDispatcher.stream.listen((event) { if (!mounted) return; if (event.item1 != 'refresh-avatar') return; diff --git a/lib/app/layouts/conversation_list/widgets/conversation_list_fab.dart b/lib/app/layouts/conversation_list/widgets/conversation_list_fab.dart index 54a78d9114..ff3449eb09 100644 --- a/lib/app/layouts/conversation_list/widgets/conversation_list_fab.dart +++ b/lib/app/layouts/conversation_list/widgets/conversation_list_fab.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:bluebubbles/app/layouts/conversation_list/pages/conversation_list.dart'; import 'package:bluebubbles/app/wrappers/stateful_boilerplate.dart'; import 'package:bluebubbles/app/wrappers/theme_switcher.dart'; @@ -18,6 +20,8 @@ class ConversationListFAB extends CustomStateful { } class _ConversationListFABState extends CustomState { + late final StreamSubscription _avatarOnlySubscription; + void _focusBackToList() { if (!FocusScope.of(context).focusInDirection(TraversalDirection.left)) { FocusScope.of(context).previousFocus(); @@ -31,27 +35,29 @@ class _ConversationListFABState extends CustomState controller.openNewChatCreator(context), }; + void _handleMaterialScroll() { + if (!mounted || !material) return; + if (controller.materialScrollStartPosition - controller.materialScrollController.offset < -75 + && controller.materialScrollController.position.userScrollDirection == ScrollDirection.reverse + && controller.showMaterialFABText) { + setState(() { + controller.showMaterialFABText = false; + }); + } else if (controller.materialScrollStartPosition - controller.materialScrollController.offset > 75 + && controller.materialScrollController.position.userScrollDirection == ScrollDirection.forward + && !controller.showMaterialFABText) { + setState(() { + controller.showMaterialFABText = true; + }); + } + } + @override void initState() { super.initState(); - controller.materialScrollController.addListener(() { - if (!material) return; - if (controller.materialScrollStartPosition - controller.materialScrollController.offset < -75 - && controller.materialScrollController.position.userScrollDirection == ScrollDirection.reverse - && controller.showMaterialFABText) { - setState(() { - controller.showMaterialFABText = false; - }); - } else if (controller.materialScrollStartPosition - controller.materialScrollController.offset > 75 - && controller.materialScrollController.position.userScrollDirection == ScrollDirection.forward - && !controller.showMaterialFABText) { - setState(() { - controller.showMaterialFABText = true; - }); - } - }); - ns.listener.stream.listen((event) { + controller.materialScrollController.addListener(_handleMaterialScroll); + _avatarOnlySubscription = ns.listener.stream.listen((event) { if (!mounted) return; if (ns.isAvatarOnly(context) && controller.showMaterialFABText) { setState(() { @@ -61,6 +67,13 @@ class _ConversationListFABState extends CustomState Column( diff --git a/lib/app/layouts/conversation_view/pages/messages_view.dart b/lib/app/layouts/conversation_view/pages/messages_view.dart index 437b9e6b22..b3a8ec4e5b 100644 --- a/lib/app/layouts/conversation_view/pages/messages_view.dart +++ b/lib/app/layouts/conversation_view/pages/messages_view.dart @@ -141,6 +141,7 @@ class MessagesViewState extends OptimizedState { if (e.item1 == "refresh-messagebloc" && e.item2 == chat.guid) { // Clear state items noMoreMessages = false; + fetching = false; _messages = []; // Reload the state after refreshing messageService.reload(); @@ -293,28 +294,35 @@ class MessagesViewState extends OptimizedState { if (noMoreMessages || fetching) return; fetching = true; - // Start loading the next chunk of messages - noMoreMessages = !(await messageService.loadChunk(_messages.length, controller, limit: limit).catchError((e, stack) { - Logger.error("Failed to fetch message chunk!", error: e, trace: stack); - return true; - })); - - if (noMoreMessages) return setState(() {}); + try { + // Start loading the next chunk of messages + noMoreMessages = !(await messageService.loadChunk(_messages.length, controller, limit: limit).catchError((e, stack) { + Logger.error("Failed to fetch message chunk!", error: e, trace: stack); + return true; + })); - final oldLength = _messages.length; - _messages = messageService.struct.messages; - _messages.sort(Message.sort); - fetching = false; - _messages.sublist(max(oldLength - 1, 0)).forEachIndexed((i, m) { if (!mounted) return; - final c = mwc(m); - c.cvController = controller; - listKey.currentState!.insertItem(i, duration: const Duration(milliseconds: 0)); - }); - _syncBottomMessageFocusNode(); - // should only happen when a reaction is the most recent message - if (oldLength == 0) { - setState(() {}); + + if (noMoreMessages) { + setState(() {}); + return; + } + + final oldLength = _messages.length; + _messages = messageService.struct.messages; + _messages.sort(Message.sort); + _messages.sublist(max(oldLength - 1, 0)).forEachIndexed((i, m) { + final c = mwc(m); + c.cvController = controller; + listKey.currentState!.insertItem(i, duration: const Duration(milliseconds: 0)); + }); + _syncBottomMessageFocusNode(); + // should only happen when a reaction is the most recent message + if (oldLength == 0) { + setState(() {}); + } + } finally { + fetching = false; } } diff --git a/lib/app/layouts/settings/pages/theming/theming_panel.dart b/lib/app/layouts/settings/pages/theming/theming_panel.dart index 3ed3d92d0a..0926739cf6 100644 --- a/lib/app/layouts/settings/pages/theming/theming_panel.dart +++ b/lib/app/layouts/settings/pages/theming/theming_panel.dart @@ -463,7 +463,7 @@ class _ThemingPanelState extends CustomState 2) { + if (controller.refreshRates.length > 1) { return SettingsHeader( iosSubtitle: iosSubtitle, materialSubtitle: materialSubtitle, @@ -474,7 +474,7 @@ class _ThemingPanelState extends CustomState 2) { + if (controller.refreshRates.length > 1) { return SettingsSection( backgroundColor: tileColor, children: [ diff --git a/lib/app/layouts/setup/pages/rustpush/hw_inp.dart b/lib/app/layouts/setup/pages/rustpush/hw_inp.dart index be084b0560..4e61bf2106 100644 --- a/lib/app/layouts/setup/pages/rustpush/hw_inp.dart +++ b/lib/app/layouts/setup/pages/rustpush/hw_inp.dart @@ -43,6 +43,8 @@ class HwInpState extends OptimizedState { final TextEditingController hostedCodeController = TextEditingController(); final controller = Get.find(); final FocusNode focusNode = FocusNode(); + late final VoidCallback _codeListener; + late final VoidCallback _hostedCodeListener; bool loading = false; bool hosted = true; @@ -181,6 +183,7 @@ class HwInpState extends OptimizedState { if (response.statusCode == 404) { showSnackbar("Fetching validation data", "Mac Offline"); + lastCheckedCode = ""; return; } parsed = await api.configFromValidationData(data: base64Decode(response.data["data"]), extra: api.HwExtra( @@ -199,6 +202,7 @@ class HwInpState extends OptimizedState { select(parsed, true); } catch (e) { showSnackbar("Fetching validation data", "Failed"); + lastCheckedCode = ""; rethrow; } } @@ -354,13 +358,21 @@ class HwInpState extends OptimizedState { } } + Future _checkCodeSafely(String text) async { + try { + await checkCode(text); + } catch (e, stack) { + Logger.error("Failed to check registration code", error: e, trace: stack); + } + } + void updateInitial() async { Logger.debug("updating app link"); final _appLinks = AppLinks(); var link = await _appLinks.getLatestLink(); if (link != null && link.toString().startsWith(rpApiRoot)) { - checkCode(link.toString()); + unawaited(_checkCodeSafely(link.toString())); } else { if (controller.config != null) { // restore @@ -455,8 +467,13 @@ class HwInpState extends OptimizedState { @override void dispose() { - super.dispose(); + codeController.removeListener(_codeListener); + hostedCodeController.removeListener(_hostedCodeListener); subscription?.cancel(); + codeController.dispose(); + hostedCodeController.dispose(); + focusNode.dispose(); + super.dispose(); } Future handlePurchases(PurchasesResultWrapper details) async { @@ -493,16 +510,18 @@ class HwInpState extends OptimizedState { } // Start listening to changes. - codeController.addListener(() async { - checkCode(codeController.text); - }); + _codeListener = () { + unawaited(_checkCodeSafely(codeController.text)); + }; + codeController.addListener(_codeListener); - hostedCodeController.addListener(() async { + _hostedCodeListener = () { if (hostedCodeController.text.length == 36 || hostedCodeController.text.length == 9) { controller.currentWaitlist = hostedCodeController.text; controller.updateIAPState(); } - }); + }; + hostedCodeController.addListener(_hostedCodeListener); } Widget materialButton(Widget inner, bool selected, void Function() onTap) { @@ -760,7 +779,7 @@ class HwInpState extends OptimizedState { textInputAction: TextInputAction.done, onSubmitted: (value) { lastCheckedCode = ""; - checkCode(codeController.text); + unawaited(_checkCodeSafely(codeController.text)); }, decoration: InputDecoration( enabledBorder: OutlineInputBorder( @@ -814,8 +833,8 @@ class HwInpState extends OptimizedState { server.text); lastCheckedCode = ""; Get.back(); - checkCode( - codeController.text); + unawaited(_checkCodeSafely( + codeController.text)); } on FormatException catch (e) { showSnackbar( "Invalid relay URL", From b576167db028eaa2d265d5cf7c1541cb0d46776d Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:26:44 -0700 Subject: [PATCH 06/33] Avoid eager full-resolution image decoding --- .../layouts/conversation_view/pages/conversation_view.dart | 2 ++ lib/services/ui/chat/conversation_view_controller.dart | 4 ---- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/app/layouts/conversation_view/pages/conversation_view.dart b/lib/app/layouts/conversation_view/pages/conversation_view.dart index bbe813269d..2d5ec90f7f 100644 --- a/lib/app/layouts/conversation_view/pages/conversation_view.dart +++ b/lib/app/layouts/conversation_view/pages/conversation_view.dart @@ -49,6 +49,8 @@ class ConversationViewState extends OptimizedState { cm.activeChat!.controller = controller; Logger.debug("Conversation View initialized for ${chat.guid}"); + controller.updatePoster(); + if (widget.onInit != null) { Future.delayed(Duration.zero, widget.onInit!); } diff --git a/lib/services/ui/chat/conversation_view_controller.dart b/lib/services/ui/chat/conversation_view_controller.dart index a26709b9cc..295f44b145 100644 --- a/lib/services/ui/chat/conversation_view_controller.dart +++ b/lib/services/ui/chat/conversation_view_controller.dart @@ -199,7 +199,6 @@ class ConversationViewController extends StatefulController with GetSingleTicker _subjectWasLastFocused = true; } }); - updatePoster(); } void updatePoster() async { @@ -301,9 +300,6 @@ class ConversationViewController extends StatefulController with GetSingleTicker return; } imageData[attachment.guid!] = tmpData; - try { - await precacheImage(MemoryImage(tmpData), queued.item3); - } catch (_) {} queued.item4.complete(tmpData); await _processNextImage(); From 7478ceb93954eb2d3a609ee0f3d72e8644984cb6 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:56:55 -0700 Subject: [PATCH 07/33] Improve Android control accessibility --- .../widgets/text_field/send_button.dart | 132 ++++++++++-------- .../widgets/text_field/text_field_suffix.dart | 63 +++++---- .../layouts/setup/pages/page_template.dart | 19 ++- 3 files changed, 122 insertions(+), 92 deletions(-) diff --git a/lib/app/layouts/conversation_view/widgets/text_field/send_button.dart b/lib/app/layouts/conversation_view/widgets/text_field/send_button.dart index ae8e817406..56357268d6 100644 --- a/lib/app/layouts/conversation_view/widgets/text_field/send_button.dart +++ b/lib/app/layouts/conversation_view/widgets/text_field/send_button.dart @@ -44,6 +44,7 @@ class SendButtonState extends OptimizedState with SingleTickerProvid @override Widget build(BuildContext context) { + final tapTargetSize = iOS || kIsDesktop ? 28.0 : 48.0; return GestureDetector( onSecondaryTap: () { if (controller.isAnimating) { @@ -76,69 +77,78 @@ class SendButtonState extends OptimizedState with SingleTickerProvid widget.sendMessage.call(); } }, - child: TextButton( - style: TextButton.styleFrom( - backgroundColor: iOS ? context.theme.colorScheme.primary : null, - shape: const CircleBorder(), - padding: const EdgeInsets.all(0), - maximumSize: const Size(28, 28), - minimumSize: const Size(28, 28), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: AnimatedBuilder( - animation: controller, - builder: (context, widget) { - return Container( - constraints: const BoxConstraints(minHeight: 28, minWidth: 28), - decoration: BoxDecoration( - shape: iOS ? BoxShape.circle : BoxShape.rectangle, - borderRadius: iOS ? null : BorderRadius.circular(10), - gradient: iOS || controller.value != 0 - ? LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - colors: [ - baseColor, - baseColor, - context.theme.colorScheme.error, - context.theme.colorScheme.error - ], - stops: [0.0, 1 - controller.value, 1 - controller.value, 1.0], - ) - : null), - alignment: Alignment.center, - child: Icon( - controller.value == 0 - ? (iOS ? CupertinoIcons.arrow_up : Icons.send_outlined) - : (iOS ? CupertinoIcons.xmark : Icons.close), - color: controller.value == 0 - ? (iOS ? context.theme.colorScheme.onPrimary : context.theme.colorScheme.secondary) - : context.theme.colorScheme.onError, - size: iOS || controller.value != 0 ? 20 : 28, + child: Tooltip( + message: "Send message", + child: Semantics( + button: true, + label: "Send message", + tooltip: "Send message", + child: TextButton( + style: TextButton.styleFrom( + backgroundColor: iOS ? context.theme.colorScheme.primary : null, + shape: const CircleBorder(), + padding: const EdgeInsets.all(0), + maximumSize: Size(tapTargetSize, tapTargetSize), + minimumSize: Size(tapTargetSize, tapTargetSize), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: AnimatedBuilder( + animation: controller, + builder: (context, widget) { + return Container( + constraints: const BoxConstraints(minHeight: 28, minWidth: 28), + decoration: BoxDecoration( + shape: iOS ? BoxShape.circle : BoxShape.rectangle, + borderRadius: iOS ? null : BorderRadius.circular(10), + gradient: iOS || controller.value != 0 + ? LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + baseColor, + baseColor, + context.theme.colorScheme.error, + context.theme.colorScheme.error, + ], + stops: [0.0, 1 - controller.value, 1 - controller.value, 1.0], + ) + : null, + ), + alignment: Alignment.center, + child: Icon( + controller.value == 0 + ? (iOS ? CupertinoIcons.arrow_up : Icons.send_outlined) + : (iOS ? CupertinoIcons.xmark : Icons.close), + color: controller.value == 0 + ? (iOS ? context.theme.colorScheme.onPrimary : context.theme.colorScheme.secondary) + : context.theme.colorScheme.onError, + size: iOS || controller.value != 0 ? 20 : 28, + ), + ); + }, + ), + onPressed: () { + if (controller.isAnimating) { + controller.reset(); + } else if (ss.settings.sendDelay.value != 0) { + controller.forward(); + } else { + HapticFeedback.lightImpact(); + widget.sendMessage.call(); + } + }, + onLongPress: () { + if (controller.isAnimating) { + controller.reset(); + } else { + widget.onLongPress.call(); + } + }, ), - ); - }, - ), - onPressed: () { - if (controller.isAnimating) { - controller.reset(); - } else if (ss.settings.sendDelay.value != 0) { - controller.forward(); - } else { - HapticFeedback.lightImpact(); - widget.sendMessage.call(); - } - }, - onLongPress: () { - if (controller.isAnimating) { - controller.reset(); - } else { - widget.onLongPress.call(); - } - }, + ), + ), ), - ) - ) + ), ); } } diff --git a/lib/app/layouts/conversation_view/widgets/text_field/text_field_suffix.dart b/lib/app/layouts/conversation_view/widgets/text_field/text_field_suffix.dart index 29a881cd3c..139c5182a3 100644 --- a/lib/app/layouts/conversation_view/widgets/text_field/text_field_suffix.dart +++ b/lib/app/layouts/conversation_view/widgets/text_field/text_field_suffix.dart @@ -233,6 +233,7 @@ class _TextFieldSuffixState extends OptimizedState { (widget.controller?.pickedAttachments.isNotEmpty ?? false.obs.value); bool showRecording = (widget.controller?.showRecording.value ?? false.obs.value) && widget.recorderController != null; bool isLinuxArm64 = kIsDesktop && Platform.isLinux && SysInfo.kernelArchitecture == ProcessorArchitecture.arm64; + final recordTapTargetSize = kIsDesktop || iOS ? (kIsDesktop ? 40.0 : 32.0) : 48.0; return Padding( padding: const EdgeInsets.all(3.0), child: AnimatedCrossFade( @@ -255,31 +256,45 @@ class _TextFieldSuffixState extends OptimizedState { toggleRecording(context); }, }, - child: TextButton( - style: TextButton.styleFrom( - backgroundColor: !iOS || (iOS && !isChatCreator && !showRecording) - ? null - : !isChatCreator && !showRecording - ? context.theme.colorScheme.outline - : context.theme.colorScheme.primary.withOpacity(0.4), - shape: const CircleBorder(), - padding: const EdgeInsets.all(0), - maximumSize: kIsDesktop ? const Size(40, 40) : const Size(32, 32), - minimumSize: kIsDesktop ? const Size(40, 40) : const Size(32, 32), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, + child: Tooltip( + message: showRecording ? "Stop recording audio" : "Record audio", + child: Semantics( + button: true, + label: showRecording ? "Stop recording audio" : "Record audio", + tooltip: showRecording ? "Stop recording audio" : "Record audio", + child: TextButton( + style: TextButton.styleFrom( + backgroundColor: !iOS || (iOS && !isChatCreator && !showRecording) + ? null + : !isChatCreator && !showRecording + ? context.theme.colorScheme.outline + : context.theme.colorScheme.primary.withOpacity(0.4), + shape: const CircleBorder(), + padding: const EdgeInsets.all(0), + maximumSize: Size(recordTapTargetSize, recordTapTargetSize), + minimumSize: Size(recordTapTargetSize, recordTapTargetSize), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: isLinuxArm64 + ? const SizedBox(height: 40) + : !isChatCreator && !showRecording + ? CupertinoIconWrapper( + icon: Icon( + iOS ? CupertinoIcons.waveform : Icons.mic_none, + color: iOS ? context.theme.colorScheme.outline : context.theme.colorScheme.properOnSurface, + size: iOS ? 24 : 20, // Waveform icon appears smaller, using size 24 + ), + ) + : CupertinoIconWrapper( + icon: Icon( + iOS ? CupertinoIcons.stop_fill : Icons.stop_circle, + color: iOS ? context.theme.colorScheme.primary : context.theme.colorScheme.properOnSurface, + size: 15, + ), + ), + onPressed: () async => toggleRecording(context), + ), ), - child: isLinuxArm64 ? const SizedBox(height: 40) : - !isChatCreator && !showRecording - ? CupertinoIconWrapper(icon: Icon( - iOS ? CupertinoIcons.waveform : Icons.mic_none, - color: iOS ? context.theme.colorScheme.outline : context.theme.colorScheme.properOnSurface, - size: iOS ? 24 : 20, // Waveform icon appears smaller, using size 24 - )) : CupertinoIconWrapper(icon: Icon( - iOS ? CupertinoIcons.stop_fill : Icons.stop_circle, - color: iOS ? context.theme.colorScheme.primary : context.theme.colorScheme.properOnSurface, - size: 15, - )), - onPressed: () async => toggleRecording(context), ), ), secondChild: SendButton( diff --git a/lib/app/layouts/setup/pages/page_template.dart b/lib/app/layouts/setup/pages/page_template.dart index d2c7d867a2..d996d522bf 100644 --- a/lib/app/layouts/setup/pages/page_template.dart +++ b/lib/app/layouts/setup/pages/page_template.dart @@ -181,6 +181,11 @@ class PageButtons extends StatelessWidget { @override Widget build(BuildContext context) { + final isAndroid = Theme.of(context).platform == TargetPlatform.android; + final navigationButtonHeight = isAndroid ? 48.0 : 40.0; + final navigationButtonPadding = isAndroid ? EdgeInsets.zero : const EdgeInsets.all(2); + final navigationButtonContentHeight = isAndroid ? 48.0 : 36.0; + final navigationButtonMinimumWidth = isAndroid ? 48.0 : 30.0; return customButton ?? Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -192,8 +197,8 @@ class PageButtons extends StatelessWidget { colors: [HexColor('2772C3'), HexColor('5CA7F8').darkenPercent(5)], ), ), - height: 40, - padding: const EdgeInsets.all(2), + height: navigationButtonHeight, + padding: navigationButtonPadding, child: ElevatedButton( style: ButtonStyle( shape: WidgetStateProperty.all( @@ -203,8 +208,8 @@ class PageButtons extends StatelessWidget { ), backgroundColor: WidgetStateProperty.all(context.theme.colorScheme.background), shadowColor: WidgetStateProperty.all(context.theme.colorScheme.background), - maximumSize: WidgetStateProperty.all(const Size(200, 36)), - minimumSize: WidgetStateProperty.all(const Size(30, 30)), + maximumSize: WidgetStateProperty.all(Size(200, navigationButtonContentHeight)), + minimumSize: WidgetStateProperty.all(Size(navigationButtonMinimumWidth, navigationButtonContentHeight)), ), onPressed: () async { previousPage(); @@ -227,7 +232,7 @@ class PageButtons extends StatelessWidget { colors: [HexColor('2772C3'), HexColor('5CA7F8').darkenPercent(5)], ), ), - height: 40, + height: navigationButtonHeight, child: ElevatedButton( style: ButtonStyle( shape: WidgetStateProperty.all( @@ -237,8 +242,8 @@ class PageButtons extends StatelessWidget { ), backgroundColor: WidgetStateProperty.all(Colors.transparent), shadowColor: WidgetStateProperty.all(Colors.transparent), - maximumSize: WidgetStateProperty.all(const Size(200, 36)), - minimumSize: WidgetStateProperty.all(const Size(30, 30)), + maximumSize: WidgetStateProperty.all(Size(200, navigationButtonContentHeight)), + minimumSize: WidgetStateProperty.all(Size(navigationButtonMinimumWidth, navigationButtonContentHeight)), ), onPressed: () async { final proceed = (await onNextPressed?.call()) ?? true; From 9cf10343621b2a1e497777fbee721a55309f2a4c Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:42:41 -0700 Subject: [PATCH 08/33] Build resilient Alpha profile APK --- .github/workflows/build.yml | 15 +++++++++++---- .gitmodules | 2 +- rustpush | 2 +- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f118701ddf..f95a17968d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -65,11 +65,18 @@ jobs: done # First run is expected to fail until ffmpeg_kit_flutter_new is fixed. - - name: Run Build Script - run: | - flutter build apk --flavor alpha --debug --target-platform android-arm64 + - name: Build Alpha Debug APK + run: flutter build apk --flavor alpha --debug --target-platform android-arm64 - uses: actions/upload-artifact@v4 with: name: Alpha Debug APK - path: build/app/outputs/flutter-apk/app-alpha-debug.apk \ No newline at end of file + path: build/app/outputs/flutter-apk/app-alpha-debug.apk + + - name: Build Alpha Profile APK + run: flutter build apk --flavor alpha --profile --target-platform android-arm64 + + - uses: actions/upload-artifact@v4 + with: + name: Alpha Profile APK + path: build/app/outputs/flutter-apk/app-alpha-profile.apk diff --git a/.gitmodules b/.gitmodules index 9b859a75b8..8f97a8c2fc 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ [submodule "rustpush"] path = rustpush - url = git@github.com:OpenBubbles/rustpush.git + url = https://github.com/Xare123/rustpush.git [submodule "telephony_plus"] path = telephony_plus url = git@github.com:OpenBubbles/telephony_plus.git diff --git a/rustpush b/rustpush index a7fab473e7..f03de54cbf 160000 --- a/rustpush +++ b/rustpush @@ -1 +1 @@ -Subproject commit a7fab473e7a33325a760635285db2860de8e1cb0 +Subproject commit f03de54cbf6f7a9ed61ccf40e7d5f9d9723df4f0 From 95ef27712a4d259d0e1bd1d125b0f98db418e671 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:54:34 -0700 Subject: [PATCH 09/33] Prioritize Alpha profile artifact --- .github/workflows/build.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f95a17968d..902c34eb0d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -65,14 +65,6 @@ jobs: done # First run is expected to fail until ffmpeg_kit_flutter_new is fixed. - - name: Build Alpha Debug APK - run: flutter build apk --flavor alpha --debug --target-platform android-arm64 - - - uses: actions/upload-artifact@v4 - with: - name: Alpha Debug APK - path: build/app/outputs/flutter-apk/app-alpha-debug.apk - - name: Build Alpha Profile APK run: flutter build apk --flavor alpha --profile --target-platform android-arm64 @@ -80,3 +72,11 @@ jobs: with: name: Alpha Profile APK path: build/app/outputs/flutter-apk/app-alpha-profile.apk + + - name: Build Alpha Debug APK + run: flutter build apk --flavor alpha --debug --target-platform android-arm64 + + - uses: actions/upload-artifact@v4 + with: + name: Alpha Debug APK + path: build/app/outputs/flutter-apk/app-alpha-debug.apk From ad26128424d6ed16165207a6d692a978ac112c94 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:54:48 -0700 Subject: [PATCH 10/33] Remove workflow trailing whitespace --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 902c34eb0d..949ae671e8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -75,7 +75,7 @@ jobs: - name: Build Alpha Debug APK run: flutter build apk --flavor alpha --debug --target-platform android-arm64 - + - uses: actions/upload-artifact@v4 with: name: Alpha Debug APK From e6e284cf490a677f4b933e9796b53879fc59cbf9 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:15:53 -0700 Subject: [PATCH 11/33] Compile resilient anisette provisioning --- rustpush | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rustpush b/rustpush index f03de54cbf..82dfa0100d 160000 --- a/rustpush +++ b/rustpush @@ -1 +1 @@ -Subproject commit f03de54cbf6f7a9ed61ccf40e7d5f9d9723df4f0 +Subproject commit 82dfa0100db8fdd42a07873ba25869fc44518d4f From 018bd0164ced6eab669818d379db10cd6f27996e Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:20:35 -0700 Subject: [PATCH 12/33] Retry anisette transport failures --- rustpush | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rustpush b/rustpush index 82dfa0100d..38ddc729fc 160000 --- a/rustpush +++ b/rustpush @@ -1 +1 @@ -Subproject commit 82dfa0100db8fdd42a07873ba25869fc44518d4f +Subproject commit 38ddc729fca8fccb33d48a70a43ee51a50524432 From 1fc63f0bd62dd6f5ee3fa760159bbefa0e1aec0d Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:44:17 -0700 Subject: [PATCH 13/33] Harden message refresh and receive diagnostics --- .../widgets/tile/conversation_tile.dart | 45 +++++++++-- .../pages/messages_view.dart | 76 ++++++++++++++++--- lib/main.dart | 14 +++- lib/services/rustpush/rustpush_service.dart | 45 +++++++---- 4 files changed, 146 insertions(+), 34 deletions(-) diff --git a/lib/app/layouts/conversation_list/widgets/tile/conversation_tile.dart b/lib/app/layouts/conversation_list/widgets/tile/conversation_tile.dart index e92f0ca0ce..ecde7c2b18 100644 --- a/lib/app/layouts/conversation_list/widgets/tile/conversation_tile.dart +++ b/lib/app/layouts/conversation_list/widgets/tile/conversation_tile.dart @@ -459,6 +459,26 @@ class _ChatSubtitleState extends CustomState { bool initialized = false; bool fetching = false; + bool _refreshing = false; late bool noMoreMessages = widget.customService != null; List _messages = []; RxList smartReplies = [].obs; RxMap internalSmartReplies = {}.obs; - late final messageService = widget.customService ?? ms(chat.guid) - ..init(chat, handleNewMessage, handleUpdatedMessage, handleDeletedMessage, jumpToMessage); + late MessagesService messageService; final smartReply = GoogleMlKit.nlp.smartReply(); final listKey = GlobalKey(); final RxBool dragging = false.obs; @@ -135,18 +135,13 @@ class MessagesViewState extends OptimizedState { @override void initState() { super.initState(); + messageService = widget.customService ?? ms(chat.guid); + messageService.init(chat, handleNewMessage, handleUpdatedMessage, handleDeletedMessage, jumpToMessage); _eventSubscription = eventDispatcher.stream.listen((e) async { if (!mounted) return; if (e.item1 == "refresh-messagebloc" && e.item2 == chat.guid) { - // Clear state items - noMoreMessages = false; - fetching = false; - _messages = []; - // Reload the state after refreshing - messageService.reload(); - messageService.init(chat, handleNewMessage, handleUpdatedMessage, handleDeletedMessage, jumpToMessage); - setState(() {}); + await _refreshMessageBloc(); } else if (e.item1 == "add-custom-smartreply") { if (e.item2 != null && internalSmartReplies['attach-recent'] == null) { internalSmartReplies['attach-recent'] = _buildReply("Attach recent photo", onTap: () async { @@ -201,6 +196,67 @@ class MessagesViewState extends OptimizedState { }); } + void _closeMessageControllers(Iterable messages) { + for (final message in messages) { + final guid = message.guid; + if (guid != null) getActiveMwc(guid)?.close(); + } + } + + void _bindMessageControllers(Iterable messages) { + if (!mounted) return; + for (final message in messages) { + if (message.guid == null) continue; + final messageController = mwc(message); + messageController.cvController = controller; + } + } + + Future _refreshMessageBloc() async { + if (_refreshing) return; + _refreshing = true; + try { + final staleMessages = List.from(_messages); + _closeMessageControllers(staleMessages); + for (var index = _messages.length - 1; index >= 0; index--) { + listKey.currentState?.removeItem( + index, + (context, animation) => const SizedBox.shrink(), + duration: Duration.zero, + ); + } + for (final node in messageFocusNodes.values) { + node.dispose(); + } + messageFocusNodes.clear(); + + noMoreMessages = false; + fetching = false; + _messages = []; + + // Re-acquire the service after its GetX reload so the view does not + // continue using stale callbacks while the transcript is repopulated. + messageService.reload(); + if (widget.customService == null) { + messageService = ms(chat.guid); + } + messageService.init(chat, handleNewMessage, handleUpdatedMessage, handleDeletedMessage, jumpToMessage); + await messageService.loadChunk(0, controller); + if (!mounted) return; + + _messages = List.from(messageService.struct.messages); + _messages.sort(Message.sort); + _bindMessageControllers(_messages); + _syncBottomMessageFocusNode(); + setState(() {}); + for (var index = 0; index < _messages.length; index++) { + listKey.currentState?.insertItem(index, duration: Duration.zero); + } + } finally { + _refreshing = false; + } + } + @override void dispose() { _eventSubscription.cancel(); diff --git a/lib/main.dart b/lib/main.dart index 1db315d28d..f86548e095 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -56,6 +56,16 @@ var usingRustPush = true; bool isAuthing = false; final systemTray = st.SystemTray(); +String _renderIncidentId() => Random.secure().nextInt(0x7fffffff).toRadixString(16).padLeft(8, '0'); + +void _logRenderError(FlutterErrorDetails details) { + final incidentId = _renderIncidentId(); + Logger.error( + "Render error incident=$incidentId exceptionType=${details.exception.runtimeType}", + trace: details.stack, + ); +} + @pragma('vm:entry-point') //ignore: prefer_void_to_null Future main(List arguments) async { @@ -82,7 +92,7 @@ Future initApp(bool bubble, List arguments) async { StackTrace? stacktrace; FlutterError.onError = (details) { - Logger.error("Rendering Error: ${details.exceptionAsString()}", error: details.exception, trace: details.stack); + _logRenderError(details); }; try { @@ -465,7 +475,7 @@ class _HomeState extends OptimizedState with WidgetsBindingObserver, TrayL } ErrorWidget.builder = (FlutterErrorDetails error) { - Logger.error("An unexpected error occurred when rendering.", error: error.exception, trace: error.stack); + _logRenderError(error); return CustomErrorWidget( "An unexpected error occurred when rendering.", ); diff --git a/lib/services/rustpush/rustpush_service.dart b/lib/services/rustpush/rustpush_service.dart index 859f4a6dc6..4dfa94c4aa 100644 --- a/lib/services/rustpush/rustpush_service.dart +++ b/lib/services/rustpush/rustpush_service.dart @@ -66,6 +66,10 @@ const registrationRelayAccessToken = const clientId = '1041242226917-ik21n86fp43e82iu1e5soh6bu6gvuste.apps.googleusercontent.com'; const clientSecret = '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'; +String _diagnosticHash(String value) => sha256.convert(utf8.encode(value)).toString().substring(0, 12); + +String _durationMs(Stopwatch stopwatch) => stopwatch.elapsedMilliseconds.toString(); + class SyncIsolate { static void initialize() { @@ -4043,16 +4047,20 @@ class RustPushService extends GetxService { return; } } - Logger.info("Reflecting ${myMsg.id}"); + final receiveStopwatch = Stopwatch()..start(); + final receiveId = _diagnosticHash(myMsg.id); + Logger.info("rustpush_receive reflection_start id=$receiveId"); var reflected = await pushService.reflectMessageDyn(myMsg); - Logger.info("Reflect finished ${myMsg.id}"); + Logger.info("rustpush_receive reflection_complete id=$receiveId duration_ms=${_durationMs(receiveStopwatch)} reflected=${reflected != null}"); if (reflected != null) { - Logger.info("Queing"); + final queueStopwatch = Stopwatch()..start(); + Logger.info("rustpush_receive incoming_queue_enqueue id=$receiveId pending_count=${inq.items.length}"); await inq.queue(IncomingItem( chat: chat, message: reflected, type: QueueType.newMessage )); + Logger.info("rustpush_receive incoming_queue_complete id=$receiveId duration_ms=${_durationMs(queueStopwatch)} pending_count=${inq.items.length}"); } } @@ -4706,36 +4714,45 @@ class RustPushService extends GetxService { } } - Future markAsHandledAfter(String ptr) async { + Future markAsHandledAfter(String ptr, {required String eventId, required int retry}) async { + final ackStopwatch = Stopwatch()..start(); if (inq.isProcessing.value) { - Logger.info("Marking as handled processing wait $ptr"); + Logger.info("rustpush_receive ack_wait_start id=$eventId retry=$retry pending_count=${inq.items.length}"); await for (final value in inq.isProcessing.stream) { if (!value) break; } } - Logger.info("Marking as handled commit $ptr"); + Logger.info("rustpush_receive save_and_queue_drained id=$eventId retry=$retry wait_ms=${_durationMs(ackStopwatch)} pending_count=${inq.items.length}"); + Logger.info("rustpush_receive ack_commit id=$eventId retry=$retry"); await api.completeMsg(ptr: ptr); + Logger.info("rustpush_receive ack_complete id=$eventId retry=$retry duration_ms=${_durationMs(ackStopwatch)}"); } Future recievedMsgPointer(String pointer, String retry) async { + final eventId = _diagnosticHash(pointer); + final retryCount = int.tryParse(retry) ?? 3; + final receiveStopwatch = Stopwatch()..start(); var message = await api.ptrToDart(ptr: pointer); if (message == null) { - Logger.info("bad pointer $pointer $retry"); + Logger.info("rustpush_receive pointer_missing id=$eventId retry=$retryCount"); return; } - Logger.info("waitingForInit $pointer $retry"); + final initStopwatch = Stopwatch()..start(); + Logger.info("rustpush_receive aps_init_wait_start id=$eventId retry=$retryCount"); await initFuture; - var isFinal = (int.tryParse(retry) ?? 3) >= 3; + Logger.info("rustpush_receive aps_init_wait_complete id=$eventId retry=$retryCount duration_ms=${_durationMs(initStopwatch)} total_ms=${_durationMs(receiveStopwatch)}"); + var isFinal = retryCount >= 3; try { - Logger.info("Handling $pointer $retry"); + final handlingStopwatch = Stopwatch()..start(); + Logger.info("rustpush_receive handle_start id=$eventId retry=$retryCount"); await handleMsg(message, isFinal); - Logger.info("Marking as handled $pointer"); - await markAsHandledAfter(pointer); + Logger.info("rustpush_receive handle_complete id=$eventId retry=$retryCount duration_ms=${_durationMs(handlingStopwatch)} total_ms=${_durationMs(receiveStopwatch)}"); + await markAsHandledAfter(pointer, eventId: eventId, retry: retryCount); } catch (e, s) { Logger.error("Handle failed", error: e, trace: s); if (isFinal) { - Logger.info("Failed; Marking as handled anyways $pointer"); - await markAsHandledAfter(pointer); + Logger.info("rustpush_receive final_attempt_ack id=$eventId retry=$retryCount"); + await markAsHandledAfter(pointer, eventId: eventId, retry: retryCount); } rethrow; } From b2c818421a4f1a510c1dc2cf54480f4253d130ed Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:46:20 -0700 Subject: [PATCH 14/33] Use fresh message service on transcript refresh --- .../conversation_view/pages/messages_view.dart | 15 +++++++++------ lib/main.dart | 3 ++- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/lib/app/layouts/conversation_view/pages/messages_view.dart b/lib/app/layouts/conversation_view/pages/messages_view.dart index 1b3aab3c3e..f0398aa9ce 100644 --- a/lib/app/layouts/conversation_view/pages/messages_view.dart +++ b/lib/app/layouts/conversation_view/pages/messages_view.dart @@ -214,6 +214,10 @@ class MessagesViewState extends OptimizedState { Future _refreshMessageBloc() async { if (_refreshing) return; + if (widget.customService != null) { + Logger.info("message_refresh skipped_custom_service"); + return; + } _refreshing = true; try { final staleMessages = List.from(_messages); @@ -234,12 +238,11 @@ class MessagesViewState extends OptimizedState { fetching = false; _messages = []; - // Re-acquire the service after its GetX reload so the view does not - // continue using stale callbacks while the transcript is repopulated. - messageService.reload(); - if (widget.customService == null) { - messageService = ms(chat.guid); - } + // Get.reload rebuilds the original Get.put instance. Close it instead + // so its subscriptions and in-memory message structure are flushed + // before registering a genuinely new service for this transcript. + messageService.close(force: true); + messageService = ms(chat.guid); messageService.init(chat, handleNewMessage, handleUpdatedMessage, handleDeletedMessage, jumpToMessage); await messageService.loadChunk(0, controller); if (!mounted) return; diff --git a/lib/main.dart b/lib/main.dart index f86548e095..cebeb8db64 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -475,7 +475,8 @@ class _HomeState extends OptimizedState with WidgetsBindingObserver, TrayL } ErrorWidget.builder = (FlutterErrorDetails error) { - _logRenderError(error); + // FlutterError.onError above records the incident. Logging here would + // produce a second incident ID for the same rendering failure. return CustomErrorWidget( "An unexpected error occurred when rendering.", ); From c01e6b7954fef071b5bf55c050e409da73f985da Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:47:33 -0700 Subject: [PATCH 15/33] Fix nullable reaction preview fallback --- .../widgets/tile/conversation_tile.dart | 7 +++---- lib/helpers/types/helpers/message_helper.dart | 12 ++++++++++-- test/helpers/message_helper_test.dart | 15 +++++++++++++++ 3 files changed, 28 insertions(+), 6 deletions(-) create mode 100644 test/helpers/message_helper_test.dart diff --git a/lib/app/layouts/conversation_list/widgets/tile/conversation_tile.dart b/lib/app/layouts/conversation_list/widgets/tile/conversation_tile.dart index ecde7c2b18..e85ee92f4a 100644 --- a/lib/app/layouts/conversation_list/widgets/tile/conversation_tile.dart +++ b/lib/app/layouts/conversation_list/widgets/tile/conversation_tile.dart @@ -460,13 +460,12 @@ class _ChatSubtitleState extends CustomState attachments) { Map counts = {}; diff --git a/test/helpers/message_helper_test.dart b/test/helpers/message_helper_test.dart new file mode 100644 index 0000000000..76f318f2c4 --- /dev/null +++ b/test/helpers/message_helper_test.dart @@ -0,0 +1,15 @@ +import 'package:bluebubbles/helpers/types/helpers/message_helper.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('MessageHelper.getReactionFallbackText', () { + test('uses a human-safe reaction label for missing text', () { + expect(MessageHelper.getReactionFallbackText('Someone', null), 'Someone reacted to a message'); + expect(MessageHelper.getReactionFallbackText('Someone', ' '), 'Someone reacted to a message'); + }); + + test('preserves a populated fallback reaction text', () { + expect(MessageHelper.getReactionFallbackText('Someone', 'liked a message'), 'Someone liked a message'); + }); + }); +} From dc2bd90aa86d6d4b99764c0308cc13edd3a909b4 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:54:10 -0700 Subject: [PATCH 16/33] Run focused message helper test --- .github/workflows/build.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 949ae671e8..7b0cc4f348 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -64,6 +64,9 @@ jobs: cp rustpush/certs/legacy-fairplay/fairplay.crt rustpush/certs/fairplay/$name.crt done + - name: Run focused message helper tests + run: flutter test test/helpers/message_helper_test.dart + # First run is expected to fail until ffmpeg_kit_flutter_new is fixed. - name: Build Alpha Profile APK run: flutter build apk --flavor alpha --profile --target-platform android-arm64 From f8fe0e253f29bdfa28f2f5b9ec47ec861f7d1920 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:27:36 -0700 Subject: [PATCH 17/33] docs: add Android development and diagnostics guide --- CONTRIBUTING.md | 7 ++- README.md | 8 ++++ docs/DEVELOPMENT.md | 111 ++++++++++++++++++++++++++++++++++++++++++++ docs/DIAGNOSTICS.md | 99 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 docs/DEVELOPMENT.md create mode 100644 docs/DIAGNOSTICS.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index db0adb5686..92b78de3c9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ We encourage all contributions to this project! All we ask are you follow these Please make sure you have completed the following pre-requisites: * Install Git: [download](https://git-scm.com/downloads) -* Install Java: [download](https://www.oracle.com/java/technologies/javase/javase-jdk8-downloads.html) +* Install Java 21: [download](https://adoptium.net/temurin/releases/?version=21) * Install Flutter: [guide/download](https://flutter.dev/docs/get-started/install) * Install Android Studio [download](https://developer.android.com/studio) - Also install the Flutter & Dart Plugins via the Plugin Manager @@ -26,6 +26,11 @@ Once you have a code editor installed, remember to install all of the required p * Flutter * Intellisense/Intellicode +Before opening a pull request, read the repository-specific +[development and diagnostics notes](docs/DEVELOPMENT.md). Do not commit relay +registration codes, Apple credentials, phone numbers, message text, generated +signing files, or `.env` values. + ## Forking the Repository In order to start contributing, follow these steps: diff --git a/README.md b/README.md index 7daf5ca364..8f64968c5b 100644 --- a/README.md +++ b/README.md @@ -34,3 +34,11 @@ If you need help setting up the app, have any issues or feature requests, or jus ## Getting Started [Quickstart](https://openbubbles.app/quickstart.html) + +## Contributor documentation + +The repository-specific development and diagnostic notes are in +[`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md) and +[`docs/DIAGNOSTICS.md`](docs/DIAGNOSTICS.md). They document the Android build +matrix, the iMessage/relay versus SMS/MMS/RCS boundary, safe log collection, +and the current known limitations. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000000..de0276d112 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,111 @@ +# OpenBubbles Android development + +This document describes the supported local workflow for the Flutter Android +client. It is intentionally separate from the end-user setup guide: a local +build is useful for testing, but it does not replace a trusted relay, Apple +device, or production signing configuration. + +## Scope and message routing + +OpenBubbles is the iMessage client. The relay or Mac/iPhone side handles the +Apple service connection; the Android app renders conversations, sends user +actions, persists local state, and receives relay events. + +For a predictable test environment, keep the routing boundary explicit: + +- iMessage traffic stays in OpenBubbles and its configured relay. +- SMS, MMS, and RCS stay in the device's default Google Messages app unless a + test specifically targets forwarding. +- Do not enable Google Messages and OpenBubbles SMS forwarding at the same time + during a delivery test. Two active paths can create duplicates, reorder + messages, or make a successful delivery look lost. + +This boundary is a diagnostic control, not a claim that every carrier or relay +configuration behaves identically. + +## Toolchain + +The current CI workflow is the source of truth for the tested build matrix: + +- Flutter 3.24.0, stable channel +- Dart SDK supplied by that Flutter release +- Rust stable for the Rust bridge and RustPush components +- Java 21 (Temurin in CI) +- Android SDK and command-line tools +- Protobuf compiler (`protoc`) + +The older Java 8 link in historical contribution notes is not the CI target. +Use the same major Java version as CI when diagnosing Gradle failures. + +## Local checkout and build + +Clone the repository with submodules, then install dependencies: + +```bash +git clone --recurse-submodules +cd openbubbles-app +flutter pub get +``` + +Run a focused test before a full build: + +```bash +flutter test test/helpers/message_helper_test.dart +``` + +The CI workflow builds unsigned arm64 Alpha artifacts. The equivalent local +commands are: + +```bash +flutter build apk --flavor alpha --profile --target-platform android-arm64 +flutter build apk --flavor alpha --debug --target-platform android-arm64 +``` + +Use a release build for performance measurements. Do not compare a debug build +with a store release and attribute every frame difference to application code. +Generated files, signing keys, `.env` values, relay registration codes, and +Apple credentials must not be committed. + +## Change and review workflow + +1. Start from a clean branch based on the intended upstream branch. +2. Make one narrow change per branch where practical. +3. Add or update a focused test for message parsing, routing, or state changes. +4. Run the focused test and the relevant Flutter analyzer/build locally. +5. Describe the user-visible behavior, failure mode, and test evidence in the PR. +6. Keep performance claims tied to a reproducible device, build mode, and test + scenario. + +Changes that affect both the Android client and ValidationRelay should be +reviewed as a coordinated pair. The Android client must tolerate relay +disconnects and malformed responses; the relay must not log or persist secrets. + +## Safe diagnostics + +Enable the app's Developer Mode only for a controlled reproduction. Capture a +short window around one send or receive operation, then redact or remove the +capture before sharing it publicly. See [`DIAGNOSTICS.md`](DIAGNOSTICS.md) for +the collection checklist and known failure classes. + +Never include registration codes, registration secrets, Apple IDs, phone +numbers, message text, attachment URLs, auth tokens, or full device identifiers +in an issue or pull request. A short-lived hash or local incident ID is enough +to correlate events. + +## Known limitations + +- Android background execution, Doze, OEM battery policies, and network path + changes can delay relay delivery even when the app code is healthy. +- CloudKit/Apple plist payloads can contain types that are not present in every + historical message. Decoding must fail closed and preserve the rest of the + sync rather than crashing the UI. +- Notifications can arrive with incomplete contact/group metadata. UI and + notification code must use a generic avatar fallback instead of throwing. +- Reaction events may race message persistence. A missing target should be + retried or ignored with bounded logging, not trigger an unbounded lookup loop. +- A green WebSocket connection only proves transport connectivity. It does not + prove that registration, validation, or message persistence succeeded. + +These are test boundaries, not promises of feature support. Record the exact +device, Android version, build variant, relay, and network when reporting a +failure. diff --git a/docs/DIAGNOSTICS.md b/docs/DIAGNOSTICS.md new file mode 100644 index 0000000000..179146afc5 --- /dev/null +++ b/docs/DIAGNOSTICS.md @@ -0,0 +1,99 @@ +# Diagnostics and delivery troubleshooting + +Use this guide to collect evidence for slow delivery, duplicate messages, +rendering failures, battery drain, or incorrect routing. The goal is a small, +redacted reproduction, not a permanent verbose log. + +## Controlled reproduction + +1. Keep Google Messages as the default SMS app. +2. Turn off OpenBubbles SMS forwarding while testing iMessage delivery. +3. Turn on OpenBubbles Developer Mode. +4. Force-close and reopen the test build. +5. Send one uniquely identifiable test message in each direction. +6. Record the sender, receiver, network (Wi-Fi or cellular), and approximate + timestamps locally. Do not put message text or phone numbers in a public + issue. +7. Export only the relevant log window and remove credentials and personal + content before sharing. + +For Android-side timing, a developer can also collect a bounded window with +the package filter (replace the package if testing a different flavor): + +```bash +adb logcat -c +adb shell am force-stop com.bluebubbles.messaging.alpha +adb shell monkey -p com.bluebubbles.messaging.alpha 1 +# Reproduce one operation, then stop capture promptly. +adb logcat -d -v threadtime -t 2000 > openbubbles-log.txt +``` + +Do not attach an unfiltered `logcat` dump. It can contain notification text, +contact data, URLs, and platform identifiers. + +## What to look for + +### Slow or missing receives + +Trace the sequence, in order: + +1. relay/WebSocket receive +2. event parsing and validation +3. message-service queueing +4. database save/upsert +5. acknowledgement back to the relay +6. UI refresh and notification + +A transport connection without a database save is not a delivered message. +An acknowledgement before persistence can create a loss window after a process +restart. Any instrumentation should use a short redacted event ID and elapsed +milliseconds, never message text or a secret. + +### Duplicates or wrong app routing + +Check that only one SMS/MMS/RCS path is enabled. If both Google Messages and +OpenBubbles forwarding are enabled, disable forwarding and repeat the test. +For iMessage, verify that the event is not being inserted once from the live +stream and again from a refresh/reconnect path. Compare stable server/message +IDs rather than message text. + +### Battery drain + +Look for reconnect loops, unbounded timers, repeated database refreshes, or +notifications that fail and retry continuously. A healthy relay should use +bounded reconnect backoff and one active connection manager. Compare Android +battery statistics over the same time window with the test build stopped. + +### UI/rendering failures + +Repeated `LateError`, null avatar, or “unexpected error occurred when +rendering” entries indicate a UI data-shape problem. Capture the incident ID, +screen, and safe event timing. Do not work around a rendering failure by +silently dropping the entire conversation. + +### Registration and validation + +Registration input should not be decoded until it matches the complete expected +format. Validation errors should be surfaced as a bounded failure and retry, +not a tight loop. Relay registration secrets belong in Keychain on iOS and must +never appear in logs or shared preferences. + +## Current audit themes + +The recent field logs identified four recurring classes to keep covered by +tests and review: + +- notification avatar data can be incomplete; +- CloudKit plist decoding can receive an unexpected byte-array shape; +- reaction events can race message persistence; +- anisette/validation WebSockets can reset during provisioning. + +These are not all necessarily present in every build. When a fix is proposed, +include the before/after log counts and a focused test or reproduction. + +## Privacy and retention + +Keep raw captures in a local, access-controlled folder. Delete them when the +issue is closed. Redact before uploading to GitHub, Discord, or a bug tracker. +Never request or paste an Apple password, two-factor code, registration secret, +private key, or full device identifier into an issue. From 95c08b59c00f9ea5625f002878bf6d409cbd3c52 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:33:18 -0700 Subject: [PATCH 18/33] docs: define delivery and performance verification plan --- docs/VERIFICATION.md | 124 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 docs/VERIFICATION.md diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md new file mode 100644 index 0000000000..55a3a96e81 --- /dev/null +++ b/docs/VERIFICATION.md @@ -0,0 +1,124 @@ +# Delivery, routing, and performance verification + +Use this plan when comparing an app change against a known-good Alpha build. +It separates transport, persistence, rendering, and Android background work so +that a faster WebSocket does not hide a database or notification regression. + +## Reproducible build gate + +The repository CI workflow (`.github/workflows/build.yml`) currently uses: + +```text +Flutter 3.24.0 (stable) +Rust stable +Java 21 (Temurin) +Android SDK and protoc +``` + +Run the same commands locally from the repository root: + +```bash +flutter pub get +flutter test test/helpers/message_helper_test.dart +flutter analyze +flutter build apk --flavor alpha --profile --target-platform android-arm64 +flutter build apk --flavor alpha --debug --target-platform android-arm64 +``` + +The focused helper test is the required CI test today. `flutter analyze` is an +additional local gate for source changes. Do not call a build verified when the +machine has a different Flutter or Java major version. + +### Environment evidence from the Windows review host + +On 2026-07-24 this host had `adb 37.0.0` and Java 8, but `flutter` and `dart` +were not on `PATH`. Therefore no Flutter test, analyzer, or APK build was +claimed locally. The expected next action is to install/use Flutter 3.24.0 and +Java 21, then run the commands above or rely on the GitHub workflow. + +## Functional delivery matrix + +Run each scenario once on the baseline and at least three times on the changed +build. Use a unique local test ID, not message text, in the timing sheet. + +| Scenario | Expected result | Evidence | +| --- | --- | --- | +| iMessage receive, app foreground | One database row and one UI message | relay receive, save, UI timestamps | +| iMessage receive, app background | One notification and one database row | notification ID plus save timestamp | +| Duplicate relay event | One logical message, no duplicate notification | stable server/message ID count | +| Reconnect during receive | Event is retried or recovered, no loss | disconnect, reconnect, save, acknowledgement order | +| Updated message/reaction before base message | Event is eventually applied or boundedly deferred | target ID, retry count, final row | +| Malformed/partial payload | Event rejected safely; process remains alive | redacted error and subsequent message success | +| SMS/MMS/RCS routing | Delivered only by Google Messages in the control test | default-app state and notification source | + +For the SMS/RCS control, keep Google Messages as the default SMS app and turn +off OpenBubbles forwarding. Test the forwarding path separately; do not use two +active paths to judge loss or duplication. + +## Timing and correctness metrics + +Instrument or correlate a redacted event ID at these boundaries: + +1. relay/WebSocket receive +2. payload parse/validation +3. incoming queue start +4. database save/upsert complete +5. acknowledgement sent +6. UI listener refresh +7. notification scheduled + +Report median and p95 milliseconds for each segment, plus counts of duplicate +IDs, missing IDs, parse failures, notification failures, and reconnects. The +minimum acceptance gate for a delivery fix is zero lost IDs and zero duplicate +IDs in the controlled run. Latency improvements are secondary to correctness. + +## On-device performance capture + +Use a profile APK for frame and CPU comparisons, and keep the same device, +Android version, screen refresh rate, chat history, network, and test script. +Replace the package name if using another flavor. + +```bash +adb shell am force-stop com.bluebubbles.messaging.alpha +adb shell monkey -p com.bluebubbles.messaging.alpha 1 +adb shell dumpsys gfxinfo com.bluebubbles.messaging.alpha reset +# Perform one 60-second scroll/open/send/receive script. +adb shell dumpsys gfxinfo com.bluebubbles.messaging.alpha framestats > gfxinfo.txt +adb shell dumpsys meminfo com.bluebubbles.messaging.alpha > meminfo.txt +adb shell dumpsys cpuinfo > cpuinfo.txt +``` + +Record total frames, missed frames, 90th/95th/99th percentile frame time, peak +RSS, and CPU share. A smoother UI should reduce long frames without trading +away receive or database work. + +For battery and background behavior, use a fresh controlled window: + +```bash +adb shell dumpsys batterystats --reset +# Leave the same build idle for 30 minutes, then exercise five receives. +adb shell dumpsys batterystats --charged > batterystats.txt +adb shell dumpsys netstats detail > netstats.txt +``` + +Compare baseline and changed builds under the same network. Look specifically +for reconnect loops, repeated refresh timers, foreground services that never +stop, and notification retry storms. + +## Failure triage + +- A WebSocket connect without a database save is a receive failure, not a pass. +- An acknowledgement before persistence is a possible loss window after a + process restart; capture the ordering explicitly. +- Repeated null-avatar or render exceptions can make the app feel slow even if + transport latency is normal. +- Reaction lookups that miss their target should be bounded and observable, not + an unbounded retry loop. +- CloudKit plist decoding failures should isolate the malformed item and allow + later messages to continue. +- Registration/anisette failures should back off; a tight retry loop is both a + battery and delivery risk. + +Attach only redacted logs and a small timing table to a review. Never include +message text, phone numbers, Apple credentials, relay secrets, auth tokens, or +full device identifiers. From b7e9b987b7324e14f7c17869c6f8e4f038cffe2d Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:34:29 -0700 Subject: [PATCH 19/33] Harden background routing and reconnect lifecycle --- .../foreground/SocketIOForegroundService.kt | 34 ++++++-- .../messaging/services/rustpush/APNService.kt | 2 - lib/services/backend/action_handler.dart | 2 +- .../method_channel_service.dart | 16 +--- .../firebase/firebase_database_service.dart | 9 +- lib/services/network/socket_service.dart | 87 ++++++++++++++----- 6 files changed, 101 insertions(+), 49 deletions(-) diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/foreground/SocketIOForegroundService.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/foreground/SocketIOForegroundService.kt index c5a56dfe77..54b8852986 100644 --- a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/foreground/SocketIOForegroundService.kt +++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/foreground/SocketIOForegroundService.kt @@ -9,7 +9,9 @@ import android.content.Context import android.content.Intent import android.util.Log import android.os.Build +import android.os.Handler import android.os.IBinder +import android.os.Looper import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import com.bluebubbles.messaging.Constants @@ -47,6 +49,9 @@ class SocketIOForegroundService : Service() { private var isBeingDestroyed: Boolean = false private var hasStarted: Boolean = false + private val reconnectHandler = Handler(Looper.getMainLooper()) + private var reconnectRunnable: Runnable? = null + private var reconnectAttempt: Int = 0 private val eventBlacklist: Array = arrayOf( "typing-indicator", @@ -102,6 +107,9 @@ class SocketIOForegroundService : Service() { Log.d(Constants.logTag, "Foreground Service is connecting to: $serverUrl") val opts = IO.Options() + // Reconnects are scheduled by this service so the Socket.IO manager + // cannot race a second retry loop with our URL/service lifecycle. + opts.reconnection = false try { // Read the custom headers JSON string from preferences and parse it into a map @@ -128,6 +136,9 @@ class SocketIOForegroundService : Service() { mSocket!!.on(Socket.EVENT_CONNECT) { Log.d(Constants.logTag, "Socket.io connected to your server!") + reconnectAttempt = 0 + reconnectRunnable?.let { reconnectHandler.removeCallbacks(it) } + reconnectRunnable = null updateNotification(CONNECTED) } @@ -135,6 +146,7 @@ class SocketIOForegroundService : Service() { val error = args[0] as Exception Log.d(Constants.logTag, "Socket.io failed to connect to $serverUrl! Error: ${error.message}") updateNotification(CONNECT_FAILED + error.message) + tryReconnect() } // with reason, details args @@ -148,6 +160,7 @@ class SocketIOForegroundService : Service() { val details = args.getOrNull(1) Log.d(Constants.logTag, "Socket.io disconnected from server! Reason: $reason, Details: $details") updateNotification(DISCONNECTED + reason) + tryReconnect() } mSocket!!.on("reconnecting") { @@ -165,9 +178,7 @@ class SocketIOForegroundService : Service() { val event = args[0] as String val message = args[1] as JSONObject - Log.d(Constants.logTag, "Received event of type $event from Socket.io...") if (!eventBlacklist.contains(event)) { - Log.d(Constants.logTag, "Received event of type $event from Socket.io...") DartWorkManager.createWorker(applicationContext, "socket-event", hashMapOf("event" to event, "data" to message.toString())) {} } else { Log.d(Constants.logTag, "Ignored event of type $event from Socket.io...") @@ -190,11 +201,18 @@ class SocketIOForegroundService : Service() { private fun tryReconnect() { if (mSocket != null && !mSocket!!.connected()) { - Log.e(Constants.logTag, "Waiting 30 seconds before reconnecting...") - - // Sleep for 30 seconds before attempting to reconnect - Thread.sleep(30000) - mSocket!!.connect() + if (reconnectRunnable != null) return + val delaySeconds = 30L * (1L shl reconnectAttempt.coerceAtMost(3)) + reconnectAttempt = (reconnectAttempt + 1).coerceAtMost(3) + Log.e(Constants.logTag, "Scheduling reconnect in ${delaySeconds}s...") + val runnable = Runnable { + reconnectRunnable = null + if (!isBeingDestroyed && mSocket != null && !mSocket!!.connected()) { + mSocket!!.connect() + } + } + reconnectRunnable = runnable + reconnectHandler.postDelayed(runnable, delaySeconds * 1000L) } } @@ -261,6 +279,8 @@ class SocketIOForegroundService : Service() { override fun onDestroy() { isBeingDestroyed = true hasStarted = false + reconnectRunnable?.let { reconnectHandler.removeCallbacks(it) } + reconnectRunnable = null Log.d(Constants.logTag, "BlueBubbles Service is being destroyed!") super.onDestroy() diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/rustpush/APNService.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/rustpush/APNService.kt index cc72a4e65e..8f531715df 100644 --- a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/rustpush/APNService.kt +++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/rustpush/APNService.kt @@ -96,12 +96,10 @@ class APNService : Service(), MsgReceiver { override fun receievedMsg(ptr: ULong, retry: ULong) { Handler(Looper.getMainLooper()).post { if (MainActivity.engine != null) { - Log.i("ugh running", "here $ptr $retry") // app is alive, deliver directly there MethodCallHandler.invokeMethod("APNMsg", mapOf("pointer" to ptr.toString(), "retry" to retry.toString())) return@post } - Log.i("ugh running", "backend $ptr $retry") CoroutineScope(Dispatchers.Main).launch { DartWorker.callMethod(this@APNService, "APNMsg", mapOf("pointer" to ptr.toString(), "retry" to retry.toString())) } diff --git a/lib/services/backend/action_handler.dart b/lib/services/backend/action_handler.dart index d0e8f9faee..514fa78540 100644 --- a/lib/services/backend/action_handler.dart +++ b/lib/services/backend/action_handler.dart @@ -498,7 +498,7 @@ class ActionHandler extends GetxService { } // should have been handled by the sanity check if (tempGuid != null) return; - Logger.info("New message: [${m.text}] - for chat [${c.guid}]", tag: "ActionHandler"); + Logger.debug("Received new message (attachments=${m.hasAttachments})", tag: "ActionHandler"); // Gets the chat from the db or server (if new) c = m.isParticipantEvent ? await handleNewOrUpdatedChat(c) : kIsWeb ? c : (Chat.findOne(guid: c.guid) ?? await handleNewOrUpdatedChat(c)); // Get the message handle diff --git a/lib/services/backend/java_dart_interop/method_channel_service.dart b/lib/services/backend/java_dart_interop/method_channel_service.dart index 6e48850a4d..ed6cd5c24a 100644 --- a/lib/services/backend/java_dart_interop/method_channel_service.dart +++ b/lib/services/backend/java_dart_interop/method_channel_service.dart @@ -141,13 +141,12 @@ class MethodChannelService extends GetxService { await Database.waitForInit(); Logger.info("Received new message from MethodChannel"); - // The socket will handle this event if the app is alive and unifiedpush is not enabled + // When the app is backgrounded, FCM is the safe fallback if the optional + // foreground socket is disconnected or still reconnecting. The message + // handler deduplicates by GUID after the first delivery is persisted. if (ls.isAlive && socket.socket.connected && ss.settings.endpointUnifiedPush.value == "") { Logger.debug("App is alive, ignoring new message..."); return Future.value(true); - } else if (!ls.isAlive && ss.settings.keepAppAlive.value) { - Logger.debug("Ignoring FCM message while app is not alive, but keepAppAlive is enabled"); - return Future.value(true); } try { @@ -174,9 +173,6 @@ class MethodChannelService extends GetxService { if (ls.isAlive && socket.socket.connected) { Logger.debug("App is alive, ignoring updated message..."); return Future.value(true); - } else if (!ls.isAlive && ss.settings.keepAppAlive.value) { - Logger.debug("Ignoring FCM message while app is not alive, but keepAppAlive is enabled"); - return Future.value(true); } try { @@ -221,9 +217,6 @@ class MethodChannelService extends GetxService { if (ls.isAlive && socket.socket.connected) { Logger.debug("App is alive, ignoring updated message..."); return Future.value(true); - } else if (!ls.isAlive && ss.settings.keepAppAlive.value) { - Logger.debug("Ignoring FCM message while app is not alive, but keepAppAlive is enabled"); - return Future.value(true); } try { @@ -246,9 +239,6 @@ class MethodChannelService extends GetxService { if (ls.isAlive && socket.socket.connected) { Logger.debug("App is alive, ignoring updated message..."); return Future.value(true); - } else if (!ls.isAlive && ss.settings.keepAppAlive.value) { - Logger.debug("Ignoring FCM message while app is not alive, but keepAppAlive is enabled"); - return Future.value(true); } try { diff --git a/lib/services/network/firebase/firebase_database_service.dart b/lib/services/network/firebase/firebase_database_service.dart index 35c7798f59..b8c9d3ccf2 100644 --- a/lib/services/network/firebase/firebase_database_service.dart +++ b/lib/services/network/firebase/firebase_database_service.dart @@ -55,7 +55,7 @@ class FirebaseDatabaseService extends GetxService { } /// Fetch the new server URL from the Firebase Database - Future fetchNewUrl() async { + Future fetchNewUrl({bool restartSocket = true, bool tryRestartForegroundService = true}) async { // Make sure setup is complete and we have valid data if (!ss.settings.finishedSetup.value) return null; if (ss.fcmData.isNull) { @@ -104,7 +104,12 @@ class FirebaseDatabaseService extends GetxService { url = sanitizeServerAddress(address: await mcs.invokeMethod("get-server-url")); } - await saveNewServerUrl(url ?? ss.settings.serverAddress.value, force: true); + await saveNewServerUrl( + url ?? ss.settings.serverAddress.value, + force: true, + restartSocket: restartSocket, + tryRestartForegroundService: tryRestartForegroundService, + ); return url; } catch (e, s) { Logger.error("Failed to fetch URL!", error: e, trace: s); diff --git a/lib/services/network/socket_service.dart b/lib/services/network/socket_service.dart index 62f56a1903..f0676ca0dd 100644 --- a/lib/services/network/socket_service.dart +++ b/lib/services/network/socket_service.dart @@ -26,6 +26,9 @@ class SocketService extends GetxService { SocketState _lastState = SocketState.disconnected; RxString lastError = "".obs; Timer? _reconnectTimer; + int _connectionGeneration = 0; + int _reconnectEpoch = 0; + int _reconnectAttempt = 0; late Socket socket; String get serverAddress => http.origin; @@ -55,29 +58,33 @@ class SocketService extends GetxService { } void startSocket() { + _cancelReconnect(); + final generation = ++_connectionGeneration; OptionBuilder options = OptionBuilder() .setQuery({"guid": password}) .setTransports(['websocket', 'polling']) .setExtraHeaders(http.headers) // Disable so that we can create the listeners first .disableAutoConnect() - .enableReconnection(); + // Reconnection is owned here so that URL refresh and socket creation + // cannot race the Socket.IO manager's own retry loop. + .disableReconnection(); socket = io(serverAddress, options.build()); // placed here so that [socket] is still initialized if (isNullOrEmpty(serverAddress)) return; - socket.onConnect((data) => handleStatusUpdate(SocketState.connected, data)); - socket.onReconnect((data) => handleStatusUpdate(SocketState.connected, data)); + socket.onConnect((data) => handleStatusUpdate(SocketState.connected, data, generation: generation)); + socket.onReconnect((data) => handleStatusUpdate(SocketState.connected, data, generation: generation)); - socket.onReconnectAttempt((data) => handleStatusUpdate(SocketState.connecting, data)); - socket.onReconnecting((data) => handleStatusUpdate(SocketState.connecting, data)); - socket.onConnecting((data) => handleStatusUpdate(SocketState.connecting, data)); + socket.onReconnectAttempt((data) => handleStatusUpdate(SocketState.connecting, data, generation: generation)); + socket.onReconnecting((data) => handleStatusUpdate(SocketState.connecting, data, generation: generation)); + socket.onConnecting((data) => handleStatusUpdate(SocketState.connecting, data, generation: generation)); - socket.onDisconnect((data) => handleStatusUpdate(SocketState.disconnected, data)); + socket.onDisconnect((data) => handleStatusUpdate(SocketState.disconnected, data, generation: generation)); - socket.onConnectError((data) => handleStatusUpdate(SocketState.error, data)); - socket.onConnectTimeout((data) => handleStatusUpdate(SocketState.error, data)); - socket.onError((data) => handleStatusUpdate(SocketState.error, data)); + socket.onConnectError((data) => handleStatusUpdate(SocketState.error, data, generation: generation)); + socket.onConnectTimeout((data) => handleStatusUpdate(SocketState.error, data, generation: generation)); + socket.onError((data) => handleStatusUpdate(SocketState.error, data, generation: generation)); // custom events // only listen to these events from socket on web/desktop (FCM handles on Android) @@ -100,6 +107,8 @@ class SocketService extends GetxService { } void disconnect() { + _cancelReconnect(); + _connectionGeneration++; if (isNullOrEmpty(serverAddress)) return; socket.disconnect(); state.value = SocketState.disconnected; @@ -107,11 +116,14 @@ class SocketService extends GetxService { void reconnect() { if (state.value == SocketState.connected || isNullOrEmpty(serverAddress)) return; + _cancelReconnect(); state.value = SocketState.connecting; socket.connect(); } void closeSocket() { + _cancelReconnect(); + _connectionGeneration++; if (isNullOrEmpty(serverAddress)) return; socket.dispose(); state.value = SocketState.disconnected; @@ -144,7 +156,8 @@ class SocketService extends GetxService { return completer.future; } - void handleStatusUpdate(SocketState status, dynamic data) { + void handleStatusUpdate(SocketState status, dynamic data, {int? generation}) { + if (generation != null && generation != _connectionGeneration) return; if (_lastState == status) return; _lastState = status; @@ -153,12 +166,15 @@ class SocketService extends GetxService { state.value = SocketState.connected; _reconnectTimer?.cancel(); _reconnectTimer = null; + _reconnectEpoch++; + _reconnectAttempt = 0; NetworkTasks.onConnect(); notif.clearSocketError(); return; case SocketState.disconnected: Logger.info("Disconnected from socket..."); state.value = SocketState.disconnected; + _scheduleReconnect(); return; case SocketState.connecting: Logger.info("Connecting to socket..."); @@ -172,25 +188,48 @@ class SocketService extends GetxService { } state.value = SocketState.error; - // After 5 seconds of an error, we should retry the connection - _reconnectTimer = Timer(const Duration(seconds: 5), () async { - if (state.value == SocketState.connected) return; - - await fdb.fetchNewUrl(); - restartSocket(); - - if (state.value == SocketState.connected) return; - - if (!ss.settings.keepAppAlive.value) { - notif.createSocketError(); - } - }); + _scheduleReconnect(); return; default: return; } } + void _cancelReconnect() { + _reconnectTimer?.cancel(); + _reconnectTimer = null; + _reconnectEpoch++; + } + + void _scheduleReconnect() { + if (_reconnectTimer != null || state.value == SocketState.connected || isNullOrEmpty(serverAddress)) return; + + final epoch = _reconnectEpoch; + final generation = _connectionGeneration; + final attempt = _reconnectAttempt > 3 ? 3 : _reconnectAttempt; + final seconds = 5 * (1 << attempt); + if (_reconnectAttempt < 3) _reconnectAttempt++; + _reconnectTimer = Timer(Duration(seconds: seconds), () async { + _reconnectTimer = null; + if (epoch != _reconnectEpoch || generation != _connectionGeneration || state.value == SocketState.connected) return; + + try { + await fdb.fetchNewUrl(restartSocket: false, tryRestartForegroundService: false); + } catch (e, s) { + Logger.warn("Failed to refresh socket URL before reconnect", error: e, trace: s); + _scheduleReconnect(); + return; + } + + if (epoch != _reconnectEpoch || generation != _connectionGeneration) return; + restartSocket(); + + if (state.value != SocketState.connected && !ss.settings.keepAppAlive.value) { + notif.createSocketError(); + } + }); + } + void handleSocketException(SocketException e) { String msg = e.message; if (msg.contains("Failed host lookup")) { From d2e8c6b57f3c7213f7f558485567936bd64f56f9 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:36:15 -0700 Subject: [PATCH 20/33] Back off terminal APS poll panics --- rust/src/native.rs | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/rust/src/native.rs b/rust/src/native.rs index 5ef5584215..f4b45322b0 100644 --- a/rust/src/native.rs +++ b/rust/src/native.rs @@ -154,15 +154,41 @@ pub fn plist_to_string(value: &T) -> Result)>> = LazyLock::new(|| Mutex::new((0, HashMap::new()))); +fn is_terminal_poll_panic(message: &str) -> bool { + let message = message.to_ascii_lowercase(); + message.contains("wrong phase") + || (message.contains("watcher") && message.contains("closed")) + || message.contains("channel closed") +} + +#[cfg(test)] +mod tests { + use super::is_terminal_poll_panic; + + #[test] + fn terminal_watcher_panics_stop_the_receive_loop() { + assert!(is_terminal_poll_panic("Wrong phase!")); + assert!(is_terminal_poll_panic("APS watcher is closed")); + assert!(is_terminal_poll_panic("channel closed")); + } + + #[test] + fn unrelated_panics_remain_retryable() { + assert!(!is_terminal_poll_panic("temporary network failure")); + } +} + #[uniffi::export] impl NativePushState { pub fn start_loop(self: Arc, handler: Arc) { RUNTIME.spawn(async move { let mut watcher = self.watcher.lock().await; + let mut panic_backoff_ms = 250u64; loop { match std::panic::AssertUnwindSafe(recv_wait(&mut watcher, &self.state)).catch_unwind().await { Ok(yes) => { + panic_backoff_ms = 250; match yes { PollResult::Cont(Some(msg)) => { if let PushMessage::TwoFaAuthEvent(event) = &msg { @@ -211,7 +237,14 @@ impl NativePushState { None => None, }, }; - error!("Failed {:?}", panic); + if panic.map(is_terminal_poll_panic).unwrap_or(false) { + warn!("Stopping APS receive loop after terminal watcher panic: {:?}", panic); + break; + } + + error!("Failed {:?}; backing off {}ms", panic, panic_backoff_ms); + tokio::time::sleep(Duration::from_millis(panic_backoff_ms)).await; + panic_backoff_ms = (panic_backoff_ms.saturating_mul(2)).min(5_000); } } } @@ -368,4 +401,4 @@ impl NativePushState { } }); } -} \ No newline at end of file +} From d332a41b809c715ae646e60592ece001eb3c5e04 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:37:40 -0700 Subject: [PATCH 21/33] Register socket callbacks before connecting --- .../services/foreground/SocketIOForegroundService.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/foreground/SocketIOForegroundService.kt b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/foreground/SocketIOForegroundService.kt index 54b8852986..e7d0192715 100644 --- a/android/app/src/main/kotlin/com/bluebubbles/messaging/services/foreground/SocketIOForegroundService.kt +++ b/android/app/src/main/kotlin/com/bluebubbles/messaging/services/foreground/SocketIOForegroundService.kt @@ -132,7 +132,6 @@ class SocketIOForegroundService : Service() { val encodedPw = URLEncoder.encode(storedPassword, "UTF-8") opts.query = "password=$encodedPw" mSocket = IO.socket(serverUrl, opts) - mSocket!!.connect() mSocket!!.on(Socket.EVENT_CONNECT) { Log.d(Constants.logTag, "Socket.io connected to your server!") @@ -185,6 +184,10 @@ class SocketIOForegroundService : Service() { } } } + + // Register every callback before opening the transport so an + // immediate connect or event cannot race listener setup. + mSocket!!.connect() } catch (e: Exception) { if (isBeingDestroyed) { return From 696461dc2edb0ca4f8354e018f074a8149337e31 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:39:47 -0700 Subject: [PATCH 22/33] fix: preserve incoming message delivery integrity --- lib/database/io/message.dart | 34 +++--- lib/services/backend/action_handler.dart | 48 ++++++++- lib/services/backend/queue/queue_impl.dart | 111 +++++++++++--------- lib/services/rustpush/rustpush_service.dart | 13 ++- test/services/queue_impl_test.dart | 57 ++++++++++ 5 files changed, 195 insertions(+), 68 deletions(-) create mode 100644 test/services/queue_impl_test.dart diff --git a/lib/database/io/message.dart b/lib/database/io/message.dart index 4440c2ef6a..7140998410 100644 --- a/lib/database/io/message.dart +++ b/lib/database/io/message.dart @@ -400,19 +400,27 @@ class Message { var attachments = fetchAttachments()!; bool useMMS = chat.participants.length > 1 || attachments.isNotEmpty; int status; - if (useMMS) { - status = await TelephonyPlus().sendMMS( - addresses: chat.participants.map((e) => e.address).filter((e) => e.isPhoneNumber).toList(), - message: text?.trim() == "" ? null : text, - threadId: chat.telephonyId, - attachments: await Future.wait(attachments.map((e) => e!.toTelephony()).toList()) - ); - } else { - status = await TelephonyPlus().sendSMS( - address: chat.participants.first.address, - threadId: chat.telephonyId, - message: text!, - ); + try { + if (useMMS) { + status = await TelephonyPlus().sendMMS( + addresses: chat.participants.map((e) => e.address).filter((e) => e.isPhoneNumber).toList(), + message: text?.trim() == "" ? null : text, + threadId: chat.telephonyId, + attachments: await Future.wait(attachments.map((e) => e!.toTelephony()).toList()) + ); + } else { + status = await TelephonyPlus().sendSMS( + address: chat.participants.first.address, + threadId: chat.telephonyId, + message: text!, + ); + } + } catch (_) { + // No native status means the forwarding attempt did not complete. Let + // the transport retry instead of permanently suppressing forwarding. + hasBeenForwarded = false; + save(chat: chat); + rethrow; } if (status != -1) { await (backend as RustPushBackend).confirmSmsSent(this, chat, false); diff --git a/lib/services/backend/action_handler.dart b/lib/services/backend/action_handler.dart index 514fa78540..15d3498a7f 100644 --- a/lib/services/backend/action_handler.dart +++ b/lib/services/backend/action_handler.dart @@ -32,8 +32,19 @@ class ActionHandler extends GetxService { final RxList> attachmentProgress = >[].obs; final List outOfOrderTempGuids = []; final List handledNewMessages = []; + final Map> _inFlightNewMessages = {}; CancelToken? latestCancelToken; + Future _notifyNewMessageBestEffort(Message message, Chat chat) async { + try { + await MessageHelper.handleNotification(message, chat, findExisting: false); + } catch (_, stack) { + // Notification rendering must never prevent a message from being kept. + // Do not include the message or sender in diagnostics. + Logger.warn("Incoming message notification failed after persistence", tag: "Notification", trace: stack); + } + } + /// Checks if a GUID has been handled. /// After each check, before returning, trim the list of GUIDs to the last 100. bool shouldNotifyForNewMessageGuid(String guid) { @@ -487,6 +498,35 @@ class ActionHandler extends GetxService { } Future handleNewMessage(Chat c, Message m, String? tempGuid, {bool checkExisting = true}) async { + final key = m.guid; + final existingFlight = key == null ? null : _inFlightNewMessages[key]; + if (existingFlight != null) { + try { + await existingFlight; + } catch (_) { + // A failed first attempt must not prevent a concurrent fallback from retrying. + } + if (checkExisting && Message.findOne(guid: tempGuid ?? m.guid) != null) { + return await handleUpdatedMessage(c, m, tempGuid, checkExisting: false); + } + } + + if (key == null) { + return await _handleNewMessage(c, m, tempGuid, checkExisting: checkExisting); + } + + final flight = _handleNewMessage(c, m, tempGuid, checkExisting: checkExisting); + _inFlightNewMessages[key] = flight; + try { + await flight; + } finally { + if (identical(_inFlightNewMessages[key], flight)) { + _inFlightNewMessages.remove(key); + } + } + } + + Future _handleNewMessage(Chat c, Message m, String? tempGuid, {bool checkExisting = true}) async { Logger.info("handling new ${m.id}"); // sanity check if (checkExisting) { @@ -510,11 +550,13 @@ class ActionHandler extends GetxService { Logger.info("Not notifying for already handled new message with GUID ${m.guid}...", tag: "ActionHandler"); } + await c.addMessage(m); + await m.forwardIfNessesary(c, markFailed: true); + // Persistence is complete before notification work begins. Notification + // failures are isolated so they cannot make the transport drop the message. if ((!ls.isAlive || ss.settings.endpointUnifiedPush.value != "") && shouldNotify) { - await MessageHelper.handleNotification(m, c); + unawaited(_notifyNewMessageBestEffort(m, c)); } - await m.forwardIfNessesary(c, markFailed: true); - await c.addMessage(m); } Future handleUpdatedMessage(Chat c, Message m, String? tempGuid, {bool checkExisting = true}) async { diff --git a/lib/services/backend/queue/queue_impl.dart b/lib/services/backend/queue/queue_impl.dart index 38e7868de1..d76f731467 100644 --- a/lib/services/backend/queue/queue_impl.dart +++ b/lib/services/backend/queue/queue_impl.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:isolate'; import 'package:bluebubbles/helpers/helpers.dart'; import 'package:bluebubbles/database/models.dart'; @@ -10,72 +9,88 @@ import 'package:get/get.dart'; abstract class Queue extends GetxService { RxBool isProcessing = false.obs; List items = []; + bool _runnerActive = false; Future queue(QueueItem item, {bool prep = true}) async { - if (prep) { - final returned = await prepItem(item); - // we may get a link split into 2 messages - if (item is OutgoingItem && returned is List) { - items.addAll(returned.map((e) => OutgoingItem( - type: item.type, - chat: item.chat, - message: e, - completer: item.completer, - selected: item.selected, - reaction: item.reaction, - ))); + try { + if (prep) { + final returned = await prepItem(item); + // we may get a link split into 2 messages + if (item is OutgoingItem && returned is List) { + items.addAll(returned.map((e) => OutgoingItem( + type: item.type, + chat: item.chat, + message: e, + completer: item.completer, + selected: item.selected, + reaction: item.reaction, + ))); + } else { + items.add(item); + } } else { items.add(item); } - } else { - items.add(item); + } catch (ex, stacktrace) { + if (item.completer != null && !item.completer!.isCompleted) { + item.completer!.completeError(ex, stacktrace); + } + rethrow; } - if (!isProcessing.value || (items.isEmpty && item is IncomingItem)) processNextItem(); + _startRunner(); } Future prepItem(QueueItem _); + void _startRunner() { + if (_runnerActive) return; + _runnerActive = true; + unawaited(processNextItem()); + } + Future processNextItem() async { - if (items.isEmpty) { + isProcessing.value = true; + try { + while (items.isNotEmpty) { + ls.closeTimer?.cancel(); + ls.closeTimer = null; + + final queued = items.removeAt(0); + try { + await handleQueueItem(queued); + if (queued.completer != null && !queued.completer!.isCompleted) { + queued.completer!.complete(); + } + } catch (ex, stacktrace) { + Logger.error("Failed to handle queued item!", error: ex, trace: stacktrace); + if (queued is OutgoingItem && ss.settings.cancelQueuedMessages.value) { + final toCancel = List.from(items.whereType().where((e) => e.chat.guid == queued.chat.guid)); + for (final i in toCancel) { + items.remove(i); + final m = i.message; + final tempGuid = m.guid; + m.guid = m.guid!.replaceAll("temp", "error-Canceled due to previous failure"); + m.error = MessageError.BAD_REQUEST.code; + Message.replaceMessage(tempGuid, m); + } + } + if (queued.completer != null && !queued.completer!.isCompleted) { + queued.completer!.completeError(ex, stacktrace); + } + } + } + } finally { isProcessing.value = false; + _runnerActive = false; + if (items.isNotEmpty) _startRunner(); if (ls.isDead && !inq.isProcessing.value && !outq.isProcessing.value) { Logger.info("Done! waiting a bit for any stragglers"); ls.closeTimer = Timer(const Duration(seconds: 5), () { mcs.invokeMethod("engine-done"); }); } - return; - } - - ls.closeTimer?.cancel(); - ls.closeTimer = null; - - isProcessing.value = true; - QueueItem queued = items.removeAt(0); - - try { - await handleQueueItem(queued).catchError((err, trace) async { - Logger.error("Failed to handle queued item!", error: err, trace: trace); - if (queued is OutgoingItem && ss.settings.cancelQueuedMessages.value) { - final toCancel = List.from(items.whereType().where((e) => e.chat.guid == queued.chat.guid)); - for (OutgoingItem i in toCancel) { - items.remove(i); - final m = i.message; - final tempGuid = m.guid; - m.guid = m.guid!.replaceAll("temp", "error-Canceled due to previous failure"); - m.error = MessageError.BAD_REQUEST.code; - Message.replaceMessage(tempGuid, m); - } - } - }); - queued.completer?.complete(); - } catch (ex, stacktrace) { - Logger.error("Failed to handle queued item!", error: ex, trace: stacktrace); - queued.completer?.completeError(ex); } - - await processNextItem(); } Future handleQueueItem(QueueItem _); -} \ No newline at end of file +} diff --git a/lib/services/rustpush/rustpush_service.dart b/lib/services/rustpush/rustpush_service.dart index 4dfa94c4aa..f30f2aabbd 100644 --- a/lib/services/rustpush/rustpush_service.dart +++ b/lib/services/rustpush/rustpush_service.dart @@ -4032,9 +4032,11 @@ class RustPushService extends GetxService { myMsg.target = otherIds.map((element) => api.MessageTarget.uuid(element)).toList(); // forward to other devices await (backend as RustPushBackend).sendMsg(myMsg); } - var msg = (await pushService.reflectMessageDyn(myMsg))!; - msg.temp = true; - msg.forwardIfNessesary(chat); + final msg = await pushService.reflectMessageDyn(myMsg); + if (msg != null) { + msg.temp = true; + await msg.forwardIfNessesary(chat); + } return; } } @@ -4054,12 +4056,15 @@ class RustPushService extends GetxService { Logger.info("rustpush_receive reflection_complete id=$receiveId duration_ms=${_durationMs(receiveStopwatch)} reflected=${reflected != null}"); if (reflected != null) { final queueStopwatch = Stopwatch()..start(); + final queueCompletion = Completer(); Logger.info("rustpush_receive incoming_queue_enqueue id=$receiveId pending_count=${inq.items.length}"); await inq.queue(IncomingItem( chat: chat, message: reflected, - type: QueueType.newMessage + type: QueueType.newMessage, + completer: queueCompletion, )); + await queueCompletion.future; Logger.info("rustpush_receive incoming_queue_complete id=$receiveId duration_ms=${_durationMs(queueStopwatch)} pending_count=${inq.items.length}"); } } diff --git a/test/services/queue_impl_test.dart b/test/services/queue_impl_test.dart new file mode 100644 index 0000000000..84a63d6d78 --- /dev/null +++ b/test/services/queue_impl_test.dart @@ -0,0 +1,57 @@ +import 'dart:async'; + +import 'package:bluebubbles/database/models.dart'; +import 'package:bluebubbles/services/backend/queue/queue_impl.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _TestItem extends QueueItem { + _TestItem({Completer? completer}) + : super(type: QueueType.newMessage, completer: completer); +} + +class _TestQueue extends Queue { + int active = 0; + int maxActive = 0; + bool fail = false; + + @override + Future prepItem(QueueItem item) async {} + + @override + Future handleQueueItem(QueueItem item) async { + active++; + maxActive = active > maxActive ? active : maxActive; + await Future.delayed(const Duration(milliseconds: 10)); + active--; + if (fail) throw StateError('expected test failure'); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('runs queued items with one active runner', () async { + final queue = _TestQueue(); + final first = Completer(); + final second = Completer(); + + await Future.wait([ + queue.queue(_TestItem(completer: first)), + queue.queue(_TestItem(completer: second)), + ]); + await Future.wait([first.future, second.future]); + + expect(queue.maxActive, 1); + expect(queue.isProcessing.value, isFalse); + }); + + test('completes a failed item with an error', () async { + final queue = _TestQueue()..fail = true; + final completion = Completer(); + + await queue.queue(_TestItem(completer: completion)); + + await expectLater(completion.future, throwsStateError); + expect(queue.isProcessing.value, isFalse); + }); +} From 18c8cdb818abb796ee3a4a51a70807ddcef5ec22 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:47:00 -0700 Subject: [PATCH 23/33] Fix avatar resource leaks and push acknowledgement ordering --- lib/helpers/ui/ui_helpers.dart | 55 +++++++++++++-------- lib/services/rustpush/rustpush_service.dart | 30 ++++------- 2 files changed, 44 insertions(+), 41 deletions(-) diff --git a/lib/helpers/ui/ui_helpers.dart b/lib/helpers/ui/ui_helpers.dart index 083ea931fa..94b57af924 100644 --- a/lib/helpers/ui/ui_helpers.dart +++ b/lib/helpers/ui/ui_helpers.dart @@ -395,12 +395,14 @@ Future avatarAsBytes({ await paintGroupAvatar( chat: chat, participants: participants, canvas: canvas, size: quality, usingParticipantsOverride: participantsOverride != null); - ui.Picture picture = pictureRecorder.endRecording(); - ui.Image image = await picture.toImage(quality.toInt(), quality.toInt()); - - Uint8List bytes = (await image.toByteData(format: ui.ImageByteFormat.png))!.buffer.asUint8List(); - - return bytes; + final picture = pictureRecorder.endRecording(); + final image = await picture.toImage(quality.toInt(), quality.toInt()); + try { + return (await image.toByteData(format: ui.ImageByteFormat.png))!.buffer.asUint8List(); + } finally { + image.dispose(); + picture.dispose(); + } } Future paintGroupAvatar({ @@ -412,14 +414,15 @@ Future paintGroupAvatar({ }) async { late final ThemeData theme; final bool systemDark = PlatformDispatcher.instance.platformBrightness == Brightness.dark; - if (!ls.isAlive) { + final context = Get.context; + if (!ls.isAlive || context == null) { if (systemDark) { theme = ThemeStruct.getDarkTheme().data; } else { theme = ThemeStruct.getLightTheme().data; } } else { - theme = Get.context!.theme; + theme = context.theme; } if (chat.customAvatarPath != null && !usingParticipantsOverride) { @@ -430,7 +433,12 @@ Future paintGroupAvatar({ Logger.warn("Failed to load/clip custom avatar!", error: e, trace: stack); } if (customAvatar != null) { - canvas.drawImage(await loadImage(customAvatar), const Offset(0, 0), Paint()); + final avatarImage = await loadImage(customAvatar); + try { + canvas.drawImage(avatarImage, const Offset(0, 0), Paint()); + } finally { + avatarImage.dispose(); + } return; } } @@ -519,7 +527,12 @@ Future paintAvatar( if (contact?.avatar != null) { Uint8List? contactAvatar = await clip(contact!.avatar ?? contact.avatar!, size: size.toInt(), circle: kIsDesktop || inGroup); if (contactAvatar != null) { - canvas.drawImage(await loadImage(contactAvatar), offset, Paint()); + final avatarImage = await loadImage(contactAvatar); + try { + canvas.drawImage(avatarImage, offset, Paint()); + } finally { + avatarImage.dispose(); + } return; } } @@ -586,7 +599,6 @@ Future paintAvatar( } Future clip(Uint8List data, {required int size, required bool circle}) async { - ui.Image image; Uint8List _data = data; // Resize the image if it's the wrong size @@ -597,26 +609,29 @@ Future clip(Uint8List data, {required int size, required bool circle _data = img.encodePng(_image); } - image = await loadImage(_data); + final sourceImage = await loadImage(_data); ui.PictureRecorder pictureRecorder = ui.PictureRecorder(); Canvas canvas = Canvas(pictureRecorder); Paint paint = Paint(); paint.isAntiAlias = true; - Rect bounds = Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()); + Rect bounds = Rect.fromLTWH(0, 0, sourceImage.width.toDouble(), sourceImage.height.toDouble()); Path path = circle ? (Path()..addOval(bounds)) : (Path()..addRect(bounds)); canvas.clipPath(path); - canvas.drawImage(image, const Offset(0, 0), paint); - - ui.Picture picture = pictureRecorder.endRecording(); - image = await picture.toImage(image.width, image.height); + canvas.drawImage(sourceImage, const Offset(0, 0), paint); - Uint8List? bytes = (await image.toByteData(format: ui.ImageByteFormat.png))?.buffer.asUint8List(); - - return bytes; + final picture = pictureRecorder.endRecording(); + final clippedImage = await picture.toImage(sourceImage.width, sourceImage.height); + try { + return (await clippedImage.toByteData(format: ui.ImageByteFormat.png))?.buffer.asUint8List(); + } finally { + clippedImage.dispose(); + picture.dispose(); + sourceImage.dispose(); + } } Future loadImage(Uint8List data) async { diff --git a/lib/services/rustpush/rustpush_service.dart b/lib/services/rustpush/rustpush_service.dart index f30f2aabbd..49a7010485 100644 --- a/lib/services/rustpush/rustpush_service.dart +++ b/lib/services/rustpush/rustpush_service.dart @@ -3376,13 +3376,8 @@ class RustPushService extends GetxService { Chat.softDelete(chat); } - Future handleMsg(api.PushMessage push, bool finalAttempt) async { - try { - await handleMsgInner(push).timeout(const Duration(minutes: 3)); - } catch (e, s) { - if (finalAttempt) markCertified(push); - rethrow; - } + Future handleMsg(api.PushMessage push) async { + await handleMsgInner(push).timeout(const Duration(minutes: 3)); // if we complete successfully, mark delivery "certified" markCertified(push); } @@ -4721,13 +4716,9 @@ class RustPushService extends GetxService { Future markAsHandledAfter(String ptr, {required String eventId, required int retry}) async { final ackStopwatch = Stopwatch()..start(); - if (inq.isProcessing.value) { - Logger.info("rustpush_receive ack_wait_start id=$eventId retry=$retry pending_count=${inq.items.length}"); - await for (final value in inq.isProcessing.stream) { - if (!value) break; - } - } - Logger.info("rustpush_receive save_and_queue_drained id=$eventId retry=$retry wait_ms=${_durationMs(ackStopwatch)} pending_count=${inq.items.length}"); + // handleMsg awaits the completion for this pointer's queue item. Do not + // wait for unrelated incoming work before acknowledging this message. + Logger.info("rustpush_receive durable_work_complete id=$eventId retry=$retry pending_count=${inq.items.length}"); Logger.info("rustpush_receive ack_commit id=$eventId retry=$retry"); await api.completeMsg(ptr: ptr); Logger.info("rustpush_receive ack_complete id=$eventId retry=$retry duration_ms=${_durationMs(ackStopwatch)}"); @@ -4746,19 +4737,16 @@ class RustPushService extends GetxService { Logger.info("rustpush_receive aps_init_wait_start id=$eventId retry=$retryCount"); await initFuture; Logger.info("rustpush_receive aps_init_wait_complete id=$eventId retry=$retryCount duration_ms=${_durationMs(initStopwatch)} total_ms=${_durationMs(receiveStopwatch)}"); - var isFinal = retryCount >= 3; try { final handlingStopwatch = Stopwatch()..start(); Logger.info("rustpush_receive handle_start id=$eventId retry=$retryCount"); - await handleMsg(message, isFinal); + await handleMsg(message); Logger.info("rustpush_receive handle_complete id=$eventId retry=$retryCount duration_ms=${_durationMs(handlingStopwatch)} total_ms=${_durationMs(receiveStopwatch)}"); await markAsHandledAfter(pointer, eventId: eventId, retry: retryCount); } catch (e, s) { Logger.error("Handle failed", error: e, trace: s); - if (isFinal) { - Logger.info("rustpush_receive final_attempt_ack id=$eventId retry=$retryCount"); - await markAsHandledAfter(pointer, eventId: eventId, retry: retryCount); - } + // Leave the pointer pending so the native bounded retry loop can try + // again. A failed handler must never be acknowledged as delivered. rethrow; } } @@ -4779,7 +4767,7 @@ class RustPushService extends GetxService { if (msg == null) { continue; } - await handleMsg(msg, true); + await handleMsg(msg); } catch (e, t) { // if there was an error somewhere, log it and move on. // don't stop our loop From 2f324f2af99162484c50e9eeb06c6f901f9e5fd7 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:45:55 -0700 Subject: [PATCH 24/33] fix: harden group participant and reaction handling --- lib/database/global/chat_messages.dart | 42 +++++++++++- lib/helpers/group_participant_helpers.dart | 28 ++++++++ lib/services/rustpush/rustpush_service.dart | 67 +++++++++++++------ lib/services/ui/chat/chat_manager.dart | 31 +++++---- lib/services/ui/message/messages_service.dart | 19 ++++-- test/helpers/chat_messages_test.dart | 37 ++++++++++ .../group_participant_helpers_test.dart | 47 +++++++++++++ 7 files changed, 234 insertions(+), 37 deletions(-) create mode 100644 lib/helpers/group_participant_helpers.dart create mode 100644 test/helpers/chat_messages_test.dart create mode 100644 test/helpers/group_participant_helpers_test.dart diff --git a/lib/database/global/chat_messages.dart b/lib/database/global/chat_messages.dart index 131468a6a0..887a0549cc 100644 --- a/lib/database/global/chat_messages.dart +++ b/lib/database/global/chat_messages.dart @@ -1,8 +1,11 @@ import 'package:bluebubbles/database/models.dart'; class ChatMessages { + static const int _maxPendingReactionsPerMessage = 32; + static const int _maxPendingReactionParents = 128; final Map _messages = {}; final Map _reactions = {}; + final Map> _pendingReactions = {}; final Map _attachments = {}; final Map> _threads = {}; final Map> _edits = {}; @@ -21,9 +24,37 @@ class ChatMessages { if (m.associatedMessageGuid != null) { // add reactions _reactions[m.guid!] = m; + final parent = getMessage(m.associatedMessageGuid!); + if (parent != null) { + _attachReaction(parent, m); + } else { + final parentGuid = m.associatedMessageGuid!; + var pending = _pendingReactions[parentGuid]; + if (pending == null) { + if (_pendingReactions.length >= _maxPendingReactionParents) { + _pendingReactions.remove(_pendingReactions.keys.first); + } + pending = {}; + _pendingReactions[parentGuid] = pending; + } + // A malformed or delayed stream must not grow this cache forever. + // The database sync remains the source of truth if an item is + // evicted before its parent arrives. + if (pending.length >= _maxPendingReactionsPerMessage && + !pending.containsKey(m.guid)) { + pending.remove(pending.keys.first); + } + pending[m.guid!] = m; + } } else { // add regular texts _messages[m.guid!] = m; + final pending = _pendingReactions.remove(m.guid); + if (pending != null) { + for (final reaction in pending.values) { + _attachReaction(m, reaction); + } + } } if (m.threadOriginatorGuid != null && !m.guid!.startsWith("temp") && m.associatedMessageGuid == null) { // add threaded messages @@ -38,9 +69,17 @@ class ChatMessages { } } + void _attachReaction(Message parent, Message reaction) { + if (!parent.associatedMessages.any((item) => item.guid == reaction.guid)) { + parent.associatedMessages.add(reaction); + } + parent.hasReactions = true; + } + void removeMessage(String guid) { _messages.remove(guid); _reactions.remove(guid); + _pendingReactions.remove(guid); final result = _threads.remove(guid); if (result == null) { for (Map element in _threads.values) { @@ -100,8 +139,9 @@ class ChatMessages { flush() { _messages.clear(); _reactions.clear(); + _pendingReactions.clear(); _attachments.clear(); _threads.clear(); _edits.clear(); } -} \ No newline at end of file +} diff --git a/lib/helpers/group_participant_helpers.dart b/lib/helpers/group_participant_helpers.dart new file mode 100644 index 0000000000..9f76275200 --- /dev/null +++ b/lib/helpers/group_participant_helpers.dart @@ -0,0 +1,28 @@ +import 'package:bluebubbles/database/models.dart'; + +/// Reconciles a locally cached participant list with the server's latest +/// ordered list. +/// +/// The old fetch path only handled length changes and could leave a group +/// stale when one participant was replaced by another. It also added only one +/// handle when several participants were added. Preserve existing Handle +/// objects (and their contact metadata) when the identity is unchanged, while +/// applying the server list exactly once and dropping duplicate identities. +List reconcileGroupParticipants( + List current, + List incoming, +) { + final existingByIdentity = {}; + for (final handle in current) { + existingByIdentity.putIfAbsent(handle.uniqueAddressAndService, () => handle); + } + + final seen = {}; + final reconciled = []; + for (final handle in incoming) { + final identity = handle.uniqueAddressAndService; + if (!seen.add(identity)) continue; + reconciled.add(existingByIdentity[identity] ?? handle); + } + return reconciled; +} diff --git a/lib/services/rustpush/rustpush_service.dart b/lib/services/rustpush/rustpush_service.dart index 49a7010485..72617c9f3f 100644 --- a/lib/services/rustpush/rustpush_service.dart +++ b/lib/services/rustpush/rustpush_service.dart @@ -1585,6 +1585,11 @@ class RustPushService extends GetxService { } Future updateChatParticipants(Chat c, api.MessageInst myMsg, List oldParticipants, List newParticipants) async { + final sender = myMsg.sender; + if (sender == null || sender.isEmpty) { + Logger.warn("Ignoring participant update without a sender"); + return; + } var myHandles = await api.getHandles(state: pushService.state!.client); var newP = newParticipants.filter((p) => !oldParticipants.contains(p) && !myHandles.contains(p)); var delP = oldParticipants.filter((p) => !newParticipants.contains(p)); @@ -1603,8 +1608,8 @@ class RustPushService extends GetxService { var bb = RustPushBBUtils.rustHandleToBB(item); var msg = Message( guid: useId ? myMsg.id : uuid.v4(), - isFromMe: myHandles.contains(myMsg.sender), - handleId: RustPushBBUtils.rustHandleToBB(myMsg.sender!).originalROWID!, + isFromMe: myHandles.contains(sender), + handleId: RustPushBBUtils.rustHandleToBB(sender).originalROWID!, dateCreated: DateTime.fromMillisecondsSinceEpoch(myMsg.sentTimestamp), itemType: 1, groupActionType: 0, @@ -1620,11 +1625,11 @@ class RustPushService extends GetxService { for (var item in delP) { var bb = RustPushBBUtils.rustHandleToBB(item); - var personDidLeave = item == myMsg.sender; + var personDidLeave = item == sender; var msg = Message( guid: useId ? myMsg.id : uuid.v4(), - isFromMe: myHandles.contains(myMsg.sender), - handleId: RustPushBBUtils.rustHandleToBB(myMsg.sender!).originalROWID!, + isFromMe: myHandles.contains(sender), + handleId: RustPushBBUtils.rustHandleToBB(sender).originalROWID!, dateCreated: DateTime.fromMillisecondsSinceEpoch(myMsg.sentTimestamp), itemType: personDidLeave ? 3 : 1, groupActionType: personDidLeave ? 0 : 1, @@ -1917,15 +1922,16 @@ class RustPushService extends GetxService { return msg; } else if (myMsg.message is api.Message_RenameMessage) { var msg = myMsg.message as api.Message_RenameMessage; - if (myMsg.verificationFailed) return null; + final sender = myMsg.sender; + if (myMsg.verificationFailed || chat == null || sender == null || sender.isEmpty) return null; - chat!.ckSyncState = false; + chat.ckSyncState = false; chat.save(updateCkSyncState: true); return Message( guid: myMsg.id, - isFromMe: myHandles.contains(myMsg.sender), - handleId: RustPushBBUtils.rustHandleToBB(myMsg.sender!).originalROWID!, + isFromMe: myHandles.contains(sender), + handleId: RustPushBBUtils.rustHandleToBB(sender).originalROWID!, dateCreated: DateTime.fromMillisecondsSinceEpoch(myMsg.sentTimestamp), itemType: 2, groupActionType: 2, @@ -1933,15 +1939,18 @@ class RustPushService extends GetxService { ); } else if (myMsg.message is api.Message_ChangeParticipants) { var msg = myMsg.message as api.Message_ChangeParticipants; - if (myMsg.verificationFailed) return null; - await updateChatParticipants(chat!, myMsg, myMsg.conversation!.participants, msg.field0.newParticipants); + final conversation = myMsg.conversation; + if (myMsg.verificationFailed || chat == null || conversation == null || myMsg.sender == null) return null; + await updateChatParticipants(chat, myMsg, conversation.participants, msg.field0.newParticipants); chat.groupVersion = msg.field0.groupVersion; chat.ckSyncState = false; chat.save(updateGroupVersion: true, updateCkSyncState: true); return null; } else if (myMsg.message is api.Message_IconChange) { var innerMsg = myMsg.message as api.Message_IconChange; - if (!chat!.lockChatIcon && (chat.groupVersion ?? 0) < innerMsg.field0.groupVersion) { + final sender = myMsg.sender; + if (chat == null || sender == null || sender.isEmpty) return null; + if (!chat.lockChatIcon && (chat.groupVersion ?? 0) < innerMsg.field0.groupVersion) { var file = innerMsg.field0.file; chat.groupVersion = innerMsg.field0.groupVersion; chat.ckSyncState = false; @@ -1960,16 +1969,21 @@ class RustPushService extends GetxService { } return Message( guid: myMsg.id, - isFromMe: myHandles.contains(myMsg.sender), - handleId: RustPushBBUtils.rustHandleToBB(myMsg.sender!).originalROWID!, + isFromMe: myHandles.contains(sender), + handleId: RustPushBBUtils.rustHandleToBB(sender).originalROWID!, dateCreated: DateTime.fromMillisecondsSinceEpoch(myMsg.sentTimestamp), itemType: 3, groupActionType: 1, ); } else if (myMsg.message is api.Message_React) { var msg = myMsg.message as api.Message_React; + final sender = myMsg.sender; + if (sender == null || sender.isEmpty) { + Logger.warn("Ignoring reaction without a sender"); + return null; + } if (msg.field0.embeddedProfile != null) { - handleSharedProfile(msg.field0.embeddedProfile!, myMsg.sender!, chat?.participants ?? []); + handleSharedProfile(msg.field0.embeddedProfile!, sender, chat?.participants ?? []); } String? reaction; @@ -2021,7 +2035,11 @@ class RustPushService extends GetxService { final messages = query.find(); query.close(); - final original = messages.firstWhere((msg) => (msg.stagingGuid ?? msg.guid) != myMsg.id); + final original = messages.firstWhereOrNull((msg) => (msg.stagingGuid ?? msg.guid) != myMsg.id); + if (original == null) { + Logger.warn("Ignoring extension update without a base message"); + return null; + } original.fetchAssociatedMessages(); @@ -2033,7 +2051,12 @@ class RustPushService extends GetxService { } // allow updating image - attributedBodyData = (attributedBodyData.$3.isEmpty ? original.attributedBody[0] : attributedBodyData.$1, original.text!, attributedBodyData.$3.isEmpty ? original.dbAttachments : attributedBodyData.$3); + final originalBody = original.attributedBody.firstOrNull; + if (attributedBodyData.$3.isEmpty && originalBody == null) { + Logger.warn("Ignoring extension update without message content"); + return null; + } + attributedBodyData = (attributedBodyData.$3.isEmpty ? originalBody! : attributedBodyData.$1, original.text ?? "", attributedBodyData.$3.isEmpty ? original.dbAttachments : attributedBodyData.$3); var tag = es.getLatest(msg.field0.toUuid); // updates cached value; we are latest if (tag.firstOrNull != myMsg.id) { @@ -2055,8 +2078,8 @@ class RustPushService extends GetxService { } var message = Message( guid: myMsg.id, - isFromMe: myHandles.contains(myMsg.sender), - handleId: RustPushBBUtils.rustHandleToBB(myMsg.sender!).originalROWID!, + isFromMe: myHandles.contains(sender), + handleId: RustPushBBUtils.rustHandleToBB(sender).originalROWID!, dateCreated: DateTime.fromMillisecondsSinceEpoch(myMsg.sentTimestamp), associatedMessagePart: msg.field0.toPart, associatedMessageGuid: reaction == null ? null : msg.field0.toUuid, @@ -2080,7 +2103,11 @@ class RustPushService extends GetxService { return message; } else if (myMsg.message is api.Message_Unsend) { var msg = myMsg.message as api.Message_Unsend; - var msgObj = Message.findOne(guid: msg.field0.tuuid)!; + var msgObj = Message.findOne(guid: msg.field0.tuuid); + if (msgObj == null) { + Logger.warn("Ignoring unsend for a missing message"); + return null; + } msgObj.verificationFailed = myMsg.verificationFailed; msgObj.dateEdited = DateTime.now(); var summaryInfo = msgObj.messageSummaryInfo.firstOrNull; diff --git a/lib/services/ui/chat/chat_manager.dart b/lib/services/ui/chat/chat_manager.dart index 11caeefe2f..6079d66d03 100644 --- a/lib/services/ui/chat/chat_manager.dart +++ b/lib/services/ui/chat/chat_manager.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:bluebubbles/utils/logger/logger.dart'; import 'package:bluebubbles/database/models.dart'; +import 'package:bluebubbles/helpers/group_participant_helpers.dart'; import 'package:bluebubbles/services/services.dart'; import 'package:dio/dio.dart'; import 'package:bluebubbles/services/rustpush/rustpush_service.dart'; @@ -157,18 +158,24 @@ class ChatManager extends GetxService { if (chat == null) { updatedChat.save(); chat = Chat.findOne(guid: chatGuid)!; - } else if (chat.handles.length > updatedChat.participants.length) { - final newAddresses = updatedChat.participants.map((e) => e.address); - final handlesToUse = chat.participants.where((e) => newAddresses.contains(e.address)); - chat.handles.clear(); - chat.handles.addAll(handlesToUse); - chat.handles.applyToDb(); - } else if (chat.handles.length < updatedChat.participants.length) { - final existingAddresses = chat.participants.map((e) => e.address); - final newHandle = updatedChat.participants.firstWhere((e) => !existingAddresses.contains(e.address)); - final handle = Handle.findOne(addressAndService: Tuple2(newHandle.address, chat.isIMessage ? "iMessage" : "SMS")) ?? newHandle.save(); - chat.handles.add(handle); - chat.handles.applyToDb(); + } else { + // Reconcile by address and service, not just list length. A group can + // replace one participant without changing its size, and multiple + // additions must all be reflected in the local relation. + // An incomplete server response must not erase a known participant + // list. A valid empty group is not useful for this client, so retain + // the local list when the response contains no participants. + if (updatedChat.participants.isNotEmpty || chat.participants.isEmpty) { + final handles = reconcileGroupParticipants( + chat.participants, + updatedChat.participants, + ); + if (handles.isNotEmpty) Handle.bulkSave(handles); + chat.handles.clear(); + chat.handles.addAll(handles); + chat.handles.applyToDb(); + chat.getParticipants(); + } } if (!chat.lockChatName) { chat.displayName = updatedChat.displayName; diff --git a/lib/services/ui/message/messages_service.dart b/lib/services/ui/message/messages_service.dart index 83ec39d670..74857d9927 100644 --- a/lib/services/ui/message/messages_service.dart +++ b/lib/services/ui/message/messages_service.dart @@ -120,15 +120,26 @@ class MessagesService extends GetxController { if (message.amkSessionId != null) { message.fetchAssociatedMessages(); } - // add this as a reaction if needed, update thread originators and associated messages + // Add this as a reaction if needed, update thread originators and + // associated messages. ChatMessages retains a bounded pending set when a + // reaction arrives before its base message. if (message.associatedMessageGuid != null) { - struct.getMessage(message.associatedMessageGuid!)?.associatedMessages.add(message); - getActiveMwc(message.associatedMessageGuid!)?.updateAssociatedMessage(message); + final parent = struct.getMessage(message.associatedMessageGuid!); + if (parent != null) { + getActiveMwc(message.associatedMessageGuid!)?.updateAssociatedMessage(message); + } } if (message.threadOriginatorGuid != null) { getActiveMwc(message.threadOriginatorGuid!)?.updateThreadOriginator(message); } struct.addMessages([message]); + if (message.associatedMessageGuid == null) { + // ChatMessages attaches any reactions that arrived before this message; + // refresh the active bubble after the parent is present in the struct. + for (final reaction in message.associatedMessages) { + getActiveMwc(message.guid!)?.updateAssociatedMessage(reaction); + } + } if (message.associatedMessageGuid == null) { newFunc.call(message); } @@ -271,4 +282,4 @@ class MessagesService extends GetxController { return completer.future; } -} \ No newline at end of file +} diff --git a/test/helpers/chat_messages_test.dart b/test/helpers/chat_messages_test.dart new file mode 100644 index 0000000000..734fc161da --- /dev/null +++ b/test/helpers/chat_messages_test.dart @@ -0,0 +1,37 @@ +import 'package:bluebubbles/database/global/chat_messages.dart'; +import 'package:bluebubbles/database/models.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('attaches a reaction that arrives before its base message', () { + final messages = ChatMessages(); + final reaction = Message( + guid: 'reaction-1', + associatedMessageGuid: 'base-1', + associatedMessageType: 'like', + ); + final base = Message(guid: 'base-1', text: 'hello'); + + messages.addMessages([reaction]); + messages.addMessages([base]); + + expect(messages.getMessage('base-1'), same(base)); + expect(base.hasReactions, isTrue); + expect(base.associatedMessages.map((item) => item.guid).toList(), ['reaction-1']); + }); + + test('does not duplicate a reaction when the event is replayed', () { + final messages = ChatMessages(); + final base = Message(guid: 'base-2', text: 'hello'); + final reaction = Message( + guid: 'reaction-2', + associatedMessageGuid: 'base-2', + associatedMessageType: 'like', + ); + + messages.addMessages([base, reaction, reaction]); + + expect(base.associatedMessages, hasLength(1)); + expect(base.associatedMessages.single, same(reaction)); + }); +} diff --git a/test/helpers/group_participant_helpers_test.dart b/test/helpers/group_participant_helpers_test.dart new file mode 100644 index 0000000000..1c2d04eb5f --- /dev/null +++ b/test/helpers/group_participant_helpers_test.dart @@ -0,0 +1,47 @@ +import 'package:bluebubbles/database/models.dart'; +import 'package:bluebubbles/helpers/group_participant_helpers.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('reconcileGroupParticipants', () { + test('replaces a participant when group size is unchanged', () { + final existing = Handle(address: 'old@example.com'); + final retained = Handle(address: 'kept@example.com'); + final replacement = Handle(address: 'new@example.com'); + + final result = reconcileGroupParticipants( + [existing, retained], + [replacement, Handle(address: 'kept@example.com')], + ); + + expect(result, hasLength(2)); + expect(result[0], same(replacement)); + expect(result[1], same(retained)); + }); + + test('keeps every new participant and removes duplicate identities', () { + final result = reconcileGroupParticipants( + [], + [ + Handle(address: 'one@example.com'), + Handle(address: 'two@example.com'), + Handle(address: 'two@example.com'), + Handle(address: 'three@example.com'), + ], + ); + + expect(result.map((handle) => handle.address), + ['one@example.com', 'two@example.com', 'three@example.com']); + }); + + test('distinguishes the same address on different services', () { + final sms = Handle(address: '+15550000001', service: 'SMS'); + final imessage = Handle(address: '+15550000001', service: 'iMessage'); + + final result = reconcileGroupParticipants([sms], [imessage]); + + expect(result, hasLength(1)); + expect(result.single, same(imessage)); + }); + }); +} From e7623f93cbb6942d037a50275ee5dbd879238e82 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:48:15 -0700 Subject: [PATCH 25/33] Refresh group avatars without background controller churn --- .../avatars/contact_avatar_group_widget.dart | 16 +++++++++++++++- lib/database/io/chat.dart | 6 +++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/lib/app/components/avatars/contact_avatar_group_widget.dart b/lib/app/components/avatars/contact_avatar_group_widget.dart index 1bae06b89f..957f9c713a 100644 --- a/lib/app/components/avatars/contact_avatar_group_widget.dart +++ b/lib/app/components/avatars/contact_avatar_group_widget.dart @@ -31,7 +31,7 @@ class ContactAvatarGroupWidget extends StatefulWidget { } class _ContactAvatarGroupWidgetState extends OptimizedState { - late final List participants = widget.chat?.participants ?? widget.participants ?? []; + late List participants; final Map materialGeneration = { 2: [24.5/40, 10.5/40, [Alignment.topRight, Alignment.bottomLeft]], 3: [21.5/40, 9/40, [Alignment.bottomRight, Alignment.bottomLeft, Alignment.topCenter]], @@ -41,6 +41,20 @@ class _ContactAvatarGroupWidgetState extends OptimizedState.from(widget.chat?.participants ?? widget.participants ?? []); participants.sort((a, b) { bool avatarA = a.contact?.avatar?.isNotEmpty ?? false; bool avatarB = b.contact?.avatar?.isNotEmpty ?? false; diff --git a/lib/database/io/chat.dart b/lib/database/io/chat.dart index db4c8a7df5..82ee7ed437 100644 --- a/lib/database/io/chat.dart +++ b/lib/database/io/chat.dart @@ -390,7 +390,11 @@ class Chat { RxDouble sendProgress = 0.0.obs; void handlesChanged() { - var cachedChat = cvc(this).chat; + // Group updates can arrive while the app is backgrounded. Do not create a + // full conversation controller just to mirror a relation that has no + // visible UI; that leaks controller resources and can wake rendering work. + if (!Get.isRegistered(tag: guid)) return; + var cachedChat = Get.find(tag: guid).chat; cachedChat.handles = handles; // someone can't keep their objects in sync... cachedChat._participants = []; } From 7791de6aa7d6dc708395dd14d9a8a218b192e47c Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:10:17 -0700 Subject: [PATCH 26/33] Harden partial widget initialization and profile loading --- .../avatars/contact_avatar_widget.dart | 4 ++-- .../widgets/conversation_list_fab.dart | 4 ++-- .../widgets/tile/conversation_tile.dart | 4 ++-- .../tile/pinned_conversation_tile.dart | 4 ++-- .../pages/messages_view.dart | 4 ++-- .../effects/screen_effects_widget.dart | 4 ++-- .../widgets/message/message_holder.dart | 4 ++-- .../widgets/message/misc/bubble_effects.dart | 4 ++-- .../widgets/message/text/text_bubble.dart | 4 ++-- .../timestamp/delivered_indicator.dart | 4 ++-- lib/app/wrappers/tablet_mode_wrapper.dart | 8 +++---- lib/main.dart | 15 ++++++++++++- lib/services/rustpush/rustpush_service.dart | 22 ++++++++++++++++++- 13 files changed, 59 insertions(+), 26 deletions(-) diff --git a/lib/app/components/avatars/contact_avatar_widget.dart b/lib/app/components/avatars/contact_avatar_widget.dart index 3a70a84992..4a5bb12a7a 100644 --- a/lib/app/components/avatars/contact_avatar_widget.dart +++ b/lib/app/components/avatars/contact_avatar_widget.dart @@ -40,7 +40,7 @@ class _ContactAvatarWidgetState extends OptimizedState { Contact? get contact => widget.contact ?? widget.handle?.contact; late final String _keyPrefix; String get keyPrefix => _keyPrefix; - late final StreamSubscription _avatarRefreshSubscription; + StreamSubscription? _avatarRefreshSubscription; @override void initState() { @@ -57,7 +57,7 @@ class _ContactAvatarWidgetState extends OptimizedState { @override void dispose() { - _avatarRefreshSubscription.cancel(); + _avatarRefreshSubscription?.cancel(); super.dispose(); } diff --git a/lib/app/layouts/conversation_list/widgets/conversation_list_fab.dart b/lib/app/layouts/conversation_list/widgets/conversation_list_fab.dart index ff3449eb09..9762f788fd 100644 --- a/lib/app/layouts/conversation_list/widgets/conversation_list_fab.dart +++ b/lib/app/layouts/conversation_list/widgets/conversation_list_fab.dart @@ -20,7 +20,7 @@ class ConversationListFAB extends CustomStateful { } class _ConversationListFABState extends CustomState { - late final StreamSubscription _avatarOnlySubscription; + StreamSubscription? _avatarOnlySubscription; void _focusBackToList() { if (!FocusScope.of(context).focusInDirection(TraversalDirection.left)) { @@ -70,7 +70,7 @@ class _ConversationListFABState extends CustomState { class _ConversationTileState extends CustomState with AutomaticKeepAliveClientMixin { ConversationListController get listController => controller.listController; - late final StreamSubscription _highlightSubscription; + StreamSubscription? _highlightSubscription; @override bool get wantKeepAlive => true; @@ -252,7 +252,7 @@ class _ConversationTileState extends CustomState class _PinnedConversationTileState extends CustomState { ConversationListController get listController => controller.listController; Offset? longPressPosition; - late final StreamSubscription _highlightSubscription; + StreamSubscription? _highlightSubscription; @override void initState() { @@ -68,7 +68,7 @@ class _PinnedConversationTileState extends CustomState { final RxBool latestMessageDeliveredState = false.obs; final RxBool jumpingToOldestUnread = false.obs; final Map messageFocusNodes = {}; - late final StreamSubscription _eventSubscription; + StreamSubscription? _eventSubscription; ConversationViewController get controller => widget.controller; @@ -262,7 +262,7 @@ class MessagesViewState extends OptimizedState { @override void dispose() { - _eventSubscription.cancel(); + _eventSubscription?.cancel(); if (!kIsWeb && !kIsDesktop) smartReply.close(); if (_messages.isNotEmpty) { chat.lastReadMessageGuid = _messages.first.guid; diff --git a/lib/app/layouts/conversation_view/widgets/effects/screen_effects_widget.dart b/lib/app/layouts/conversation_view/widgets/effects/screen_effects_widget.dart index 4354b7e017..f283180966 100644 --- a/lib/app/layouts/conversation_view/widgets/effects/screen_effects_widget.dart +++ b/lib/app/layouts/conversation_view/widgets/effects/screen_effects_widget.dart @@ -37,7 +37,7 @@ class _ScreenEffectsWidgetState extends OptimizedState with late final SpotlightController spotlightController; late final LaserController laserController; String screenSelected = ""; - late final StreamSubscription _effectSubscription; + StreamSubscription? _effectSubscription; @override void initState() { @@ -130,7 +130,7 @@ class _ScreenEffectsWidgetState extends OptimizedState with @override void dispose() { - _effectSubscription.cancel(); + _effectSubscription?.cancel(); super.dispose(); } diff --git a/lib/app/layouts/conversation_view/widgets/message/message_holder.dart b/lib/app/layouts/conversation_view/widgets/message/message_holder.dart index 1e15e9bba7..033d3cb0f8 100644 --- a/lib/app/layouts/conversation_view/widgets/message/message_holder.dart +++ b/lib/app/layouts/conversation_view/widgets/message/message_holder.dart @@ -85,7 +85,7 @@ class _MessageHolderState extends CustomState keys = []; bool gaveHapticFeedback = false; final RxBool tapped = false.obs; - late final StreamSubscription _avatarRefreshSubscription; + StreamSubscription? _avatarRefreshSubscription; @override void initState() { @@ -118,7 +118,7 @@ class _MessageHolderState extends CustomState { late MovieTween tween; Control controller = Control.stop; Size size = Size.zero; - late final StreamSubscription _effectSubscription; + StreamSubscription? _effectSubscription; @override void initState() { @@ -63,7 +63,7 @@ class _BubbleEffectsState extends OptimizedState { @override void dispose() { - _effectSubscription.cancel(); + _effectSubscription?.cancel(); super.dispose(); } diff --git a/lib/app/layouts/conversation_view/widgets/message/text/text_bubble.dart b/lib/app/layouts/conversation_view/widgets/message/text/text_bubble.dart index a42e901bac..0e305e8aee 100644 --- a/lib/app/layouts/conversation_view/widgets/message/text/text_bubble.dart +++ b/lib/app/layouts/conversation_view/widgets/message/text/text_bubble.dart @@ -37,7 +37,7 @@ class _TextBubbleState extends CustomState { class _DeliveredIndicatorState extends CustomState { Message get message => controller.message; bool get showAvatar => (controller.cvController?.chat ?? cm.activeChat!.chat).isGroup; - late final StreamSubscription _messageUpdateSubscription; + StreamSubscription? _messageUpdateSubscription; @override void initState() { @@ -41,7 +41,7 @@ class _DeliveredIndicatorState extends CustomState { late final RxDouble _ratio; double? _maxWidth; bool? altLayoutCache; - late final StreamSubscription _eventSubscription; - late final Worker _ratioWorker; + StreamSubscription? _eventSubscription; + Worker? _ratioWorker; get _width1 => max(min(_ratio * _maxWidth!, widget.maxWidthLeft ?? double.infinity), widget.minWidthLeft ?? double.negativeInfinity); @@ -73,8 +73,8 @@ class _TabletModeWrapperState extends OptimizedState { @override void dispose() { - _eventSubscription.cancel(); - _ratioWorker.dispose(); + _eventSubscription?.cancel(); + _ratioWorker?.dispose(); super.dispose(); } diff --git a/lib/main.dart b/lib/main.dart index cebeb8db64..3f19f01937 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -58,10 +58,23 @@ final systemTray = st.SystemTray(); String _renderIncidentId() => Random.secure().nextInt(0x7fffffff).toRadixString(16).padLeft(8, '0'); +String _redactedRenderContext(FlutterErrorDetails details) { + var context = details.context?.toDescription() ?? details.library ?? "unknown"; + context = context + .replaceAll(RegExp(r"'[^']*'"), "'[redacted]'") + .replaceAll(RegExp(r'"[^"]*"'), '"[redacted]"') + .replaceAll(RegExp(r"\s+"), " ") + .trim(); + if (context.length > 160) { + context = context.substring(0, 160); + } + return context; +} + void _logRenderError(FlutterErrorDetails details) { final incidentId = _renderIncidentId(); Logger.error( - "Render error incident=$incidentId exceptionType=${details.exception.runtimeType}", + "Render error incident=$incidentId exceptionType=${details.exception.runtimeType} context=${_redactedRenderContext(details)}", trace: details.stack, ); } diff --git a/lib/services/rustpush/rustpush_service.dart b/lib/services/rustpush/rustpush_service.dart index 72617c9f3f..1ae56f6180 100644 --- a/lib/services/rustpush/rustpush_service.dart +++ b/lib/services/rustpush/rustpush_service.dart @@ -3091,7 +3091,27 @@ class RustPushService extends GetxService { } List profilesDownloading = []; - Future handleSharedProfile(api.ShareProfileMessage shared, String sender, List targets) async { + final Map _profileRetryAfter = {}; + + Future handleSharedProfile(api.ShareProfileMessage shared, String sender, List targets) async { + final profileKey = shared.cloudKitRecordKey; + final retryAfter = _profileRetryAfter[profileKey]; + if (retryAfter != null && retryAfter.isAfter(DateTime.now())) return; + + try { + await _handleSharedProfile(shared, sender, targets); + _profileRetryAfter.remove(profileKey); + } catch (error) { + // Shared profile payloads are optional message metadata. A malformed + // CloudKit plist must not escape an unawaited profile task and disturb + // message delivery or repeatedly consume CPU while the same payload is + // replayed. + _profileRetryAfter[profileKey] = DateTime.now().add(const Duration(minutes: 10)); + Logger.warn("Skipping shared profile payload after ${error.runtimeType}"); + } + } + + Future _handleSharedProfile(api.ShareProfileMessage shared, String sender, List targets) async { var myHandles = await api.getHandles(state: pushService.state!.client); if (myHandles.contains(sender)) { for (var target in targets) { From cdac24b15c96b50f7e194818c3e58df9023c8ed3 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:12:11 -0700 Subject: [PATCH 27/33] Use upstream RustPush provisioning dependency --- .gitmodules | 2 +- rustpush | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index 8f97a8c2fc..029414ae9a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ [submodule "rustpush"] path = rustpush - url = https://github.com/Xare123/rustpush.git + url = https://github.com/OpenBubbles/rustpush.git [submodule "telephony_plus"] path = telephony_plus url = git@github.com:OpenBubbles/telephony_plus.git diff --git a/rustpush b/rustpush index 38ddc729fc..28b20cd055 160000 --- a/rustpush +++ b/rustpush @@ -1 +1 @@ -Subproject commit 38ddc729fca8fccb33d48a70a43ee51a50524432 +Subproject commit 28b20cd0551402348b4d53e346c08db0b2bf6462 From 27a2d33edac4b4880a58e96ccd9980c50269c19c Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:27:39 -0700 Subject: [PATCH 28/33] Close remaining UI lifecycle races --- lib/app/animations/balloon_classes.dart | 13 +-- lib/app/animations/celebration_class.dart | 11 ++- lib/app/animations/fireworks_classes.dart | 13 +-- lib/app/animations/laser_classes.dart | 13 +-- lib/app/animations/love_classes.dart | 13 +-- lib/app/animations/spotlight_classes.dart | 13 +-- .../tile/cupertino_conversation_tile.dart | 4 +- .../tile/material_conversation_tile.dart | 4 +- .../tile/pinned_conversation_tile.dart | 4 +- .../widgets/tile/pinned_tile_text_bubble.dart | 4 +- .../tile/samsung_conversation_tile.dart | 4 +- .../pages/messages_view.dart | 38 ++++---- .../effects/screen_effects_widget.dart | 89 ++++++++++++------- .../widgets/header/cupertino_header.dart | 4 +- .../widgets/header/material_header.dart | 4 +- lib/main.dart | 14 +-- lib/services/rustpush/rustpush_service.dart | 13 ++- 17 files changed, 157 insertions(+), 101 deletions(-) diff --git a/lib/app/animations/balloon_classes.dart b/lib/app/animations/balloon_classes.dart index f7e220dc3e..38bf95a2ee 100644 --- a/lib/app/animations/balloon_classes.dart +++ b/lib/app/animations/balloon_classes.dart @@ -14,7 +14,7 @@ class BalloonController implements Listenable { final Random random = Random(); Size windowSize; - late Ticker ticker; + Ticker? ticker; bool isPlaying = false; bool requestedToStop = false; @@ -28,6 +28,7 @@ class BalloonController implements Listenable { isPlaying = true; autoLaunchDuration = const Duration(milliseconds: 100); lastAutoLaunch = Duration.zero; + ticker?.dispose(); ticker = vsync.createTicker(update)..start(); } @@ -53,7 +54,8 @@ class BalloonController implements Listenable { void dispose() { listeners.clear(); - ticker.dispose(); + ticker?.dispose(); + ticker = null; } void update(Duration elapsedDuration) { @@ -82,8 +84,9 @@ class BalloonController implements Listenable { return element.position.y < -100 || element.position.x < -100; }); if (balloons.isEmpty && requestedToStop) { - ticker.stop(); - ticker.dispose(); + ticker?.stop(); + ticker?.dispose(); + ticker = null; isPlaying = false; requestedToStop = false; stopFunc?.call(); @@ -132,4 +135,4 @@ const List primaries = [ Colors.lightGreen, Colors.orange, Colors.yellow, -]; \ No newline at end of file +]; diff --git a/lib/app/animations/celebration_class.dart b/lib/app/animations/celebration_class.dart index 82875f76e6..dd3dddc7cb 100644 --- a/lib/app/animations/celebration_class.dart +++ b/lib/app/animations/celebration_class.dart @@ -14,6 +14,7 @@ class CelebrationController extends FireworkController { isPlaying = true; autoLaunchDuration = const Duration(milliseconds: 100); lastAutoLaunch = Duration.zero; + ticker?.dispose(); ticker = vsync.createTicker(update)..start(); } @@ -41,7 +42,8 @@ class CelebrationController extends FireworkController { @override void dispose() { listeners.clear(); - ticker.dispose(); + ticker?.dispose(); + ticker = null; } @override @@ -63,8 +65,9 @@ class CelebrationController extends FireworkController { particles.removeWhere((element) => element.alpha <= 0); if (particles.isEmpty && requestedToStop) { - ticker.stop(); - ticker.dispose(); + ticker?.stop(); + ticker?.dispose(); + ticker = null; isPlaying = false; requestedToStop = false; hasCreatedParticles = false; @@ -94,4 +97,4 @@ class CelebrationController extends FireworkController { )); } } -} \ No newline at end of file +} diff --git a/lib/app/animations/fireworks_classes.dart b/lib/app/animations/fireworks_classes.dart index 268d805d6c..baa7bae345 100644 --- a/lib/app/animations/fireworks_classes.dart +++ b/lib/app/animations/fireworks_classes.dart @@ -23,7 +23,7 @@ class FireworkController implements Listenable { Size windowSize; double globalHue = 42; - late Ticker ticker; + Ticker? ticker; bool hasCreatedParticles = false; bool isPlaying = false; @@ -41,6 +41,7 @@ class FireworkController implements Listenable { isPlaying = true; autoLaunchDuration = const Duration(milliseconds: 100); lastAutoLaunch = Duration.zero; + ticker?.dispose(); ticker = vsync.createTicker(update)..start(); } @@ -66,7 +67,8 @@ class FireworkController implements Listenable { void dispose() { listeners.clear(); - ticker.dispose(); + ticker?.dispose(); + ticker = null; } void update(Duration elapsedDuration) { @@ -113,8 +115,9 @@ class FireworkController implements Listenable { }); particles.removeWhere((element) => element.alpha <= 0); if (particles.isEmpty && requestedToStop && hasCreatedParticles) { - ticker.stop(); - ticker.dispose(); + ticker?.stop(); + ticker?.dispose(); + ticker = null; isPlaying = false; requestedToStop = false; hasCreatedParticles = false; @@ -283,4 +286,4 @@ class FireworkRocket extends FireworkObjectWithTrail { position += vp; } } -} \ No newline at end of file +} diff --git a/lib/app/animations/laser_classes.dart b/lib/app/animations/laser_classes.dart index 3644b718ac..a0d12b04c5 100644 --- a/lib/app/animations/laser_classes.dart +++ b/lib/app/animations/laser_classes.dart @@ -16,7 +16,7 @@ class LaserController implements Listenable { final Random random = Random(); Size windowSize; - late Ticker ticker; + Ticker? ticker; late Point position; late double size; double globalHue = 42; @@ -34,6 +34,7 @@ class LaserController implements Listenable { autoLaunchDuration = const Duration(milliseconds: 500); lastAutoLaunch = Duration.zero; position = Point((bubbleDimensions.left + bubbleDimensions.right) / 2, (bubbleDimensions.top + bubbleDimensions.bottom) / 2); + ticker?.dispose(); ticker = vsync.createTicker(update)..start(); } @@ -58,7 +59,8 @@ class LaserController implements Listenable { void dispose() { listeners.clear(); - ticker.dispose(); + ticker?.dispose(); + ticker = null; } void update(Duration elapsedDuration) { @@ -103,8 +105,9 @@ class LaserController implements Listenable { } if (elapsedDuration.inSeconds > 5 && requestedToStop) { - ticker.stop(); - ticker.dispose(); + ticker?.stop(); + ticker?.dispose(); + ticker = null; isPlaying = false; requestedToStop = false; laser = null; @@ -199,4 +202,4 @@ class LaserBeam { } } -enum Direction {up, down} \ No newline at end of file +enum Direction {up, down} diff --git a/lib/app/animations/love_classes.dart b/lib/app/animations/love_classes.dart index 9619ee3c69..792c8cd8a6 100644 --- a/lib/app/animations/love_classes.dart +++ b/lib/app/animations/love_classes.dart @@ -14,7 +14,7 @@ class LoveController implements Listenable { final Random random = Random(); Size windowSize; - late Ticker ticker; + Ticker? ticker; late Point position; bool isPlaying = false; @@ -30,6 +30,7 @@ class LoveController implements Listenable { autoLaunchDuration = const Duration(milliseconds: 100); lastAutoLaunch = Duration.zero; position = startPos; + ticker?.dispose(); ticker = vsync.createTicker(update)..start(); } @@ -55,7 +56,8 @@ class LoveController implements Listenable { void dispose() { listeners.clear(); - ticker.dispose(); + ticker?.dispose(); + ticker = null; } void update(Duration elapsedDuration) { @@ -75,8 +77,9 @@ class LoveController implements Listenable { heart!.update(); if (heart!.position.y < -200 && requestedToStop) { - ticker.stop(); - ticker.dispose(); + ticker?.stop(); + ticker?.dispose(); + ticker = null; isPlaying = false; requestedToStop = false; heart = null; @@ -128,4 +131,4 @@ class LoveObject { velocity *= acceleration; velocity.clamp(0.5, 2); } -} \ No newline at end of file +} diff --git a/lib/app/animations/spotlight_classes.dart b/lib/app/animations/spotlight_classes.dart index 557a04049f..dd7c7fa53d 100644 --- a/lib/app/animations/spotlight_classes.dart +++ b/lib/app/animations/spotlight_classes.dart @@ -14,7 +14,7 @@ class SpotlightController implements Listenable { final Random random = Random(); Size windowSize; - late Ticker ticker; + Ticker? ticker; late Point position; late double size; @@ -32,6 +32,7 @@ class SpotlightController implements Listenable { lastAutoLaunch = Duration.zero; position = Point((bubbleDimensions.left + bubbleDimensions.right) / 2, (bubbleDimensions.top + bubbleDimensions.bottom) / 2); size = max(bubbleDimensions.width, bubbleDimensions.height) + 50; + ticker?.dispose(); ticker = vsync.createTicker(update)..start(); } @@ -57,7 +58,8 @@ class SpotlightController implements Listenable { void dispose() { listeners.clear(); - ticker.dispose(); + ticker?.dispose(); + ticker = null; } void update(Duration elapsedDuration) { @@ -76,8 +78,9 @@ class SpotlightController implements Listenable { spotlight!.update(elapsedDuration); if (spotlight!.stop < 0 && requestedToStop) { - ticker.stop(); - ticker.dispose(); + ticker?.stop(); + ticker?.dispose(); + ticker = null; isPlaying = false; requestedToStop = false; spotlight = null; @@ -116,4 +119,4 @@ class SpotlightObject { stop = stop - 0.05; } } -} \ No newline at end of file +} diff --git a/lib/app/layouts/conversation_list/widgets/tile/cupertino_conversation_tile.dart b/lib/app/layouts/conversation_list/widgets/tile/cupertino_conversation_tile.dart index 59b0f27b1a..7317feac8f 100644 --- a/lib/app/layouts/conversation_list/widgets/tile/cupertino_conversation_tile.dart +++ b/lib/app/layouts/conversation_list/widgets/tile/cupertino_conversation_tile.dart @@ -182,7 +182,7 @@ class CupertinoTrailing extends CustomStateful { class _CupertinoTrailingState extends CustomState { DateTime? dateCreated; - late final StreamSubscription sub; + StreamSubscription? sub; String? cachedLatestMessageGuid = ""; Message? cachedLatestMessage; @@ -240,7 +240,7 @@ class _CupertinoTrailingState extends CustomState { class _MaterialTrailingState extends CustomState { DateTime? dateCreated; - late final StreamSubscription sub; + StreamSubscription? sub; String? cachedLatestMessageGuid = ""; Message? cachedLatestMessage; @@ -235,7 +235,7 @@ class _MaterialTrailingState extends CustomState { class _ChatTitleState extends CustomState { String title = "Unknown"; - late final StreamSubscription sub; + StreamSubscription? sub; String? cachedDisplayName = ""; List cachedParticipants = []; @@ -331,7 +331,7 @@ class _ChatTitleState extends CustomState { class _SamsungTrailingState extends CustomState { DateTime? dateCreated; - late final StreamSubscription sub; + StreamSubscription? sub; String? cachedLatestMessageGuid = ""; Message? cachedLatestMessage; @@ -205,7 +205,7 @@ class _SamsungTrailingState extends CustomState { final RxBool jumpingToOldestUnread = false.obs; final Map messageFocusNodes = {}; StreamSubscription? _eventSubscription; + int _lifecycleGeneration = 0; ConversationViewController get controller => widget.controller; @@ -153,29 +153,34 @@ class MessagesViewState extends OptimizedState { }); updateObx(() async { + if (!mounted) return; + final generation = _lifecycleGeneration; if (chat.isIMessage && !chat.isGroup) { getFocusState(); } - final searchMessage = (messageService.method == null) ? null : messageService.struct.messages.firstOrNull; - if (messageService.method != null) { - await messageService.loadSearchChunk( - messageService.struct.messages.first, messageService.method == "local" ? SearchMethod.local : SearchMethod.network); - } else if (messageService.struct.isEmpty) { - await messageService.loadChunk(0, controller); + final initialService = messageService; + final searchMessage = (initialService.method == null) ? null : initialService.struct.messages.firstOrNull; + if (initialService.method != null) { + await initialService.loadSearchChunk( + initialService.struct.messages.first, initialService.method == "local" ? SearchMethod.local : SearchMethod.network); + } else if (initialService.struct.isEmpty) { + await initialService.loadChunk(0, controller); } - _messages = messageService.struct.messages; + if (!mounted || generation != _lifecycleGeneration || !identical(messageService, initialService)) return; + _messages = initialService.struct.messages; _messages.sort(Message.sort); setState(() {}); _messages.forEachIndexed((i, m) { final c = mwc(m); c.cvController = controller; - listKey.currentState!.insertItem(i, duration: const Duration(milliseconds: 0)); + listKey.currentState?.insertItem(i, duration: const Duration(milliseconds: 0)); }); _syncBottomMessageFocusNode(); // scroll to message if needed if (searchMessage != null) { final index = _messages.indexWhere((element) => element.guid == searchMessage.guid); await scrollController.scrollToIndex(index, preferPosition: AutoScrollPosition.middle); + if (!mounted || generation != _lifecycleGeneration) return; scrollController.highlight(index, highlightDuration: const Duration(milliseconds: 500)); } else if (!(_messages.firstOrNull?.isFromMe ?? true)) { updateReplies(); @@ -219,6 +224,7 @@ class MessagesViewState extends OptimizedState { return; } _refreshing = true; + final generation = ++_lifecycleGeneration; try { final staleMessages = List.from(_messages); _closeMessageControllers(staleMessages); @@ -242,12 +248,13 @@ class MessagesViewState extends OptimizedState { // so its subscriptions and in-memory message structure are flushed // before registering a genuinely new service for this transcript. messageService.close(force: true); - messageService = ms(chat.guid); - messageService.init(chat, handleNewMessage, handleUpdatedMessage, handleDeletedMessage, jumpToMessage); - await messageService.loadChunk(0, controller); - if (!mounted) return; + final refreshedService = ms(chat.guid); + messageService = refreshedService; + refreshedService.init(chat, handleNewMessage, handleUpdatedMessage, handleDeletedMessage, jumpToMessage); + await refreshedService.loadChunk(0, controller); + if (!mounted || generation != _lifecycleGeneration || !identical(messageService, refreshedService)) return; - _messages = List.from(messageService.struct.messages); + _messages = List.from(refreshedService.struct.messages); _messages.sort(Message.sort); _bindMessageControllers(_messages); _syncBottomMessageFocusNode(); @@ -262,6 +269,7 @@ class MessagesViewState extends OptimizedState { @override void dispose() { + _lifecycleGeneration++; _eventSubscription?.cancel(); if (!kIsWeb && !kIsDesktop) smartReply.close(); if (_messages.isNotEmpty) { @@ -750,7 +758,7 @@ class MessagesViewState extends OptimizedState { .copyWith(color: Colors.deepPurple)), style: TextButton.styleFrom( padding: EdgeInsets.zero, - minimumSize: Size(50, 30), + minimumSize: const Size(50, 30), tapTargetSize: MaterialTapTargetSize.shrinkWrap, alignment: Alignment.centerLeft), onPressed: () async { diff --git a/lib/app/layouts/conversation_view/widgets/effects/screen_effects_widget.dart b/lib/app/layouts/conversation_view/widgets/effects/screen_effects_widget.dart index f283180966..901bcb5cae 100644 --- a/lib/app/layouts/conversation_view/widgets/effects/screen_effects_widget.dart +++ b/lib/app/layouts/conversation_view/widgets/effects/screen_effects_widget.dart @@ -38,23 +38,25 @@ class _ScreenEffectsWidgetState extends OptimizedState with late final LaserController laserController; String screenSelected = ""; StreamSubscription? _effectSubscription; + bool _controllersInitialized = false; + int _effectGeneration = 0; + + bool _isEffectActive(int generation) => mounted && generation == _effectGeneration; + + void _clearEffect(int generation) { + if (!_isEffectActive(generation)) return; + setState(() { + screenSelected = ""; + }); + } @override void initState() { super.initState(); - updateObx(() { - fireworkController = FireworkController(vsync: this, windowSize: Size(ns.width(context), context.height)); - celebrationController = CelebrationController(vsync: this, windowSize: Size(ns.width(context), context.height)); - confettiController = ConfettiController(duration: const Duration(seconds: 1)); - balloonController = BalloonController(vsync: this, windowSize: Size(ns.width(context), context.height)); - loveController = LoveController(vsync: this, windowSize: Size(ns.width(context), context.height)); - spotlightController = SpotlightController(vsync: this, windowSize: Size(ns.width(context), context.height)); - laserController = LaserController(vsync: this, windowSize: Size(ns.width(context), context.height)); - }); - _effectSubscription = eventDispatcher.stream.listen((event) async { - if (event.item1 == 'play-effect' && mounted && screenSelected.isEmpty) { + if (event.item1 == 'play-effect' && mounted && _controllersInitialized && screenSelected.isEmpty) { + final generation = ++_effectGeneration; setState(() { screenSelected = event.item2['type']; }); @@ -63,74 +65,101 @@ class _ScreenEffectsWidgetState extends OptimizedState with fireworkController.windowSize = Size(ns.width(context), context.height); fireworkController.start(); await Future.delayed(const Duration(seconds: 1)); + if (!_isEffectActive(generation)) return; fireworkController.stop(onStop: () { - setState(() { - screenSelected = ""; - }); + _clearEffect(generation); }); } else if (screenSelected == "celebration" && !celebrationController.isPlaying) { celebrationController.windowSize = Size(ns.width(context), context.height); celebrationController.start(); await Future.delayed(const Duration(seconds: 1)); + if (!_isEffectActive(generation)) return; celebrationController.stop(onStop: () { - setState(() { - screenSelected = ""; - }); + _clearEffect(generation); }); } else if (screenSelected == "balloons" && !balloonController.isPlaying) { balloonController.windowSize = Size(ns.width(context), context.height); balloonController.start(); await Future.delayed(const Duration(seconds: 1)); + if (!_isEffectActive(generation)) return; balloonController.stop(onStop: () { - setState(() { - screenSelected = ""; - }); + _clearEffect(generation); }); } else if (screenSelected == "love" && !loveController.isPlaying) { if (rect != null) { loveController.windowSize = Size(ns.width(context), context.height); loveController.start(Point((rect!.left + rect!.right) / 2, (rect!.top + rect!.bottom) / 2)); await Future.delayed(const Duration(seconds: 1)); + if (!_isEffectActive(generation)) return; loveController.stop(onStop: () { - setState(() { - screenSelected = ""; - }); + _clearEffect(generation); }); + } else { + _clearEffect(generation); } } else if (screenSelected == "spotlight" && !spotlightController.isPlaying) { if (rect != null) { spotlightController.windowSize = Size(ns.width(context), context.height); spotlightController.start(rect!); await Future.delayed(const Duration(seconds: 1)); + if (!_isEffectActive(generation)) return; spotlightController.stop(onStop: () { - setState(() { - screenSelected = ""; - }); + _clearEffect(generation); }); + } else { + _clearEffect(generation); } } else if (screenSelected == "lasers" && !laserController.isPlaying) { if (rect != null) { laserController.windowSize = Size(ns.width(context), context.height); laserController.start(rect!); await Future.delayed(const Duration(seconds: 1)); + if (!_isEffectActive(generation)) return; laserController.stop(onStop: () { - setState(() { - screenSelected = ""; - }); + _clearEffect(generation); }); + } else { + _clearEffect(generation); } } else if (screenSelected == "confetti") { confettiController.play(); await Future.delayed(const Duration(seconds: 1)); - screenSelected = ""; + _clearEffect(generation); + } else { + _clearEffect(generation); } } }); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_controllersInitialized) return; + final windowSize = Size(ns.width(context), context.height); + fireworkController = FireworkController(vsync: this, windowSize: windowSize); + celebrationController = CelebrationController(vsync: this, windowSize: windowSize); + confettiController = ConfettiController(duration: const Duration(seconds: 1)); + balloonController = BalloonController(vsync: this, windowSize: windowSize); + loveController = LoveController(vsync: this, windowSize: windowSize); + spotlightController = SpotlightController(vsync: this, windowSize: windowSize); + laserController = LaserController(vsync: this, windowSize: windowSize); + _controllersInitialized = true; + } + @override void dispose() { + _effectGeneration++; _effectSubscription?.cancel(); + if (_controllersInitialized) { + fireworkController.dispose(); + celebrationController.dispose(); + confettiController.dispose(); + balloonController.dispose(); + loveController.dispose(); + spotlightController.dispose(); + laserController.dispose(); + } super.dispose(); } diff --git a/lib/app/layouts/conversation_view/widgets/header/cupertino_header.dart b/lib/app/layouts/conversation_view/widgets/header/cupertino_header.dart index 43e4c44e23..5b60474f8c 100644 --- a/lib/app/layouts/conversation_view/widgets/header/cupertino_header.dart +++ b/lib/app/layouts/conversation_view/widgets/header/cupertino_header.dart @@ -448,7 +448,7 @@ class _ChatIconAndTitle extends CustomStateful { class _ChatIconAndTitleState extends CustomState<_ChatIconAndTitle, void, ConversationViewController> { String title = "Unknown"; - late final StreamSubscription sub; + StreamSubscription? sub; String? cachedDisplayName = ""; List cachedParticipants = []; late String cachedGuid; @@ -523,7 +523,7 @@ class _ChatIconAndTitleState extends CustomState<_ChatIconAndTitle, void, Conver @override void dispose() { - sub.cancel(); + sub?.cancel(); sub2.cancel(); super.dispose(); } diff --git a/lib/app/layouts/conversation_view/widgets/header/material_header.dart b/lib/app/layouts/conversation_view/widgets/header/material_header.dart index 3511cfafa7..6c315cc8fd 100644 --- a/lib/app/layouts/conversation_view/widgets/header/material_header.dart +++ b/lib/app/layouts/conversation_view/widgets/header/material_header.dart @@ -421,7 +421,7 @@ class _ChatIconAndTitle extends CustomStateful { class _ChatIconAndTitleState extends CustomState<_ChatIconAndTitle, void, ConversationViewController> { String title = "Unknown"; - late final StreamSubscription sub; + StreamSubscription? sub; String? cachedDisplayName = ""; List cachedParticipants = []; @@ -491,7 +491,7 @@ class _ChatIconAndTitleState extends CustomState<_ChatIconAndTitle, void, Conver @override void dispose() { - sub.cancel(); + sub?.cancel(); sub2.cancel(); super.dispose(); } diff --git a/lib/main.dart b/lib/main.dart index 3f19f01937..3a8dda1d8f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,7 +10,6 @@ import 'package:bluebubbles/helpers/backend/startup_tasks.dart'; import 'package:bluebubbles/helpers/helpers.dart'; import 'package:bluebubbles/services/network/http_overrides.dart'; import 'package:bluebubbles/utils/logger/logger.dart'; -import 'package:bluebubbles/services/network/backend_service.dart'; import 'package:bluebubbles/utils/window_effects.dart'; import 'package:bluebubbles/app/layouts/conversation_list/pages/conversation_list.dart'; import 'package:bluebubbles/app/layouts/startup/failure_to_start.dart'; @@ -29,7 +28,6 @@ import 'package:flutter/scheduler.dart' hide Priority; import 'package:flutter/services.dart'; import 'package:flutter_acrylic/flutter_acrylic.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:flutter_timezone/flutter_timezone.dart'; import 'package:get/get.dart'; import 'package:google_ml_kit/google_ml_kit.dart' hide Message; @@ -59,16 +57,8 @@ final systemTray = st.SystemTray(); String _renderIncidentId() => Random.secure().nextInt(0x7fffffff).toRadixString(16).padLeft(8, '0'); String _redactedRenderContext(FlutterErrorDetails details) { - var context = details.context?.toDescription() ?? details.library ?? "unknown"; - context = context - .replaceAll(RegExp(r"'[^']*'"), "'[redacted]'") - .replaceAll(RegExp(r'"[^"]*"'), '"[redacted]"') - .replaceAll(RegExp(r"\s+"), " ") - .trim(); - if (context.length > 160) { - context = context.substring(0, 160); - } - return context; + final contextType = details.context?.runtimeType.toString() ?? "none"; + return "contextType=$contextType"; } void _logRenderError(FlutterErrorDetails details) { diff --git a/lib/services/rustpush/rustpush_service.dart b/lib/services/rustpush/rustpush_service.dart index 1ae56f6180..33bcaff26b 100644 --- a/lib/services/rustpush/rustpush_service.dart +++ b/lib/services/rustpush/rustpush_service.dart @@ -3092,11 +3092,21 @@ class RustPushService extends GetxService { List profilesDownloading = []; final Map _profileRetryAfter = {}; + static const int _maxProfileRetryEntries = 128; + + void _pruneProfileRetryAfter(DateTime now) { + _profileRetryAfter.removeWhere((_, expiry) => !expiry.isAfter(now)); + while (_profileRetryAfter.length >= _maxProfileRetryEntries) { + _profileRetryAfter.remove(_profileRetryAfter.keys.first); + } + } Future handleSharedProfile(api.ShareProfileMessage shared, String sender, List targets) async { final profileKey = shared.cloudKitRecordKey; + final now = DateTime.now(); + _pruneProfileRetryAfter(now); final retryAfter = _profileRetryAfter[profileKey]; - if (retryAfter != null && retryAfter.isAfter(DateTime.now())) return; + if (retryAfter != null && retryAfter.isAfter(now)) return; try { await _handleSharedProfile(shared, sender, targets); @@ -3106,6 +3116,7 @@ class RustPushService extends GetxService { // CloudKit plist must not escape an unawaited profile task and disturb // message delivery or repeatedly consume CPU while the same payload is // replayed. + _pruneProfileRetryAfter(DateTime.now()); _profileRetryAfter[profileKey] = DateTime.now().add(const Duration(minutes: 10)); Logger.warn("Skipping shared profile payload after ${error.runtimeType}"); } From 1da494af39cf6f76ba9530c6b85709328ea66368 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:27:39 -0700 Subject: [PATCH 29/33] Use HTTPS for public submodules --- .gitmodules | 2 +- rustpush | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index 029414ae9a..468942beac 100644 --- a/.gitmodules +++ b/.gitmodules @@ -3,4 +3,4 @@ url = https://github.com/OpenBubbles/rustpush.git [submodule "telephony_plus"] path = telephony_plus - url = git@github.com:OpenBubbles/telephony_plus.git + url = https://github.com/OpenBubbles/telephony_plus.git diff --git a/rustpush b/rustpush index 28b20cd055..359ddf3c4c 160000 --- a/rustpush +++ b/rustpush @@ -1 +1 @@ -Subproject commit 28b20cd0551402348b4d53e346c08db0b2bf6462 +Subproject commit 359ddf3c4c6e0d34efa0219f26c41b719f9b57a3 From 92eeedbb3604a2725deebad66d1deaf9ec9f9716 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:05:34 -0700 Subject: [PATCH 30/33] Fix reply navigation and message lifecycle races --- .../widgets/message/message_holder.dart | 35 ++++++++++++------- .../widgets/message/reaction/reaction.dart | 8 ++--- lib/main.dart | 1 + .../ui/message/message_widget_controller.dart | 4 +-- lib/services/ui/message/messages_service.dart | 4 +-- 5 files changed, 28 insertions(+), 24 deletions(-) diff --git a/lib/app/layouts/conversation_view/widgets/message/message_holder.dart b/lib/app/layouts/conversation_view/widgets/message/message_holder.dart index 033d3cb0f8..a1ba1e471a 100644 --- a/lib/app/layouts/conversation_view/widgets/message/message_holder.dart +++ b/lib/app/layouts/conversation_view/widgets/message/message_holder.dart @@ -16,6 +16,7 @@ import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/popup/ import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/reaction/reaction_holder.dart'; import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/reply/reply_bubble.dart'; import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/reply/reply_line_painter.dart'; +import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/reply/reply_thread_popup.dart'; import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/text/text_bubble.dart'; import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/timestamp/delivered_indicator.dart'; import 'package:bluebubbles/app/layouts/conversation_view/widgets/message/timestamp/message_timestamp.dart'; @@ -191,6 +192,12 @@ class _MessageHolderState extends CustomState reactionsForPart(int part) { return reactions.where((s) => (s.associatedMessagePart ?? 0) == part); } + final replyTarget = replyTo; + MessageWidgetController? replyController; + if (replyTarget?.guid != null) { + replyController = getActiveMwc(replyTarget!.guid!) ?? mwc(replyTarget); + replyController.cvController ??= widget.cvController; + } /// Layout tree /// - Timestamp /// - Stack (see code comment) @@ -281,12 +288,12 @@ class _MessageHolderState extends CustomState tapped.value = !tapped.value : null, child: IgnorePointer( ignoring: widget.cvController.inSelectMode.value, diff --git a/lib/app/layouts/conversation_view/widgets/message/reaction/reaction.dart b/lib/app/layouts/conversation_view/widgets/message/reaction/reaction.dart index 860caa1bb9..34ddead58e 100644 --- a/lib/app/layouts/conversation_view/widgets/message/reaction/reaction.dart +++ b/lib/app/layouts/conversation_view/widgets/message/reaction/reaction.dart @@ -12,7 +12,6 @@ import 'package:defer_pointer/defer_pointer.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_svg/svg.dart'; import 'package:get/get.dart'; import 'package:universal_io/io.dart'; @@ -34,8 +33,7 @@ class ReactionWidget extends StatefulWidget { class ReactionWidgetState extends OptimizedState { late Message reaction = widget.reaction; - late final StreamSubscription sub; - bool hasStream = false; + StreamSubscription? sub; List? get reactions => widget.reactions; bool get reactionIsFromMe => reaction.isFromMe!; @@ -70,8 +68,6 @@ class ReactionWidgetState extends OptimizedState { getActiveMwc(widget.message!.guid!)?.updateAssociatedMessage(reaction, updateHolder: false); } }); - - hasStream = true; } else if (kIsWeb && widget.message != null) { sub = WebListeners.messageUpdate.listen((tuple) { final _message = tuple.item1; @@ -125,7 +121,7 @@ class ReactionWidgetState extends OptimizedState { @override void dispose() { - if (!kIsWeb && hasStream) sub.cancel(); + sub?.cancel(); super.dispose(); } diff --git a/lib/main.dart b/lib/main.dart index 3a8dda1d8f..478f88a897 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -65,6 +65,7 @@ void _logRenderError(FlutterErrorDetails details) { final incidentId = _renderIncidentId(); Logger.error( "Render error incident=$incidentId exceptionType=${details.exception.runtimeType} context=${_redactedRenderContext(details)}", + error: details.exception, trace: details.stack, ); } diff --git a/lib/services/ui/message/message_widget_controller.dart b/lib/services/ui/message/message_widget_controller.dart index 2fb6a8edcc..8f60c98076 100644 --- a/lib/services/ui/message/message_widget_controller.dart +++ b/lib/services/ui/message/message_widget_controller.dart @@ -9,10 +9,8 @@ import 'package:bluebubbles/helpers/helpers.dart'; import 'package:bluebubbles/database/database.dart'; import 'package:bluebubbles/database/models.dart'; import 'package:bluebubbles/services/services.dart'; -import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:get/get.dart'; -import 'package:bluebubbles/utils/logger/logger.dart'; MessageWidgetController mwc(Message message) => Get.isRegistered(tag: message.guid) ? Get.find(tag: message.guid) @@ -31,7 +29,7 @@ class MessageWidgetController extends StatefulController with GetSingleTickerPro String? newMessageGuid; ConversationViewController? cvController; late final String tag; - late final StreamSubscription? sub; + StreamSubscription? sub; bool built = false; static const maxBubbleSizeFactor = 0.75; diff --git a/lib/services/ui/message/messages_service.dart b/lib/services/ui/message/messages_service.dart index 74857d9927..904d0a742a 100644 --- a/lib/services/ui/message/messages_service.dart +++ b/lib/services/ui/message/messages_service.dart @@ -19,7 +19,7 @@ String? lastReloadedChat() => Get.isRegistered(tag: 'lastReloadedChat') class MessagesService extends GetxController { static final Map cachedBubbleSizes = {}; late Chat chat; - late StreamSubscription countSub; + StreamSubscription? countSub; final ChatMessages struct = ChatMessages(); late Function(Message) newFunc; late Function(Message, {String? oldGuid}) updateFunc; @@ -85,7 +85,7 @@ class MessagesService extends GetxController { @override void onClose() { if (_init) { - countSub.cancel(); + countSub?.cancel(); } _init = false; super.onClose(); From d31edcfe8dbe28fea332af41d4c18446ba7dbd71 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Sat, 25 Jul 2026 05:54:02 -0700 Subject: [PATCH 31/33] Fix recycled message controller remounts --- lib/app/wrappers/stateful_boilerplate.dart | 6 ++-- test/wrappers/stateful_boilerplate_test.dart | 35 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 test/wrappers/stateful_boilerplate_test.dart diff --git a/lib/app/wrappers/stateful_boilerplate.dart b/lib/app/wrappers/stateful_boilerplate.dart index cfff176107..ec1868f2f5 100644 --- a/lib/app/wrappers/stateful_boilerplate.dart +++ b/lib/app/wrappers/stateful_boilerplate.dart @@ -8,7 +8,7 @@ import 'package:get/get.dart'; /// [GetxController] with support for optimized state management class StatefulController extends GetxController { final Map> updateWidgetFunctions = {}; - late final void Function(VoidCallback) updateObx; + late void Function(VoidCallback) updateObx; void updateWidgets(Object? arg) { updateWidgetFunctions[T]?.forEach((e) => e.call(arg)); @@ -50,8 +50,8 @@ abstract class CustomState { + const _TestWidget({required super.parentController}); + + @override + State<_TestWidget> createState() => _TestWidgetState(); +} + +class _TestWidgetState extends CustomState<_TestWidget, void, _TestController> { + @override + void initState() { + forceDelete = false; + super.initState(); + } + + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} + +void main() { + testWidgets('rebinds updates when a retained controller row is remounted', (tester) async { + final controller = _TestController(); + + await tester.pumpWidget(MaterialApp(home: _TestWidget(parentController: controller))); + await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink())); + await tester.pumpWidget(MaterialApp(home: _TestWidget(parentController: controller))); + + expect(tester.takeException(), isNull); + }); +} From d6eba3603aeab9b03bd7aa4f45820a02358a0116 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Sat, 25 Jul 2026 05:57:19 -0700 Subject: [PATCH 32/33] Make conversation keyboard dismissal reliable --- .../pages/conversation_view.dart | 27 ++++++++++++++----- .../ui/chat/conversation_view_controller.dart | 11 +++++++- .../conversation_view_controller_test.dart | 26 ++++++++++++++++++ 3 files changed, 57 insertions(+), 7 deletions(-) create mode 100644 test/services/conversation_view_controller_test.dart diff --git a/lib/app/layouts/conversation_view/pages/conversation_view.dart b/lib/app/layouts/conversation_view/pages/conversation_view.dart index 2d5ec90f7f..5844f077e0 100644 --- a/lib/app/layouts/conversation_view/pages/conversation_view.dart +++ b/lib/app/layouts/conversation_view/pages/conversation_view.dart @@ -95,6 +95,12 @@ class ConversationViewState extends OptimizedState { canPop: false, onPopInvoked: (didPop) async { if (didPop) return; + if (controller.keyboardOpen || + controller.focusNode.hasFocus || + controller.subjectFocusNode.hasFocus) { + controller.dismissKeyboard(); + return; + } if (controller.inSelectMode.value) { controller.inSelectMode.value = false; controller.selected.clear(); @@ -157,10 +163,20 @@ class ConversationViewState extends OptimizedState { Expanded( child: Stack( children: [ - MessagesView( - key: Key(chat.guid), - customService: widget.customService, - controller: controller, + Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (_) { + if (controller.keyboardOpen || + controller.focusNode.hasFocus || + controller.subjectFocusNode.hasFocus) { + controller.dismissKeyboard(); + } + }, + child: MessagesView( + key: Key(chat.guid), + customService: widget.customService, + controller: controller, + ), ), Align( alignment: iOS ? Alignment.bottomRight : Alignment.bottomCenter, @@ -220,8 +236,7 @@ class ConversationViewState extends OptimizedState { if (ss.settings.swipeToCloseKeyboard.value && details.delta.dy > 0 && controller.keyboardOpen) { - controller.focusNode.unfocus(); - controller.subjectFocusNode.unfocus(); + controller.dismissKeyboard(); } else if (ss.settings.swipeToOpenKeyboard.value && details.delta.dy < 0 && !controller.keyboardOpen) { diff --git a/lib/services/ui/chat/conversation_view_controller.dart b/lib/services/ui/chat/conversation_view_controller.dart index 295f44b145..6f3e6c41ac 100644 --- a/lib/services/ui/chat/conversation_view_controller.dart +++ b/lib/services/ui/chat/conversation_view_controller.dart @@ -11,6 +11,7 @@ import 'package:bluebubbles/services/services.dart'; import 'package:emojis/emoji.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; import 'package:get/get.dart'; import 'package:google_ml_kit/google_ml_kit.dart' hide Message; @@ -99,6 +100,7 @@ class ConversationViewController extends StatefulController with GetSingleTicker bool keyboardOpen = false; double _keyboardOffset = 0; Timer? _scrollDownDebounce; + StreamSubscription? keyboardVisibilitySubscription; Future Function(Tuple7, AttributedBody, String, String?, int?, String?, PayloadData?>, bool, DateTime?)? sendFunc; bool isProcessingImage = false; @@ -158,7 +160,7 @@ class ConversationViewController extends StatefulController with GetSingleTicker updateContactInfo(); textController.mentionables = mentionables; - KeyboardVisibilityController().onChange.listen((bool visible) async { + keyboardVisibilitySubscription = KeyboardVisibilityController().onChange.listen((bool visible) async { keyboardOpen = visible; if (scrollController.hasClients) { _keyboardOffset = scrollController.offset; @@ -228,9 +230,16 @@ class ConversationViewController extends StatefulController with GetSingleTicker scrollController.dispose(); headerBackFocusNode.dispose(); shareSubscription?.cancel(); + keyboardVisibilitySubscription?.cancel(); super.onClose(); } + void dismissKeyboard() { + focusNode.unfocus(); + subjectFocusNode.unfocus(); + SystemChannels.textInput.invokeMethod('TextInput.hide'); + } + Future scrollToBottom() async { if (scrollController.positions.isNotEmpty && scrollController.positions.first.extentBefore > 0) { await scrollController.animateTo( diff --git a/test/services/conversation_view_controller_test.dart b/test/services/conversation_view_controller_test.dart new file mode 100644 index 0000000000..f278b29979 --- /dev/null +++ b/test/services/conversation_view_controller_test.dart @@ -0,0 +1,26 @@ +import 'package:bluebubbles/database/models.dart'; +import 'package:bluebubbles/services/ui/chat/conversation_view_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('dismissKeyboard releases the conversation composer focus', (tester) async { + final controller = ConversationViewController(Chat(guid: 'iMessage;-;keyboard-test')); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TextField(focusNode: controller.focusNode), + ), + ), + ); + controller.focusNode.requestFocus(); + await tester.pump(); + expect(controller.focusNode.hasFocus, isTrue); + + controller.dismissKeyboard(); + await tester.pump(); + + expect(controller.focusNode.hasFocus, isFalse); + }); +} From 6028e1619f66b1067cbe70a74bbcc846c0bbecb4 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Sat, 25 Jul 2026 06:06:42 -0700 Subject: [PATCH 33/33] Load complete reply threads with originators --- .../message/reply/reply_thread_popup.dart | 95 ++++++++++++------- lib/database/global/chat_messages.dart | 24 ++++- lib/database/html/message.dart | 3 +- lib/database/io/message.dart | 3 +- lib/services/ui/message/messages_service.dart | 6 +- test/helpers/chat_messages_test.dart | 67 ++++++++++++- 6 files changed, 155 insertions(+), 43 deletions(-) diff --git a/lib/app/layouts/conversation_view/widgets/message/reply/reply_thread_popup.dart b/lib/app/layouts/conversation_view/widgets/message/reply/reply_thread_popup.dart index f8f5ca31fc..08e872df58 100644 --- a/lib/app/layouts/conversation_view/widgets/message/reply/reply_thread_popup.dart +++ b/lib/app/layouts/conversation_view/widgets/message/reply/reply_thread_popup.dart @@ -5,7 +5,6 @@ import 'package:bluebubbles/helpers/helpers.dart'; import 'package:bluebubbles/database/database.dart'; import 'package:bluebubbles/database/models.dart'; import 'package:bluebubbles/services/services.dart'; -import 'package:collection/collection.dart'; import 'package:defer_pointer/defer_pointer.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -13,9 +12,36 @@ import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:flutter_acrylic/flutter_acrylic.dart'; -void showReplyThread(BuildContext context, Message message, MessagePart part, MessagesService service, ConversationViewController cvController) { +Future showReplyThread(BuildContext context, Message message, MessagePart part, MessagesService service, ConversationViewController cvController) async { + cvController.dismissKeyboard(); final originatorPart = message.threadOriginatorGuid != null ? message.normalizedThreadPart : part.part; - final _messages = service.struct.threads(message.threadOriginatorGuid ?? message.guid!, originatorPart); + final originatorGuid = message.threadOriginatorGuid ?? message.guid!; + final messagesByGuid = {}; + + if (!kIsWeb) { + final originator = Message.findOne(guid: originatorGuid); + final query = Database.messages.query(Message_.threadOriginatorGuid.equals(originatorGuid)).build(); + final storedReplies = query.find(); + query.close(); + + for (final stored in [ + if (originator != null) originator, + ...storedReplies, + ]) { + if (stored.guid == null || stored.associatedMessageGuid != null) continue; + if (stored.guid != originatorGuid && stored.normalizedThreadPart != originatorPart) continue; + stored.fetchAttachments(); + stored.fetchAssociatedMessages(service: service); + stored.handle = stored.getHandle(); + messagesByGuid[stored.guid!] = stored; + } + } + + // Prefer live in-memory messages when both sources contain the same item. + for (final live in service.struct.threads(originatorGuid, originatorPart)) { + if (live.guid != null) messagesByGuid[live.guid!] = live; + } + final _messages = messagesByGuid.values.toList(); _messages.sort((a, b) => Message.sort(a, b, descending: false)); _buildThreadView(_messages, originatorPart, cvController, context); } @@ -92,39 +118,40 @@ void _buildThreadView(List _messages, int? originatorPart, Conversation ), Container( child: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Center( - child: SingleChildScrollView( - controller: controller, - child: Column( - children: _messages.mapIndexed((index, e) => GestureDetector( - onTap: () { - Navigator.of(context).pop(); - if (originatorPart == null && ss.settings.skin.value == Skins.iOS) { - // pop twice to remove convo details page - Navigator.of(context).pop(); - } - ms(cvController.chat.guid).jumpToMessage.call(e.guid!); - }, - child: AbsorbPointer( - absorbing: true, - child: Padding( - padding: const EdgeInsets.only(left: 5.0, right: 5.0), - child: MessageHolder( - cvController: cvController, - message: _messages[index], - oldMessageGuid: index > 0 ? _messages[index - 1].guid : null, - newMessageGuid: index < _messages.length - 1 ? _messages[index + 1].guid : null, - isReplyThread: true, - replyPart: index == 0 ? originatorPart : null, - ), - ), + child: ListView.builder( + controller: controller, + padding: const EdgeInsets.symmetric(vertical: 16), + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + itemCount: _messages.length, + itemBuilder: (context, index) { + final threadMessage = _messages[index]; + final messageController = getActiveMwc(threadMessage.guid!) ?? mwc(threadMessage); + messageController.cvController = cvController; + return GestureDetector( + onTap: () { + Navigator.of(context).pop(); + if (originatorPart == null && ss.settings.skin.value == Skins.iOS) { + // pop twice to remove convo details page + Navigator.of(context).pop(); + } + ms(cvController.chat.guid).jumpToMessage.call(threadMessage.guid!); + }, + child: AbsorbPointer( + absorbing: true, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 5), + child: MessageHolder( + cvController: cvController, + message: threadMessage, + oldMessageGuid: index > 0 ? _messages[index - 1].guid : null, + newMessageGuid: index < _messages.length - 1 ? _messages[index + 1].guid : null, + isReplyThread: true, + replyPart: index == 0 ? originatorPart : null, ), - )).toList(), + ), ), - ), - ), + ); + }, ), ), ), diff --git a/lib/database/global/chat_messages.dart b/lib/database/global/chat_messages.dart index 887a0549cc..399dad255b 100644 --- a/lib/database/global/chat_messages.dart +++ b/lib/database/global/chat_messages.dart @@ -15,9 +15,18 @@ class ChatMessages { List get messages => _messages.values.toList(); List get reactions => _reactions.values.toList(); List get attachments => _attachments.values.toList(); - List threads(String originatorGuid, int originatorPart, {bool returnOriginator = true}) => - _threads[originatorGuid]?.values.where((e) => - (e.normalizedThreadPart == originatorPart && e.guid != originatorGuid) || (returnOriginator ? e.guid == originatorGuid : false)).toList() ?? []; + List threads(String originatorGuid, int originatorPart, {bool returnOriginator = true}) { + final thread = _threads[originatorGuid]; + if (returnOriginator && thread?[originatorGuid] == null) { + final originator = _messages[originatorGuid]; + if (originator != null) { + addThreadOriginator(originator); + } + } + return _threads[originatorGuid]?.values.where((e) => + (e.normalizedThreadPart == originatorPart && e.guid != originatorGuid) || + (returnOriginator && e.guid == originatorGuid)).toList() ?? []; + } void addMessages(List __messages) { for (Message m in __messages) { @@ -58,8 +67,13 @@ class ChatMessages { } if (m.threadOriginatorGuid != null && !m.guid!.startsWith("temp") && m.associatedMessageGuid == null) { // add threaded messages - _threads[m.threadOriginatorGuid!] ??= {}; - _threads[m.threadOriginatorGuid]![m.guid!] = m; + final originatorGuid = m.threadOriginatorGuid!; + _threads[originatorGuid] ??= {}; + _threads[originatorGuid]![m.guid!] = m; + final loadedOriginator = _messages[originatorGuid]; + if (loadedOriginator != null) { + _threads[originatorGuid]![originatorGuid] = loadedOriginator; + } } if (_threads.keys.contains(m.guid)) { // add thread 'originator' diff --git a/lib/database/html/message.dart b/lib/database/html/message.dart index d0e2319c42..ee61d3f3bd 100644 --- a/lib/database/html/message.dart +++ b/lib/database/html/message.dart @@ -432,7 +432,8 @@ class Message { return isFromMe != newerMessage.isFromMe; } - int get normalizedThreadPart => threadOriginatorPart == null ? 0 : int.parse(threadOriginatorPart![0]); + int get normalizedThreadPart => + int.tryParse(threadOriginatorPart?.split(':').first ?? '') ?? 0; bool connectToUpper() => threadOriginatorGuid != null; diff --git a/lib/database/io/message.dart b/lib/database/io/message.dart index 7140998410..38d71c0a70 100644 --- a/lib/database/io/message.dart +++ b/lib/database/io/message.dart @@ -1461,7 +1461,8 @@ class Message { return "$part:${run.range[0]}:${run.range[1]}"; } - int get normalizedThreadPart => threadOriginatorPart == null ? 0 : int.parse(threadOriginatorPart![0]); + int get normalizedThreadPart => + int.tryParse(threadOriginatorPart?.split(':').first ?? '') ?? 0; bool connectToUpper() => threadOriginatorGuid != null; diff --git a/lib/services/ui/message/messages_service.dart b/lib/services/ui/message/messages_service.dart index 904d0a742a..5be3c400a1 100644 --- a/lib/services/ui/message/messages_service.dart +++ b/lib/services/ui/message/messages_service.dart @@ -193,7 +193,11 @@ class MessagesService extends GetxController { for (Message m in _messages.where((e) => e.threadOriginatorGuid != null)) { // see if the originator is already loaded final guid = m.threadOriginatorGuid!; - if (struct.getMessage(guid) != null) continue; + final loadedOriginator = struct.getMessage(guid); + if (loadedOriginator != null) { + struct.addThreadOriginator(loadedOriginator); + continue; + } // if not, fetch local and add to data final threadOriginator = Message.findOne(guid: guid); if (threadOriginator != null) { diff --git a/test/helpers/chat_messages_test.dart b/test/helpers/chat_messages_test.dart index 734fc161da..c769fce9de 100644 --- a/test/helpers/chat_messages_test.dart +++ b/test/helpers/chat_messages_test.dart @@ -1,4 +1,3 @@ -import 'package:bluebubbles/database/global/chat_messages.dart'; import 'package:bluebubbles/database/models.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -34,4 +33,70 @@ void main() { expect(base.associatedMessages, hasLength(1)); expect(base.associatedMessages.single, same(reaction)); }); + + test('includes the originator when it is loaded before its replies', () { + final messages = ChatMessages(); + final originator = Message(guid: 'thread-1', text: 'originator'); + final reply = Message( + guid: 'reply-1', + text: 'reply', + threadOriginatorGuid: 'thread-1', + threadOriginatorPart: '0', + ); + + messages.addMessages([originator, reply]); + + expect( + messages.threads('thread-1', 0).map((message) => message.guid), + containsAll(['thread-1', 'reply-1']), + ); + }); + + test('includes the originator when it is loaded after its replies', () { + final messages = ChatMessages(); + final originator = Message(guid: 'thread-2', text: 'originator'); + final reply = Message( + guid: 'reply-2', + text: 'reply', + threadOriginatorGuid: 'thread-2', + threadOriginatorPart: '0', + ); + + messages.addMessages([reply, originator]); + + expect( + messages.threads('thread-2', 0).map((message) => message.guid), + containsAll(['thread-2', 'reply-2']), + ); + }); + + test('returns every message in a lengthy reply chain', () { + final messages = ChatMessages(); + final originator = Message(guid: 'thread-long', text: 'originator'); + final replies = List.generate( + 100, + (index) => Message( + guid: 'reply-long-$index', + text: 'reply $index', + threadOriginatorGuid: 'thread-long', + threadOriginatorPart: '0', + ), + ); + + messages.addMessages([originator, ...replies]); + + final thread = messages.threads('thread-long', 0); + expect(thread, hasLength(101)); + expect(thread.map((message) => message.guid).toSet(), hasLength(101)); + }); + + test('keeps multi-digit reply part indexes intact', () { + final reply = Message( + guid: 'reply-part-12', + threadOriginatorGuid: 'thread-multipart', + threadOriginatorPart: '12:4:8', + ); + + expect(reply.normalizedThreadPart, 12); + }); }