From a7472b707383ccdae1eb9e1b94d9537eafcc1b29 Mon Sep 17 00:00:00 2001 From: Eric Poulsen Date: Sun, 21 Jun 2026 19:28:01 -0700 Subject: [PATCH 1/2] Reduce Android ANR risk and fix notification spam on message sync Cache SharedPreferences instances in MeshBleService.onCreate instead of looking them up by name on every tick. Skip the getActiveNotifications Binder IPC call for 10 seconds after a successful startForeground to reduce main-thread contention during screen wake. Batch message notifications during the sync phase: suppress individual notifications while syncing, then show them individually if fewer than 5 arrived, or show a single summary otherwise. Also suppress notifications for messages older than 2 minutes to prevent room server replays from buzzing after sync completes. --- .../team/team_flutter/mesh/MeshBleService.kt | 28 ++++---- android/gradle.properties | 4 ++ lib/repositories/message_repository.dart | 3 + .../message_notification_service.dart | 66 +++++++++++++++++++ lib/viewmodels/connection_viewmodel.dart | 6 +- 5 files changed, 94 insertions(+), 13 deletions(-) diff --git a/android/app/src/main/kotlin/com/meshcore/team/team_flutter/mesh/MeshBleService.kt b/android/app/src/main/kotlin/com/meshcore/team/team_flutter/mesh/MeshBleService.kt index 674f17e..b2efd87 100644 --- a/android/app/src/main/kotlin/com/meshcore/team/team_flutter/mesh/MeshBleService.kt +++ b/android/app/src/main/kotlin/com/meshcore/team/team_flutter/mesh/MeshBleService.kt @@ -21,6 +21,7 @@ import android.bluetooth.le.ScanSettings import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.SharedPreferences import android.content.pm.ApplicationInfo import android.content.pm.ServiceInfo import android.content.pm.PackageManager @@ -271,6 +272,7 @@ class MeshBleService : Service() { private var notificationTickRunnable: Runnable? = null private var lastNotificationText: String? = null private var lastNotificationPresent: Boolean? = null + private var lastStartForegroundMs: Long = 0L // Only run as a foreground service (persistent notification) once connected. private var isForegroundActive: Boolean = false @@ -387,6 +389,8 @@ class MeshBleService : Service() { private var telemetryWakeLock: PowerManager.WakeLock? = null private lateinit var fusedLocationClient: FusedLocationProviderClient + private lateinit var prefs: SharedPreferences + private lateinit var flutterPrefs: SharedPreferences override fun onCreate() { super.onCreate() @@ -394,6 +398,8 @@ class MeshBleService : Service() { bluetoothAdapter = (getSystemService(BLUETOOTH_SERVICE) as android.bluetooth.BluetoothManager).adapter scanner = bluetoothAdapter?.bluetoothLeScanner fusedLocationClient = LocationServices.getFusedLocationProviderClient(this) + prefs = getSharedPreferences(prefsName, MODE_PRIVATE) + flutterPrefs = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE) logI( "adapter/scanner init", @@ -407,7 +413,6 @@ class MeshBleService : Service() { ensureNotificationChannel() // Attempt restore - val prefs = getSharedPreferences(prefsName, MODE_PRIVATE) autoReconnect = prefs.getBoolean(keyAutoReconnect, false) targetAddress = prefs.getString(keyLastAddress, null) logI( @@ -458,7 +463,6 @@ class MeshBleService : Service() { // Also disable auto-reconnect persistence to avoid resurrecting the // foreground notification after an explicit stop. - val prefs = getSharedPreferences(prefsName, MODE_PRIVATE) prefs.edit().putBoolean(keyAutoReconnect, false).apply() autoReconnect = false reconnectAttempt = 0 @@ -479,7 +483,6 @@ class MeshBleService : Service() { logI("actionUserStop") // Disable reconnect now and across restarts - val prefs = getSharedPreferences(prefsName, MODE_PRIVATE) prefs.edit().putBoolean(keyAutoReconnect, false).remove(keyLastAddress).apply() autoReconnect = false targetAddress = null @@ -520,7 +523,6 @@ class MeshBleService : Service() { actionDisconnect -> { logI("actionDisconnect") // Treat explicit disconnect as manual: disable auto-reconnect - val prefs = getSharedPreferences(prefsName, MODE_PRIVATE) prefs.edit().putBoolean(keyAutoReconnect, false).apply() autoReconnect = false reconnectAttempt = 0 @@ -825,7 +827,6 @@ class MeshBleService : Service() { MeshBleState.deviceName = null if (!fromRestore) { - val prefs = getSharedPreferences(prefsName, MODE_PRIVATE) prefs.edit() .putString(keyLastAddress, address) .putBoolean(keyAutoReconnect, true) @@ -1442,17 +1443,16 @@ class MeshBleService : Service() { } private fun trackingNotificationLine(): String? { - val prefs = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE) - val telemetryEnabled = prefs.getBoolean("flutter.telemetry_enabled", false) + val telemetryEnabled = flutterPrefs.getBoolean("flutter.telemetry_enabled", false) if (!telemetryEnabled) return null - val companionKey = prefs.getString("flutter.current_companion_public_key", null) + val companionKey = flutterPrefs.getString("flutter.current_companion_public_key", null) val perCompanionName = if (!companionKey.isNullOrBlank()) { - prefs.getString("flutter.telemetry_channel_name_$companionKey", null) + flutterPrefs.getString("flutter.telemetry_channel_name_$companionKey", null) } else { null } - val channelName = perCompanionName ?: prefs.getString("flutter.telemetry_channel_name", null) + val channelName = perCompanionName ?: flutterPrefs.getString("flutter.telemetry_channel_name", null) val normalizedName = channelName?.trim()?.takeIf { it.isNotEmpty() } if (normalizedName != null) { @@ -1460,11 +1460,11 @@ class MeshBleService : Service() { } val perCompanionHash = if (!companionKey.isNullOrBlank()) { - prefs.getString("flutter.telemetry_channel_hash_$companionKey", null) + flutterPrefs.getString("flutter.telemetry_channel_hash_$companionKey", null) } else { null } - val hash = perCompanionHash ?: prefs.getString("flutter.telemetry_channel_hash", null) + val hash = perCompanionHash ?: flutterPrefs.getString("flutter.telemetry_channel_hash", null) val normalizedHash = hash?.trim()?.takeIf { it.isNotEmpty() } ?: return null return "Tracking enabled on channel:$normalizedHash" @@ -1505,6 +1505,7 @@ class MeshBleService : Service() { ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE }, ) + lastStartForegroundMs = android.os.SystemClock.elapsedRealtime() logI( "startForeground(refreshed)", mapOf( @@ -1530,6 +1531,9 @@ class MeshBleService : Service() { private fun isForegroundNotificationPresent(): Boolean { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true + // Skip the Binder IPC if we asserted the notification recently. After + // this window expires we do one real check, catching any user dismissal. + if (android.os.SystemClock.elapsedRealtime() - lastStartForegroundMs < 10_000L) return true return try { val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager manager.activeNotifications.any { it.id == notificationId } diff --git a/android/gradle.properties b/android/gradle.properties index fbee1d8..d5da727 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,2 +1,6 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/lib/repositories/message_repository.dart b/lib/repositories/message_repository.dart index ebb77e0..94d686e 100644 --- a/lib/repositories/message_repository.dart +++ b/lib/repositories/message_repository.dart @@ -179,6 +179,9 @@ class MessageRepository { /// - App responds with SYNC_NEXT_MESSAGE to retrieve each message /// - Continues until RESP_NO_MORE_MESSAGES received /// - Direct messages (code 16) are also pushed directly + void beginNotificationSync() => _notificationService.beginSync(); + Future endNotificationSync() => _notificationService.endSync(); + void startPushListener() { if (_isListeningForPushes) { debugPrint('[MessageSync] Already listening for push notifications'); diff --git a/lib/services/message_notification_service.dart b/lib/services/message_notification_service.dart index 9c9fad2..9c3375c 100644 --- a/lib/services/message_notification_service.dart +++ b/lib/services/message_notification_service.dart @@ -27,6 +27,11 @@ class MessageNotificationService { int _notificationIdCounter = 2000; + static const int _syncBatchThreshold = 5; + bool _isSyncing = false; + final List<({MessageData message, String channelName, bool isDirect})> + _syncQueue = []; + MessageNotificationService({ required FlutterLocalNotificationsPlugin notifications, required SettingsService settings, @@ -94,6 +99,31 @@ class MessageNotificationService { debugPrint('βœ… Message notification service initialized'); } + void beginSync() { + _isSyncing = true; + _syncQueue.clear(); + } + + Future endSync() async { + _isSyncing = false; + final queued = List.of(_syncQueue); + _syncQueue.clear(); + + if (queued.isEmpty) return; + + if (queued.length < _syncBatchThreshold) { + for (final item in queued) { + await _showNotificationInternal( + message: item.message, + channelName: item.channelName, + isDirect: item.isDirect, + ); + } + } else { + await _showSyncSummaryNotification(queued.length); + } + } + /// Show notification for a new message Future showMessageNotification({ required MessageData message, @@ -124,6 +154,19 @@ class MessageNotificationService { return; } + // Accumulate during sync; flush logic is handled by endSync(). + if (_isSyncing) { + _syncQueue.add((message: message, channelName: channelName, isDirect: isDirect)); + return; + } + + // Suppress replayed history arriving outside the sync window (e.g. room server). + final ageMs = DateTime.now().millisecondsSinceEpoch - message.timestamp; + if (ageMs > const Duration(minutes: 2).inMilliseconds) { + debugPrint('πŸ“¬ Suppressing stale message notification (age ${ageMs}ms)'); + return; + } + // Show the notification await _showNotificationInternal( message: message, @@ -207,6 +250,29 @@ class MessageNotificationService { ); } + Future _showSyncSummaryNotification(int count) async { + const notificationId = 1999; + const androidDetails = AndroidNotificationDetails( + channelIdMessages, + 'Channel Messages', + importance: Importance.high, + priority: Priority.high, + playSound: true, + enableVibration: true, + ); + const iosDetails = DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + ); + await _notifications.show( + notificationId, + 'New messages', + 'You have $count new messages', + const NotificationDetails(android: androidDetails, iOS: iosDetails), + ); + } + /// Show notification for a new waypoint Future showWaypointNotification({ required String waypointName, diff --git a/lib/viewmodels/connection_viewmodel.dart b/lib/viewmodels/connection_viewmodel.dart index 2387b85..bae3a7b 100644 --- a/lib/viewmodels/connection_viewmodel.dart +++ b/lib/viewmodels/connection_viewmodel.dart @@ -842,8 +842,9 @@ class ConnectionViewModel extends ChangeNotifier { // Start listening for PUSH_MSG_WAITING (device will push when messages available) _messageRepository.startPushListener(); + _messageRepository.beginNotificationSync(); - // Actively pull any queued messages now (some firmware won’t emit a PUSH immediately). + // Actively pull any queued messages now (some firmware won't emit a PUSH immediately). final pulled = await _messageRepository.syncMessagesNow(); debugPrint( '[ConnectionVM] βœ… Phase 3 complete: pulled $pulled messages (listener active)'); @@ -851,6 +852,7 @@ class ConnectionViewModel extends ChangeNotifier { // All phases complete _updateSyncStatus( const SyncStatus(phase: SyncPhase.complete, isComplete: true)); + await _messageRepository.endNotificationSync(); debugPrint('[ConnectionVM] βœ… All sync phases complete'); } @@ -896,12 +898,14 @@ class ConnectionViewModel extends ChangeNotifier { _updateSyncStatus(const SyncStatus(phase: SyncPhase.syncingMessages)); _messageRepository.startPushListener(); + _messageRepository.beginNotificationSync(); final pulled = await _messageRepository.syncMessagesNow(); debugPrint( '[ConnectionVM] βœ… Reconnect sync complete: pulled $pulled messages (channels skipped)'); _updateSyncStatus( const SyncStatus(phase: SyncPhase.complete, isComplete: true)); + await _messageRepository.endNotificationSync(); } Future _finalizeAfterSync() async { From 8a344b3e064f583959f6ef2ce9b390cc8af5113a Mon Sep 17 00:00:00 2001 From: Eric Poulsen Date: Sun, 21 Jun 2026 20:04:03 -0700 Subject: [PATCH 2/2] Fix Linux channel chat screen flicker on telemetry/contact updates Cache message stream in ChannelChatScreen so StreamBuilder does not re-subscribe (and flash a spinner) when setState fires from contact updates. Add context.select scoping and RepaintBoundary isolation to MainNavigationScreen to reduce unnecessary nav shell rebuilds. --- lib/screens/channel_chat_screen.dart | 4 ++-- lib/screens/main_navigation_screen.dart | 29 +++++++++++++++---------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 4d72091..7d6eb58 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -44,6 +44,7 @@ class _ChannelChatScreenState extends State { List _allContacts = []; List _mentionSuggestions = []; StreamSubscription>? _contactsSub; + Stream>? _messageStream; @override void initState() { @@ -139,8 +140,7 @@ class _ChannelChatScreenState extends State { // Messages list Expanded( child: StreamBuilder>( - stream: _messageRepository - .watchMessagesByChannel(widget.channel.hash), + stream: _messageStream ??= _messageRepository.watchMessagesByChannel(widget.channel.hash), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); diff --git a/lib/screens/main_navigation_screen.dart b/lib/screens/main_navigation_screen.dart index df30a95..ad2d3a3 100644 --- a/lib/screens/main_navigation_screen.dart +++ b/lib/screens/main_navigation_screen.dart @@ -29,14 +29,16 @@ class _MainNavigationScreenState extends State bool _identityDialogShowing = false; + Stream<_UnreadCounts>? _unreadCountsStream; + static const MethodChannel _appLifecycleChannel = MethodChannel('com.meshcore.team/app_lifecycle'); final List _screens = [ - const ConnectionScreen(), - const ContactsScreen(), - const ChannelsScreen(), - const MapScreen(), + const RepaintBoundary(child: ConnectionScreen()), + const RepaintBoundary(child: ContactsScreen()), + const RepaintBoundary(child: ChannelsScreen()), + const RepaintBoundary(child: MapScreen()), ]; @override @@ -71,13 +73,14 @@ class _MainNavigationScreenState extends State @override Widget build(BuildContext context) { - final connectionVM = context.watch(); - final channelRepository = context.watch(); - final contactRepository = context.watch(); - final isConnected = connectionVM.isConnected; - final navLocked = connectionVM.identityConfirmationRequired; + final isConnected = context.select( + (vm) => vm.isConnected); + final navLocked = context.select( + (vm) => vm.identityConfirmationRequired); + final syncPhase = context.select( + (vm) => vm.syncStatus.phase); final shouldShowIdentityDialog = - navLocked && connectionVM.syncStatus.phase == SyncPhase.complete; + navLocked && syncPhase == SyncPhase.complete; if (navLocked && _currentIndex != 0) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -92,6 +95,7 @@ class _MainNavigationScreenState extends State _identityDialogShowing = true; WidgetsBinding.instance.addPostFrameCallback((_) async { if (!mounted) return; + final connectionVM = context.read(); await _showIdentityDialog(context, connectionVM); if (!mounted) return; _identityDialogShowing = false; @@ -116,7 +120,10 @@ class _MainNavigationScreenState extends State children: _screens, ), bottomNavigationBar: StreamBuilder<_UnreadCounts>( - stream: _getUnreadCounts(channelRepository, contactRepository), + stream: _unreadCountsStream ??= _getUnreadCounts( + context.read(), + context.read(), + ), builder: (context, snapshot) { final counts = snapshot.data ?? const _UnreadCounts(0, 0); final contactsUnread = counts.contacts;