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/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; 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 {