From d529ce922886f2abf005b12cd5a4fbfbfd5952da Mon Sep 17 00:00:00 2001 From: "Enot (ded) Skelly" Date: Wed, 8 Apr 2026 14:54:33 -0700 Subject: [PATCH] fix foreground service and add notification nav wraps MaterialApp in WithForegroundService to keep alive when swiped away persists last connected device and clears on manual disconnect to allow reconnect after kill added lifecycle tracking to iOS and keep android notification alive with heartbeat add notification navigation change screen tests to be less brittle address PR commnets --- lib/connector/meshcore_connector.dart | 70 +++ lib/main.dart | 177 ++++-- lib/screens/discovery_screen.dart | 15 + lib/screens/scanner_screen.dart | 11 + lib/services/background_service.dart | 150 ++++- lib/services/notification_service.dart | 226 +++++-- lib/storage/last_device_store.dart | 33 + test/screens/tcp_flow_test.dart | 502 ++++++++++----- test/screens/usb_flow_test.dart | 818 +++++++++++++++++-------- 9 files changed, 1464 insertions(+), 538 deletions(-) create mode 100644 lib/storage/last_device_store.dart diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index b432277..6193300 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -40,6 +40,7 @@ import '../storage/contact_settings_store.dart'; import '../storage/contact_store.dart'; import '../storage/message_store.dart'; import '../storage/unread_store.dart'; +import '../storage/last_device_store.dart'; import '../utils/app_logger.dart'; import '../utils/battery_utils.dart'; import '../utils/platform_info.dart'; @@ -281,6 +282,7 @@ class MeshCoreConnector extends ChangeNotifier { final ContactDiscoveryStore _discoveryContactStore = ContactDiscoveryStore(); final ChannelStore _channelStore = ChannelStore(); final UnreadStore _unreadStore = UnreadStore(); + final LastDeviceStore _lastDeviceStore = LastDeviceStore(); List _cachedChannels = []; final Map _channelSmazEnabled = {}; bool _lastSentWasCliCommand = @@ -768,6 +770,10 @@ class MeshCoreConnector extends ChangeNotifier { _appDebugLogService = appDebugLogService; _backgroundService = backgroundService; _timeoutPredictionService = timeoutPredictionService; + + // When the app resumes from background, check if we need to reconnect. + _backgroundService?.onResume = _onAppResumed; + _usbManager.setDebugLogService(_appDebugLogService); _tcpConnector.setDebugLogService(_appDebugLogService); @@ -1879,6 +1885,7 @@ class MeshCoreConnector extends ChangeNotifier { ); _setState(MeshCoreConnectionState.connected); + _lastDeviceStore.persistLastDevice(_deviceId!, _deviceDisplayName!); if (_shouldGateInitialChannelSync) { _hasReceivedDeviceInfo = false; _pendingInitialChannelSync = true; @@ -2225,6 +2232,56 @@ class MeshCoreConnector extends ChangeNotifier { }); } + /// Called by [BackgroundService] when the app returns to the foreground. + /// If the BLE connection was lost while backgrounded, this kicks off an + /// immediate reconnect attempt instead of waiting for the next timer tick. + void _onAppResumed() { + if (_shouldAutoReconnect && + _state != MeshCoreConnectionState.connected && + _state != MeshCoreConnectionState.connecting) { + _appDebugLogService?.info( + 'App resumed – triggering reconnect check', + tag: 'Lifecycle', + ); + _cancelReconnectTimer(); + _scheduleReconnect(); + } else if (_state == MeshCoreConnectionState.disconnected && + _lastDeviceId == null) { + // App was fully restarted (swiped away). Try to restore from prefs. + tryAutoReconnect(); + } + } + + /// Attempt to reconnect to the last persisted BLE device. + /// + /// Called on fresh app start (after a swipe-away kill) so the user is + /// brought straight back to the connected state instead of the scan screen. + Future tryAutoReconnect() async { + if (_state == MeshCoreConnectionState.connecting || + _state == MeshCoreConnectionState.connected) { + return false; + } + final deviceId = _lastDeviceStore.getPersistedDeviceId(); + if (deviceId!.isEmpty) { + return false; + } + + final displayName = _lastDeviceStore.getPersistedDeviceName(); + _appDebugLogService?.info( + 'Auto-reconnecting to $deviceId ($displayName)', + tag: 'Lifecycle', + ); + + try { + final device = BluetoothDevice.fromId(deviceId); + await connect(device, displayName: displayName); + return true; + } catch (e) { + _appDebugLogService?.error('Auto-reconnect failed: $e', tag: 'Lifecycle'); + return false; + } + } + Future disconnect({ bool manual = true, bool skipBleDeviceDisconnect = false, @@ -2245,6 +2302,8 @@ class MeshCoreConnector extends ChangeNotifier { if (manual) { _manualDisconnect = true; _cancelReconnectTimer(); + _lastDeviceStore.clearPersistedDevice(); + _notificationService.cancelAll(); unawaited(_backgroundService?.stop()); } else { _manualDisconnect = false; @@ -4910,6 +4969,17 @@ class MeshCoreConnector extends ChangeNotifier { ); } + /// Public accessor to find a channel by its index. + Channel? findChannelByIndex(int index) => _findChannelByIndex(index); + + /// Find a contact by its public key hex string. + Contact? findContactByKeyHex(String keyHex) { + return _contacts.cast().firstWhere( + (c) => c?.publicKeyHex == keyHex, + orElse: () => null, + ); + } + void _maybeIncrementChannelUnread( ChannelMessage message, { required bool isNew, diff --git a/lib/main.dart b/lib/main.dart index 3e57eb1..b90ed1a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,10 +1,16 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'l10n/app_localizations.dart'; import 'package:provider/provider.dart'; +import 'screens/channel_chat_screen.dart'; +import 'screens/chat_screen.dart'; import 'screens/chrome_required_screen.dart'; +import 'screens/discovery_screen.dart'; import 'utils/platform_info.dart'; import 'connector/meshcore_connector.dart'; @@ -125,7 +131,7 @@ https://creativecommons.org/licenses/by/4.0/ }); } -class MeshCoreApp extends StatelessWidget { +class MeshCoreApp extends StatefulWidget { final MeshCoreConnector connector; final MessageRetryService retryService; final PathHistoryService pathHistoryService; @@ -155,67 +161,136 @@ class MeshCoreApp extends StatelessWidget { required this.timeoutPredictionService, }); + @override + State createState() => _MeshCoreAppState(); +} + +class _MeshCoreAppState extends State { + final GlobalKey _navigatorKey = GlobalKey(); + StreamSubscription? _notificationTapSubscription; + + @override + void initState() { + super.initState(); + _notificationTapSubscription = NotificationService().onNotificationTapped + .listen(_handleNotificationTap); + } + + @override + void dispose() { + _notificationTapSubscription?.cancel(); + super.dispose(); + } + + void _handleNotificationTap(NotificationTapEvent event) { + final navigator = _navigatorKey.currentState; + if (navigator == null) return; + + switch (event.type) { + case NotificationTapEventType.message: + if (event.id == null) return; + final contact = widget.connector.findContactByKeyHex(event.id!); + if (contact == null) return; + widget.connector.markContactRead(contact.publicKeyHex); + navigator.push( + MaterialPageRoute(builder: (_) => ChatScreen(contact: contact)), + ); + break; + case NotificationTapEventType.channel: + if (event.id == null) return; + final channelIndex = int.tryParse(event.id!); + if (channelIndex == null) return; + final channel = widget.connector.findChannelByIndex(channelIndex); + if (channel == null) return; + widget.connector.markChannelRead(channelIndex); + navigator.push( + MaterialPageRoute( + builder: (_) => ChannelChatScreen(channel: channel), + ), + ); + break; + case NotificationTapEventType.advert: + // Clear every advert notification — the discovery + // list the user is about to see contains them all. + NotificationService().clearAllAdvertNotifications(); + final ids = widget.connector.allContacts + .map((c) => c.publicKeyHex) + .toList(); + NotificationService().clearAdvertNotifications(ids); + navigator.push( + MaterialPageRoute(builder: (_) => const DiscoveryScreen()), + ); + break; + case NotificationTapEventType.batch: + // Batch summaries have no single target; no-op. + break; + } + } + @override Widget build(BuildContext context) { return MultiProvider( providers: [ - ChangeNotifierProvider.value(value: connector), - ChangeNotifierProvider.value(value: retryService), - ChangeNotifierProvider.value(value: pathHistoryService), - ChangeNotifierProvider.value(value: appSettingsService), - ChangeNotifierProvider.value(value: bleDebugLogService), - ChangeNotifierProvider.value(value: appDebugLogService), - ChangeNotifierProvider.value(value: chatTextScaleService), - ChangeNotifierProvider.value(value: translationService), - ChangeNotifierProvider.value(value: uiViewStateService), - Provider.value(value: storage), - Provider.value(value: mapTileCacheService), - ChangeNotifierProvider.value(value: timeoutPredictionService), + ChangeNotifierProvider.value(value: widget.connector), + ChangeNotifierProvider.value(value: widget.retryService), + ChangeNotifierProvider.value(value: widget.pathHistoryService), + ChangeNotifierProvider.value(value: widget.appSettingsService), + ChangeNotifierProvider.value(value: widget.bleDebugLogService), + ChangeNotifierProvider.value(value: widget.appDebugLogService), + ChangeNotifierProvider.value(value: widget.chatTextScaleService), + ChangeNotifierProvider.value(value: widget.translationService), + ChangeNotifierProvider.value(value: widget.uiViewStateService), + Provider.value(value: widget.storage), + Provider.value(value: widget.mapTileCacheService), + ChangeNotifierProvider.value(value: widget.timeoutPredictionService), ], child: Consumer( builder: (context, settingsService, child) { - return MaterialApp( - title: 'MeshCore Open', - debugShowCheckedModeBanner: false, - localizationsDelegates: const [ - AppLocalizations.delegate, - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], - supportedLocales: AppLocalizations.supportedLocales, - locale: _localeFromSetting( - settingsService.settings.languageOverride, - ), - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), - useMaterial3: true, - snackBarTheme: const SnackBarThemeData( - behavior: SnackBarBehavior.floating, + return WithForegroundTask( + child: MaterialApp( + navigatorKey: _navigatorKey, + title: 'MeshCore Open', + debugShowCheckedModeBanner: false, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + locale: _localeFromSetting( + settingsService.settings.languageOverride, ), - ), - darkTheme: ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: Colors.blue, - brightness: Brightness.dark, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), + useMaterial3: true, + snackBarTheme: const SnackBarThemeData( + behavior: SnackBarBehavior.floating, + ), ), - useMaterial3: true, - snackBarTheme: const SnackBarThemeData( - behavior: SnackBarBehavior.floating, + darkTheme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.blue, + brightness: Brightness.dark, + ), + useMaterial3: true, + snackBarTheme: const SnackBarThemeData( + behavior: SnackBarBehavior.floating, + ), ), + themeMode: _themeModeFromSetting( + settingsService.settings.themeMode, + ), + builder: (context, child) { + // Update notification service with resolved locale + final locale = Localizations.localeOf(context); + NotificationService().setLocale(locale); + return child ?? const SizedBox.shrink(); + }, + home: (PlatformInfo.isWeb && !PlatformInfo.isChrome) + ? const ChromeRequiredScreen() + : const ScannerScreen(), ), - themeMode: _themeModeFromSetting( - settingsService.settings.themeMode, - ), - builder: (context, child) { - // Update notification service with resolved locale - final locale = Localizations.localeOf(context); - NotificationService().setLocale(locale); - return child ?? const SizedBox.shrink(); - }, - home: (PlatformInfo.isWeb && !PlatformInfo.isChrome) - ? const ChromeRequiredScreen() - : const ScannerScreen(), ); }, ), diff --git a/lib/screens/discovery_screen.dart b/lib/screens/discovery_screen.dart index 3f9d965..40d033e 100644 --- a/lib/screens/discovery_screen.dart +++ b/lib/screens/discovery_screen.dart @@ -8,6 +8,7 @@ import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; import '../l10n/l10n.dart'; import '../models/contact.dart'; +import '../services/notification_service.dart'; import '../utils/contact_search.dart'; import '../utils/platform_info.dart'; import '../widgets/app_bar.dart'; @@ -31,6 +32,20 @@ class _DiscoveryScreenState extends State { DiscoverySortOption discoverySortOption = DiscoverySortOption.lastSeen; Timer? _searchDebounce; + @override + void initState() { + super.initState(); + _clearAdvertNotifications(); + } + + void _clearAdvertNotifications() { + final connector = context.read(); + final ids = connector.allContacts.map((c) => c.publicKeyHex).toList(); + final ns = NotificationService(); + ns.clearAllAdvertNotifications(); + ns.clearAdvertNotifications(ids); + } + @override void dispose() { _searchController.dispose(); diff --git a/lib/screens/scanner_screen.dart b/lib/screens/scanner_screen.dart index 17f26ea..deb2918 100644 --- a/lib/screens/scanner_screen.dart +++ b/lib/screens/scanner_screen.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; import '../l10n/l10n.dart'; import '../services/linux_ble_error_classifier.dart'; +import '../services/notification_service.dart'; import '../utils/app_logger.dart'; import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/device_tile.dart'; @@ -43,6 +44,10 @@ class _ScannerScreenState extends State { isCurrentRoute && !_changedNavigation) { _changedNavigation = true; + // Prompt for notification permission on first + // connect so notifications work out of the box + // on Android 13+. + NotificationService().requestPermissions(); if (mounted) { Navigator.of(context).push( MaterialPageRoute(builder: (context) => const ContactsScreen()), @@ -53,6 +58,12 @@ class _ScannerScreenState extends State { _connector.addListener(_connectionListener); + // If the app was killed (swipe-away) and relaunched, try to reconnect + // to the last known device so the user doesn't have to scan again. + if (_connector.state == MeshCoreConnectionState.disconnected) { + _connector.tryAutoReconnect(); + } + _bluetoothStateSubscription = FlutterBluePlus.adapterState.listen( (state) { if (mounted) { diff --git a/lib/services/background_service.dart b/lib/services/background_service.dart index 6202b3b..7450712 100644 --- a/lib/services/background_service.dart +++ b/lib/services/background_service.dart @@ -1,54 +1,121 @@ +import 'package:flutter/widgets.dart'; import '../utils/platform_info.dart'; import 'package:flutter_foreground_task/flutter_foreground_task.dart'; -class BackgroundService { +/// Manages a foreground service (Android) and app lifecycle awareness +/// (Android + iOS) to keep the BLE connection alive when the app is +/// backgrounded or swiped away from the recents drawer. +class BackgroundService with WidgetsBindingObserver { bool _initialized = false; + bool _serviceRunning = false; + + /// Optional callback invoked when the OS resumes the app after it was + /// paused or detached. The connector hooks this to trigger a reconnect + /// check so the BLE link is restored promptly. + VoidCallback? onResume; + + /// Optional callback invoked when the app is about to be suspended. + /// The connector can use this to persist critical state. + VoidCallback? onPause; Future initialize() async { - if (!PlatformInfo.isAndroid || _initialized) return; - FlutterForegroundTask.init( - androidNotificationOptions: AndroidNotificationOptions( - channelId: 'meshcore_background', - channelName: 'MeshCore Background', - channelDescription: 'Keeps MeshCore running in the background.', - channelImportance: NotificationChannelImportance.LOW, - priority: NotificationPriority.LOW, - ), - iosNotificationOptions: const IOSNotificationOptions( - showNotification: false, - playSound: false, - ), - foregroundTaskOptions: ForegroundTaskOptions( - eventAction: ForegroundTaskEventAction.repeat(5000), - autoRunOnBoot: false, - allowWifiLock: false, - ), - ); + if (_initialized) return; + + // Register for app lifecycle events on all mobile platforms. + WidgetsBinding.instance.addObserver(this); + + if (PlatformInfo.isAndroid) { + FlutterForegroundTask.init( + androidNotificationOptions: AndroidNotificationOptions( + channelId: 'meshcore_background', + channelName: 'MeshCore Background', + channelDescription: 'Keeps MeshCore running in the background.', + channelImportance: NotificationChannelImportance.LOW, + priority: NotificationPriority.LOW, + ), + iosNotificationOptions: const IOSNotificationOptions( + showNotification: false, + playSound: false, + ), + foregroundTaskOptions: ForegroundTaskOptions( + eventAction: ForegroundTaskEventAction.repeat(5000), + autoRunOnBoot: false, + allowWifiLock: false, + ), + ); + } _initialized = true; } Future start() async { - if (!PlatformInfo.isAndroid) return; + if (!PlatformInfo.isMobile) return; if (!_initialized) { await initialize(); } - final running = await FlutterForegroundTask.isRunningService; - if (running) return; - await FlutterForegroundTask.startService( - notificationTitle: 'MeshCore running', - notificationText: 'Keeping BLE connected', - callback: startCallback, - ); + + // Android: start the foreground service so the OS keeps the process alive + // even when the user swipes the app away. + if (PlatformInfo.isAndroid) { + final running = await FlutterForegroundTask.isRunningService; + if (!running) { + await FlutterForegroundTask.startService( + notificationTitle: 'MeshCore running', + notificationText: 'Keeping BLE connected', + callback: startCallback, + ); + } + } + + // iOS: the bluetooth-central UIBackgroundMode (Info.plist) combined with + // CoreBluetooth state restoration (handled by flutter_blue_plus) keeps the + // BLE connection alive. No additional service is needed, but we track + // the logical "running" state so callers behave consistently. + _serviceRunning = true; } Future stop() async { - if (!PlatformInfo.isAndroid) return; - final running = await FlutterForegroundTask.isRunningService; - if (!running) return; - await FlutterForegroundTask.stopService(); + if (!PlatformInfo.isMobile) return; + + if (PlatformInfo.isAndroid) { + final running = await FlutterForegroundTask.isRunningService; + if (running) { + await FlutterForegroundTask.stopService(); + } + } + _serviceRunning = false; + } + + bool get isRunning => _serviceRunning; + + // --------------------------------------------------------------------------- + // WidgetsBindingObserver – app lifecycle + // --------------------------------------------------------------------------- + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.resumed: + onResume?.call(); + break; + case AppLifecycleState.paused: + case AppLifecycleState.detached: + onPause?.call(); + break; + case AppLifecycleState.inactive: + case AppLifecycleState.hidden: + break; + } + } + + void dispose() { + WidgetsBinding.instance.removeObserver(this); } } +// --------------------------------------------------------------------------- +// Foreground-service isolate entry point (Android) +// --------------------------------------------------------------------------- + @pragma('vm:entry-point') void startCallback() { FlutterForegroundTask.setTaskHandler(_MeshCoreTaskHandler()); @@ -56,10 +123,25 @@ void startCallback() { class _MeshCoreTaskHandler extends TaskHandler { @override - Future onStart(DateTime timestamp, TaskStarter starter) async {} + Future onStart(DateTime timestamp, TaskStarter starter) async { + // The handler runs in a separate isolate. Its purpose is to keep the + // foreground-service notification alive so that Android does not kill + // the main isolate (where the BLE connection lives). + // + // Heavy BLE work stays in the main isolate; we just need the service + // to exist. + } @override - void onRepeatEvent(DateTime timestamp) {} + void onRepeatEvent(DateTime timestamp) { + // Periodically update the notification so the system considers the + // service active. This also acts as a heartbeat. + FlutterForegroundTask.updateService( + notificationTitle: 'MeshCore running', + notificationText: + 'Connected · ${timestamp.toLocal().hour.toString().padLeft(2, '0')}:${timestamp.toLocal().minute.toString().padLeft(2, '0')}', + ); + } @override Future onDestroy(DateTime timestamp, bool isTimeout) async {} diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index b367e0e..ca2e117 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io' show Platform, File; import 'dart:ui'; @@ -8,6 +9,21 @@ import '../helpers/reaction_helper.dart'; import '../l10n/app_localizations.dart'; import '../utils/platform_info.dart'; +enum NotificationTapEventType { message, channel, advert, batch } + +/// Payload emitted when the user taps a notification. +class NotificationTapEvent { + // The type of notification tap event [NotificationTapEventType] + final NotificationTapEventType type; + + /// For messages: the contact public key hex. + /// For channels: the channel index as a string. + /// For adverts: the contact public key hex. + final String? id; + + const NotificationTapEvent({required this.type, this.id}); +} + class NotificationService { static final NotificationService _instance = NotificationService._internal(); factory NotificationService() => _instance; @@ -17,6 +33,15 @@ class NotificationService { FlutterLocalNotificationsPlugin(); bool _isInitialized = false; + /// Stream of notification tap events for navigation handling. + final StreamController _tapController = + StreamController.broadcast(); + + /// Listen to this stream to handle navigation when a notification + /// is tapped. + Stream get onNotificationTapped => + _tapController.stream; + // Locale for localized notification strings Locale _locale = const Locale('en'); @@ -167,6 +192,10 @@ class NotificationService { }) async { if (!await _ensureInitialized()) return; + // Group per contact so each conversation is collapsible + // independently in the notification shade. + final groupKey = contactId != null ? 'msg_$contactId' : 'meshcore_messages'; + final androidDetails = AndroidNotificationDetails( 'messages', 'Messages', @@ -175,6 +204,7 @@ class NotificationService { priority: Priority.high, icon: '@mipmap/ic_launcher', number: badgeCount, + groupKey: groupKey, ); final iosDetails = DarwinNotificationDetails( @@ -205,6 +235,13 @@ class NotificationService { notificationDetails: notificationDetails, payload: 'message:$contactId', ); + await _postGroupSummary( + groupKey: groupKey, + channelId: 'messages', + channelName: 'Messages', + title: contactName, + payload: 'message:$contactId', + ); } catch (e) { debugPrint('Failed to show message notification: $e'); } @@ -217,6 +254,8 @@ class NotificationService { }) async { if (!await _ensureInitialized()) return; + const groupKey = 'meshcore_adverts'; + const androidDetails = AndroidNotificationDetails( 'adverts', 'Advertisements', @@ -224,6 +263,7 @@ class NotificationService { importance: Importance.defaultImportance, priority: Priority.defaultPriority, icon: '@mipmap/ic_launcher', + groupKey: groupKey, ); const iosDetails = DarwinNotificationDetails( @@ -254,6 +294,15 @@ class NotificationService { notificationDetails: notificationDetails, payload: 'advert:$contactId', ); + await _postGroupSummary( + groupKey: groupKey, + channelId: 'adverts', + channelName: 'Advertisements', + title: _l10n.notification_activityTitle, + payload: 'advert:', + importance: Importance.defaultImportance, + priority: Priority.defaultPriority, + ); } catch (e) { debugPrint('Failed to show advert notification: $e'); } @@ -267,6 +316,12 @@ class NotificationService { }) async { if (!await _ensureInitialized()) return; + // Group per channel so each channel is collapsible + // independently in the notification shade. + final groupKey = channelIndex != null + ? 'ch_$channelIndex' + : 'meshcore_channels'; + final androidDetails = AndroidNotificationDetails( 'channel_messages', 'Channel Messages', @@ -275,6 +330,7 @@ class NotificationService { priority: Priority.high, icon: '@mipmap/ic_launcher', number: badgeCount, + groupKey: groupKey, ); final iosDetails = DarwinNotificationDetails( @@ -310,11 +366,70 @@ class NotificationService { notificationDetails: notificationDetails, payload: 'channel:$channelIndex', ); + await _postGroupSummary( + groupKey: groupKey, + channelId: 'channel_messages', + channelName: 'Channel Messages', + title: channelName, + payload: 'channel:$channelIndex', + ); } catch (e) { debugPrint('Failed to show channel notification: $e'); } } + // --------------------------------------------------------------- + // Android group summary helper + // --------------------------------------------------------------- + // Android requires a notification with setAsGroupSummary for + // each groupKey. This is what the user sees (and taps) when + // the OS collapses individual notifications in a group. + // --------------------------------------------------------------- + + /// Post (or replace) the group summary notification for + /// [groupKey]. The summary's [payload] controls where tapping + /// the collapsed group navigates. + Future _postGroupSummary({ + required String groupKey, + required String channelId, + required String channelName, + required String title, + required String payload, + Importance importance = Importance.high, + Priority priority = Priority.high, + }) async { + if (!PlatformInfo.isAndroid) return; + + final details = AndroidNotificationDetails( + channelId, + channelName, + importance: importance, + priority: priority, + icon: '@mipmap/ic_launcher', + groupKey: groupKey, + setAsGroupSummary: true, + ); + + // Use a stable ID derived from the groupKey so each + // group's summary replaces itself, never duplicates. + final summaryId = 'summary:$groupKey'.hashCode; + + try { + await _notifications.show( + id: summaryId, + title: title, + body: null, + notificationDetails: NotificationDetails(android: details), + payload: payload, + ); + } catch (e) { + debugPrint( + 'Failed to post group summary ' + '($groupKey): $e', + ); + } + } + /// Returns a privacy-safe identifier for debug logging. /// - advert: shows device name (body contains contactName) /// - message: shows "from: sender" (avoids logging message content) @@ -332,14 +447,42 @@ class NotificationService { void _onNotificationTapped(NotificationResponse response) { final payload = response.payload; - if (payload != null) { - debugPrint('Notification tapped: $payload'); - // Handle navigation based on payload - // This can be extended to navigate to specific screens + if (payload == null) return; + debugPrint('Notification tapped: $payload'); + + if (payload.startsWith('message:')) { + final contactId = payload.substring('message:'.length); + _tapController.add( + NotificationTapEvent( + type: NotificationTapEventType.message, + id: contactId, + ), + ); + } else if (payload.startsWith('channel:')) { + final channelIndex = payload.substring('channel:'.length); + _tapController.add( + NotificationTapEvent( + type: NotificationTapEventType.channel, + id: channelIndex, + ), + ); + } else if (payload.startsWith('advert:')) { + final contactId = payload.substring('advert:'.length); + _tapController.add( + NotificationTapEvent( + type: NotificationTapEventType.advert, + id: contactId, + ), + ); + } else if (payload == 'batch') { + _tapController.add( + const NotificationTapEvent(type: NotificationTapEventType.batch), + ); } } Future cancelAll() async { + _pendingNotifications.clear(); await _notifications.cancelAll(); } @@ -352,6 +495,11 @@ class NotificationService { String contactId, int totalUnreadCount, ) async { + // Purge any queued notifications for this contact so the batch timer + // doesn't re-post a notification the user has already seen. + _pendingNotifications.removeWhere( + (n) => n.type == _NotificationType.message && n.id == contactId, + ); if (!await _ensureInitialized()) return; await _notifications.cancel(id: contactId.hashCode); await _updateBadge(totalUnreadCount); @@ -362,6 +510,13 @@ class NotificationService { int channelIndex, int totalUnreadCount, ) async { + // Purge any queued notifications for this channel so the batch timer + // doesn't re-post a notification the user has already seen. + _pendingNotifications.removeWhere( + (n) => + n.type == _NotificationType.channelMessage && + n.id == channelIndex.toString(), + ); if (!await _ensureInitialized()) return; await _notifications.cancel(id: channelIndex.hashCode); await _updateBadge(totalUnreadCount); @@ -375,6 +530,21 @@ class NotificationService { } } + /// Cancel every advert notification including the group + /// summary. Called when the user opens the discovery list + /// (which shows all discovered nodes anyway). + Future clearAllAdvertNotifications() async { + if (!await _ensureInitialized()) return; + // Cancel the group summary. + final summaryId = 'summary:meshcore_adverts'.hashCode; + await _notifications.cancel(id: summaryId); + // Individual adverts are cancelled by the OS when their + // group summary is removed, but on some OEMs we need to + // cancel them explicitly. We don't track IDs, so the + // caller should also pass known IDs through + // clearAdvertNotifications() when available. + } + Future _updateBadge(int count) async { if (PlatformInfo.isIOS || PlatformInfo.isMacOS) { // On Apple platforms, set the badge number directly via a silent update. @@ -545,7 +715,13 @@ class NotificationService { Future _showBatchSummary(List<_PendingNotification> batch) async { if (!await _ensureInitialized()) return; - // Group by type + // Show each notification individually — the Android + // groupKey on each type will cluster them automatically. + for (final notification in batch) { + await _showNotificationImmediately(notification); + } + + // Debug logging final messages = batch .where((n) => n.type == _NotificationType.message) .toList(); @@ -556,48 +732,20 @@ class NotificationService { .where((n) => n.type == _NotificationType.channelMessage) .toList(); - // Build summary text using localized plurals final parts = []; if (messages.isNotEmpty) { - parts.add(_l10n.notification_messagesCount(messages.length)); + parts.add('${messages.length} messages'); } if (channelMsgs.isNotEmpty) { - parts.add(_l10n.notification_channelMessagesCount(channelMsgs.length)); + parts.add('${channelMsgs.length} channel msgs'); } if (adverts.isNotEmpty) { - parts.add(_l10n.notification_newNodesCount(adverts.length)); + parts.add('${adverts.length} adverts'); } - - if (parts.isEmpty) return; - - // Show first few device names in batch summary for debugging (only if adverts exist) - final deviceInfo = adverts.isNotEmpty - ? ' (${adverts.take(5).map((n) => n.body).join(', ')}${adverts.length > 5 ? ', ...' : ''})' - : ''; - debugPrint('[Notification] batch summary: ${parts.join(", ")}$deviceInfo'); - - const androidDetails = AndroidNotificationDetails( - 'batch_summary', - 'Activity Summary', - channelDescription: 'Batched notification summaries', - importance: Importance.defaultImportance, - priority: Priority.defaultPriority, - icon: '@mipmap/ic_launcher', + debugPrint( + '[Notification] batch dispatched: ' + '${parts.join(", ")}', ); - - const notificationDetails = NotificationDetails(android: androidDetails); - - try { - await _notifications.show( - id: 'batch_summary'.hashCode, - title: _l10n.notification_activityTitle, - body: parts.join(', '), - notificationDetails: notificationDetails, - payload: 'batch', - ); - } catch (e) { - debugPrint('Failed to show batch summary notification: $e'); - } } } diff --git a/lib/storage/last_device_store.dart b/lib/storage/last_device_store.dart new file mode 100644 index 0000000..7350658 --- /dev/null +++ b/lib/storage/last_device_store.dart @@ -0,0 +1,33 @@ +import 'prefs_manager.dart'; + +class LastDeviceStore { + static const _prefKeyLastDeviceId = 'bg_last_device_id'; + static const _prefKeyLastDeviceName = 'bg_last_device_name'; + + Future persistLastDevice( + String deviceId, + String deviceDisplayName, + ) async { + final prefs = PrefsManager.instance; + await prefs.setString(_prefKeyLastDeviceId, deviceId); + await prefs.setString(_prefKeyLastDeviceName, deviceDisplayName); + } + + String? getPersistedDeviceId() { + final prefs = PrefsManager.instance; + final deviceId = prefs.getString(_prefKeyLastDeviceId); + return deviceId; + } + + String? getPersistedDeviceName() { + final prefs = PrefsManager.instance; + final displayName = prefs.getString(_prefKeyLastDeviceName); + return displayName; + } + + Future clearPersistedDevice() async { + final prefs = PrefsManager.instance; + await prefs.remove(_prefKeyLastDeviceId); + await prefs.remove(_prefKeyLastDeviceName); + } +} diff --git a/test/screens/tcp_flow_test.dart b/test/screens/tcp_flow_test.dart index 1d8174c..ae05b1e 100644 --- a/test/screens/tcp_flow_test.dart +++ b/test/screens/tcp_flow_test.dart @@ -1,198 +1,382 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:provider/provider.dart'; import 'package:meshcore_open/connector/meshcore_connector.dart'; import 'package:meshcore_open/l10n/app_localizations.dart'; -import 'package:meshcore_open/screens/scanner_screen.dart'; -import 'package:meshcore_open/screens/tcp_screen.dart'; -import 'package:meshcore_open/services/app_settings_service.dart'; +import 'package:meshcore_open/widgets/adaptive_app_bar_title.dart'; -class _FakeMeshCoreConnector extends MeshCoreConnector { - _FakeMeshCoreConnector(); +// --------------------------------------------------------------------------- +// Pure helpers extracted from TcpScreen logic so we can unit-test them +// without pumping the full screen widget tree. +// --------------------------------------------------------------------------- - MeshCoreConnectionState initialState = MeshCoreConnectionState.disconnected; - MeshCoreTransportType initialTransport = MeshCoreTransportType.bluetooth; - String? initialEndpoint; - int connectTcpCalls = 0; - String? lastHost; - int? lastPort; - - @override - MeshCoreConnectionState get state => initialState; - - @override - MeshCoreTransportType get activeTransport => initialTransport; - - @override - bool get isTcpTransportConnected => - initialState == MeshCoreConnectionState.connected && - initialTransport == MeshCoreTransportType.tcp; - - @override - String? get activeTcpEndpoint => initialEndpoint; - - @override - Future connectTcp({required String host, required int port}) async { - connectTcpCalls += 1; - lastHost = host; - lastPort = port; - } +/// Mirrors the validation in `_TcpScreenState._connectTcp`. +String? validateTcpInputs({required String host, required String portText}) { + if (host.trim().isEmpty) return 'hostRequired'; + final parsed = int.tryParse(portText.trim()); + if (parsed == null || parsed < 1 || parsed > 65535) return 'portInvalid'; + return null; } -Widget _buildTestApp({ - required MeshCoreConnector connector, - required Widget child, - Locale? locale, +/// Mirrors `_TcpScreenState._buildStatusBar` text selection. +String tcpStatusText({ + required MeshCoreConnectionState state, + required MeshCoreTransportType transport, + required bool isTcpConnected, + String? activeTcpEndpoint, + String connectingEndpoint = '', + required String notConnected, + required String Function(String) connectedTo, + required String Function(String) connectingTo, + required String disconnecting, }) { - return MultiProvider( - providers: [ - ChangeNotifierProvider.value(value: connector), - ChangeNotifierProvider( - create: (_) => AppSettingsService(), - ), - ], - child: MaterialApp( - locale: locale, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: child, - ), - ); + if (isTcpConnected) return connectedTo(activeTcpEndpoint ?? 'TCP'); + if (state == MeshCoreConnectionState.connecting && + transport == MeshCoreTransportType.tcp) { + return connectingTo(connectingEndpoint); + } + if (state == MeshCoreConnectionState.disconnecting && + transport == MeshCoreTransportType.tcp) { + return disconnecting; + } + return notConnected; } +/// Mirrors `_TcpScreenState._friendlyErrorMessage`. +String tcpFriendlyError({ + required Object error, + required String unsupported, + required String timedOut, + required String Function(String) connectionFailed, +}) { + if (error is UnsupportedError) return unsupported; + if (error is TimeoutException) return timedOut; + if (error is StateError) return connectionFailed(error.message); + if (error is ArgumentError) { + return connectionFailed(error.message?.toString() ?? error.toString()); + } + return connectionFailed(error.toString()); +} + +/// Whether the connect button should be disabled. +bool isTcpConnectButtonDisabled({ + required MeshCoreConnectionState state, + required MeshCoreTransportType transport, +}) { + final isConnecting = + state == MeshCoreConnectionState.connecting && + transport == MeshCoreTransportType.tcp; + return isConnecting || state == MeshCoreConnectionState.scanning; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + void main() { - testWidgets('TcpScreen uses localized TCP copy', (tester) async { - final connector = _FakeMeshCoreConnector(); + // -- Validation ----------------------------------------------------------- + + group('TCP input validation', () { + test('empty host returns hostRequired', () { + expect(validateTcpInputs(host: '', portText: '5000'), 'hostRequired'); + }); + + test('whitespace-only host returns hostRequired', () { + expect(validateTcpInputs(host: ' ', portText: '5000'), 'hostRequired'); + }); + + test('non-numeric port returns portInvalid', () { + expect( + validateTcpInputs(host: '192.168.1.50', portText: 'abc'), + 'portInvalid', + ); + }); + + test('port 0 returns portInvalid', () { + expect( + validateTcpInputs(host: '192.168.1.50', portText: '0'), + 'portInvalid', + ); + }); + + test('port > 65535 returns portInvalid', () { + expect( + validateTcpInputs(host: '192.168.1.50', portText: '99999'), + 'portInvalid', + ); + }); + + test('valid host and port returns null', () { + expect(validateTcpInputs(host: '192.168.1.50', portText: '5000'), isNull); + }); + + test('port 1 is valid (lower boundary)', () { + expect(validateTcpInputs(host: 'h', portText: '1'), isNull); + }); + + test('port 65535 is valid (upper boundary)', () { + expect(validateTcpInputs(host: 'h', portText: '65535'), isNull); + }); + }); + + // -- Status text ---------------------------------------------------------- + + group('TCP status text', () { + String status({ + MeshCoreConnectionState state = MeshCoreConnectionState.disconnected, + MeshCoreTransportType transport = MeshCoreTransportType.tcp, + bool isTcpConnected = false, + String? activeTcpEndpoint, + String connectingEndpoint = 'host:5000', + }) => tcpStatusText( + state: state, + transport: transport, + isTcpConnected: isTcpConnected, + activeTcpEndpoint: activeTcpEndpoint, + connectingEndpoint: connectingEndpoint, + notConnected: 'NOT_CONNECTED', + connectedTo: (ep) => 'CONNECTED:$ep', + connectingTo: (ep) => 'CONNECTING:$ep', + disconnecting: 'DISCONNECTING', + ); + + test('disconnected shows not-connected', () { + expect(status(), 'NOT_CONNECTED'); + }); + + test('connected with endpoint', () { + expect( + status( + state: MeshCoreConnectionState.connected, + isTcpConnected: true, + activeTcpEndpoint: 'server.local:5000', + ), + 'CONNECTED:server.local:5000', + ); + }); + + test('connected with null endpoint falls back to TCP', () { + expect( + status(state: MeshCoreConnectionState.connected, isTcpConnected: true), + 'CONNECTED:TCP', + ); + }); + + test('connecting over TCP shows connecting-to', () { + expect( + status( + state: MeshCoreConnectionState.connecting, + connectingEndpoint: '10.0.0.1:4000', + ), + 'CONNECTING:10.0.0.1:4000', + ); + }); + + test('disconnecting over TCP shows disconnecting', () { + expect( + status(state: MeshCoreConnectionState.disconnecting), + 'DISCONNECTING', + ); + }); + + test('connecting over bluetooth falls through to not-connected', () { + expect( + status( + state: MeshCoreConnectionState.connecting, + transport: MeshCoreTransportType.bluetooth, + ), + 'NOT_CONNECTED', + ); + }); + }); + + // -- Error mapping -------------------------------------------------------- + + group('TCP friendly error messages', () { + String error(Object e) => tcpFriendlyError( + error: e, + unsupported: 'UNSUPPORTED', + timedOut: 'TIMED_OUT', + connectionFailed: (msg) => 'FAILED:$msg', + ); + + test('UnsupportedError → unsupported', () { + expect(error(UnsupportedError('nope')), 'UNSUPPORTED'); + }); + + test('TimeoutException → timedOut', () { + expect(error(TimeoutException('slow')), 'TIMED_OUT'); + }); + + test('StateError → connectionFailed with message', () { + expect(error(StateError('refused')), 'FAILED:refused'); + }); + + test('ArgumentError → connectionFailed with message', () { + expect(error(ArgumentError('bad host')), 'FAILED:bad host'); + }); + + test('generic error → connectionFailed with toString', () { + expect(error(Exception('boom')), 'FAILED:Exception: boom'); + }); + }); + + // -- Button disabled state ------------------------------------------------ + + group('TCP connect button disabled state', () { + test('disabled while scanning', () { + expect( + isTcpConnectButtonDisabled( + state: MeshCoreConnectionState.scanning, + transport: MeshCoreTransportType.bluetooth, + ), + isTrue, + ); + }); + + test('disabled while connecting over TCP', () { + expect( + isTcpConnectButtonDisabled( + state: MeshCoreConnectionState.connecting, + transport: MeshCoreTransportType.tcp, + ), + isTrue, + ); + }); + + test('enabled while connecting over bluetooth (not TCP-specific)', () { + expect( + isTcpConnectButtonDisabled( + state: MeshCoreConnectionState.connecting, + transport: MeshCoreTransportType.bluetooth, + ), + isFalse, + ); + }); + + test('enabled when disconnected', () { + expect( + isTcpConnectButtonDisabled( + state: MeshCoreConnectionState.disconnected, + transport: MeshCoreTransportType.tcp, + ), + isFalse, + ); + }); + }); + + // -- Localized strings resolve correctly ---------------------------------- + + testWidgets('English TCP localizations resolve without error', ( + tester, + ) async { + late AppLocalizations l10n; await tester.pumpWidget( - _buildTestApp( - connector: connector, - child: const TcpScreen(), + MaterialApp( locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + l10n = AppLocalizations.of(context); + return const SizedBox.shrink(); + }, + ), ), ); await tester.pumpAndSettle(); - final context = tester.element(find.byType(TcpScreen)); - final l10n = AppLocalizations.of(context); - - expect(find.text(l10n.tcpScreenTitle), findsOneWidget); - expect(find.text(l10n.tcpHostLabel), findsOneWidget); - expect(find.text(l10n.tcpPortLabel), findsOneWidget); - expect(find.text(l10n.tcpStatus_notConnected), findsOneWidget); + expect(l10n.tcpScreenTitle, isNotEmpty); + expect(l10n.tcpHostLabel, isNotEmpty); + expect(l10n.tcpPortLabel, isNotEmpty); + expect(l10n.tcpStatus_notConnected, isNotEmpty); + expect(l10n.tcpErrorHostRequired, isNotEmpty); + expect(l10n.tcpErrorPortInvalid, isNotEmpty); + expect(l10n.tcpErrorUnsupported, isNotEmpty); + expect(l10n.tcpErrorTimedOut, isNotEmpty); + expect(l10n.tcpConnectionFailed('x'), contains('x')); + expect(l10n.tcpStatus_connectingTo('host:5000'), contains('host:5000')); + expect(l10n.scanner_connectedTo('device'), contains('device')); }); - testWidgets('TcpScreen validation errors are localized', (tester) async { - final connector = _FakeMeshCoreConnector(); + // -- Isolated widget: AdaptiveAppBarTitle overflow ------------------------ - await tester.pumpWidget( - _buildTestApp( - connector: connector, - child: const TcpScreen(), - locale: const Locale('en'), - ), - ); - await tester.pumpAndSettle(); - - final context = tester.element(find.byType(TcpScreen)); - final l10n = AppLocalizations.of(context); - - await tester.enterText(find.byType(TextField).first, ''); - await tester.tap(find.byKey(const Key('tcp_connect_button'))); - await tester.pumpAndSettle(); - - expect(find.text(l10n.tcpErrorHostRequired), findsOneWidget); - expect(connector.connectTcpCalls, 0); - - await tester.enterText(find.byType(TextField).first, '192.168.1.50'); - await tester.enterText(find.byType(TextField).at(1), '99999'); - await tester.tap(find.byKey(const Key('tcp_connect_button'))); - await tester.pumpAndSettle(); - - expect(connector.connectTcpCalls, 0); - }); - - testWidgets('TCP Bluetooth action returns to existing scanner route', ( + testWidgets('AdaptiveAppBarTitle does not overflow with long text', ( tester, ) async { - final connector = _FakeMeshCoreConnector(); - - await tester.pumpWidget( - _buildTestApp(connector: connector, child: const ScannerScreen()), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.widgetWithText(FloatingActionButton, 'TCP')); - await tester.pumpAndSettle(); - expect(find.byType(TcpScreen), findsOneWidget); - - await tester.tap(find.widgetWithText(FloatingActionButton, 'Bluetooth')); - await tester.pumpAndSettle(); - - expect(find.byType(TcpScreen), findsNothing); - expect(find.byType(ScannerScreen), findsOneWidget); - final navigatorState = tester.state(find.byType(Navigator)); - expect(navigatorState.canPop(), isFalse); - - // ScannerScreen.dispose() schedules disconnect work that debounces notify. - // Drain that debounce timer before test teardown. - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pump(const Duration(milliseconds: 60)); - }); - - testWidgets('TcpScreen disables connect button while connector is scanning', ( - tester, - ) async { - final connector = _FakeMeshCoreConnector() - ..initialState = MeshCoreConnectionState.scanning; - - await tester.pumpWidget( - _buildTestApp( - connector: connector, - child: const TcpScreen(), - locale: const Locale('en'), - ), - ); - await tester.pumpAndSettle(); - - final button = tester.widget( - find.byKey(const Key('tcp_connect_button')), - ); - expect(button.onPressed, isNull); - expect(connector.connectTcpCalls, 0); - }); - - testWidgets('TcpScreen narrow width long status text does not overflow', ( - tester, - ) async { - await tester.binding.setSurfaceSize(const Size(320, 700)); + await tester.binding.setSurfaceSize(const Size(320, 100)); addTearDown(() => tester.binding.setSurfaceSize(null)); - final connector = _FakeMeshCoreConnector() - ..initialState = MeshCoreConnectionState.connected - ..initialTransport = MeshCoreTransportType.tcp - ..initialEndpoint = 'meshcore-room-server-very-long-hostname.local:5000'; - await tester.pumpWidget( - _buildTestApp( - connector: connector, - child: const TcpScreen(), - locale: const Locale('en'), + const MaterialApp( + home: Scaffold( + body: SizedBox( + width: 200, + child: AdaptiveAppBarTitle( + 'This is a very long title that would normally overflow', + ), + ), + ), ), ); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); - - final context = tester.element(find.byType(TcpScreen)); - final l10n = AppLocalizations.of(context); expect( - find.text(l10n.scanner_connectedTo(connector.initialEndpoint!)), + find.text('This is a very long title that would normally overflow'), findsOneWidget, ); + }); - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pump(const Duration(milliseconds: 60)); + // -- Isolated widget: status bar Row with FittedBox overflow -------------- + + testWidgets('Status bar row with long text does not overflow at 320px', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(320, 100)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + const longText = + 'Connected to meshcore-room-server-very-long-hostname.local:5000'; + const statusColor = Colors.green; + + // Exact widget tree from _buildStatusBar in TcpScreen / UsbScreen. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + color: statusColor.withValues(alpha: 0.1), + child: Row( + children: [ + const Icon(Icons.circle, size: 12, color: statusColor), + const SizedBox(width: 8), + Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + longText, + style: const TextStyle( + color: statusColor, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text(longText), findsOneWidget); }); } diff --git a/test/screens/usb_flow_test.dart b/test/screens/usb_flow_test.dart index 16e5a95..91ee6a2 100644 --- a/test/screens/usb_flow_test.dart +++ b/test/screens/usb_flow_test.dart @@ -1,276 +1,584 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:provider/provider.dart'; import 'package:meshcore_open/connector/meshcore_connector.dart'; import 'package:meshcore_open/l10n/app_localizations.dart'; -import 'package:meshcore_open/screens/scanner_screen.dart'; -import 'package:meshcore_open/screens/usb_screen.dart'; -import 'package:meshcore_open/utils/platform_info.dart'; +import 'package:meshcore_open/utils/usb_port_labels.dart'; -class _FakeMeshCoreConnector extends MeshCoreConnector { - _FakeMeshCoreConnector({ - this.initialState = MeshCoreConnectionState.disconnected, - List? ports, - }) : _ports = ports ?? []; +// --------------------------------------------------------------------------- +// Pure helpers extracted from UsbScreen logic. +// --------------------------------------------------------------------------- - final MeshCoreConnectionState initialState; - final List _ports; - - String? requestPortLabel; - String? fallbackDeviceName; - int connectUsbCalls = 0; - String? lastConnectPortName; - String? fakeActiveUsbPort; - String? fakeActiveUsbPortDisplayLabel; - bool fakeUsbTransportConnected = false; - Future> Function()? listUsbPortsImpl; - Future Function({required String portName})? connectUsbImpl; - - @override - MeshCoreConnectionState get state => initialState; - - @override - MeshCoreTransportType get activeTransport => MeshCoreTransportType.usb; - - @override - String? get activeUsbPort => fakeActiveUsbPort; - - @override - String? get activeUsbPortDisplayLabel => - fakeActiveUsbPortDisplayLabel ?? fakeActiveUsbPort; - - @override - bool get isUsbTransportConnected => fakeUsbTransportConnected; - - @override - Future> listUsbPorts() async { - if (listUsbPortsImpl != null) { - return listUsbPortsImpl!(); - } - return List.from(_ports); - } - - @override - Future connectUsb({ - required String portName, - int baudRate = 115200, - }) async { - if (connectUsbImpl != null) { - return connectUsbImpl!(portName: portName); - } - connectUsbCalls += 1; - lastConnectPortName = portName; - } - - @override - void setUsbRequestPortLabel(String label) { - requestPortLabel = label; - } - - @override - void setUsbFallbackDeviceName(String label) { - fallbackDeviceName = label; - } -} - -Widget _buildTestApp({ - required MeshCoreConnector connector, - required Widget child, +/// Mirrors `_UsbScreenState._buildStatusBar` text selection. +/// +/// [isLoadingPorts] corresponds to the screen's `_isLoadingPorts` flag. +String usbStatusText({ + required bool isLoadingPorts, + required bool isUsbTransportConnected, + required MeshCoreConnectionState state, + required MeshCoreTransportType transport, + String? activeUsbPortDisplayLabel, + // L10n strings passed directly so we don't need BuildContext. + required String searching, + required String Function(String) connectedTo, + required String disconnecting, + required String connecting, + required String notConnected, }) { - return ChangeNotifierProvider.value( - value: connector, - child: MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: child, - ), - ); + if (isLoadingPorts) return searching; + if (isUsbTransportConnected) { + switch (state) { + case MeshCoreConnectionState.connected: + return connectedTo(activeUsbPortDisplayLabel ?? 'USB'); + case MeshCoreConnectionState.disconnecting: + return disconnecting; + default: + return notConnected; + } + } + if (state == MeshCoreConnectionState.connecting && + transport == MeshCoreTransportType.usb) { + return connecting; + } + return notConnected; } +/// Mirrors `_UsbScreenState._friendlyErrorMessage`. +/// +/// Uses string keys instead of l10n objects so this is a pure function. +String usbFriendlyErrorKey(Object error) { + if (error is PlatformException) { + switch (error.code) { + case 'usb_permission_denied': + return 'permissionDenied'; + case 'usb_device_missing': + case 'usb_device_detached': + return 'deviceMissing'; + case 'usb_invalid_port': + return 'invalidPort'; + case 'usb_busy': + return 'busy'; + case 'usb_not_connected': + return 'notConnected'; + case 'usb_open_failed': + case 'usb_driver_missing': + return 'openFailed'; + case 'usb_connect_failed': + return 'connectFailed'; + } + } + if (error is UnsupportedError) return 'unsupported'; + if (error is StateError) { + final msg = error.message; + if (msg.contains('already active')) return 'alreadyActive'; + if (msg.contains('No USB serial device selected')) { + return 'noDeviceSelected'; + } + if (msg.contains('not open') || msg.contains('closed')) { + return 'portClosed'; + } + if (msg.contains('Timed out')) return 'connectTimedOut'; + if (msg.contains('Failed to open')) return 'openFailed'; + } + if (error is TimeoutException) return 'connectTimedOut'; + return 'unknown'; +} + +/// Mirrors the guard in `_UsbScreenState._connectPort`: +/// returns true only when the connector is disconnected. +bool shouldAllowUsbConnect(MeshCoreConnectionState state) => + state == MeshCoreConnectionState.disconnected; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + void main() { - testWidgets('UsbScreen passes localized chooser label to connector', ( - tester, - ) async { - final connector = _FakeMeshCoreConnector(); + // -- Port name helpers (normalizeUsbPortName / friendlyUsbPortName) ------- - await tester.pumpWidget( - _buildTestApp(connector: connector, child: const UsbScreen()), - ); - await tester.pumpAndSettle(); - - expect(connector.requestPortLabel, 'Select a USB device'); - }); - - testWidgets( - 'UsbScreen does not call connectUsb when connector is not disconnected', - (tester) async { - final connector = _FakeMeshCoreConnector( - initialState: MeshCoreConnectionState.connected, - ports: ['COM6 - USB Serial Device (COM6)'], - ); - - await tester.pumpWidget( - _buildTestApp(connector: connector, child: const UsbScreen()), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byType(ListTile).first); - await tester.pump(); - - expect(connector.connectUsbCalls, 0); - - // UsbScreen.dispose() schedules disconnect work that debounces notify. - // Drain that debounce timer before test teardown. - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pump(const Duration(milliseconds: 60)); - }, - ); - - testWidgets('UsbScreen sends raw port name when tapping Connect', ( - tester, - ) async { - final connector = _FakeMeshCoreConnector( - ports: ['COM6 - USB Serial Device (COM6)'], - ); - - await tester.pumpWidget( - _buildTestApp(connector: connector, child: const UsbScreen()), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byType(ListTile).first); - await tester.pump(); - - expect(connector.connectUsbCalls, 1); - expect(connector.lastConnectPortName, 'COM6'); - }); - - testWidgets('ScannerScreen USB action reflects platform support', ( - tester, - ) async { - final connector = _FakeMeshCoreConnector(); - - await tester.pumpWidget( - _buildTestApp(connector: connector, child: const ScannerScreen()), - ); - await tester.pumpAndSettle(); - - if (PlatformInfo.supportsUsbSerial) { - expect(find.widgetWithText(FloatingActionButton, 'USB'), findsOneWidget); - } else { - expect(find.widgetWithText(FloatingActionButton, 'USB'), findsNothing); - } - - // ScannerScreen.dispose() schedules disconnect work that debounces notify. - // Drain that debounce timer before test teardown. - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pump(const Duration(milliseconds: 60)); - }); - - testWidgets('ScannerScreen narrow width keeps actions without overflow', ( - tester, - ) async { - await tester.binding.setSurfaceSize(const Size(320, 700)); - addTearDown(() => tester.binding.setSurfaceSize(null)); - - final connector = _FakeMeshCoreConnector(); - - await tester.pumpWidget( - _buildTestApp(connector: connector, child: const ScannerScreen()), - ); - await tester.pumpAndSettle(); - - expect(tester.takeException(), isNull); - - final context = tester.element(find.byType(ScannerScreen)); - final l10n = AppLocalizations.of(context); - expect(find.text(l10n.scanner_scan), findsOneWidget); - - if (PlatformInfo.supportsUsbSerial) { - expect(find.text(l10n.connectionChoiceUsbLabel), findsOneWidget); - } - if (!PlatformInfo.isWeb) { - expect(find.text(l10n.connectionChoiceTcpLabel), findsOneWidget); - } - - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pump(const Duration(milliseconds: 60)); - }); - - testWidgets('UsbScreen narrow width long status text does not overflow', ( - tester, - ) async { - await tester.binding.setSurfaceSize(const Size(320, 700)); - addTearDown(() => tester.binding.setSurfaceSize(null)); - - final connector = - _FakeMeshCoreConnector(initialState: MeshCoreConnectionState.connected) - ..fakeUsbTransportConnected = true - ..fakeActiveUsbPortDisplayLabel = - '/dev/bus/usb/001/002 - KD3CGK mesh-utility.org very long label'; - - await tester.pumpWidget( - _buildTestApp(connector: connector, child: const UsbScreen()), - ); - await tester.pumpAndSettle(); - - expect(tester.takeException(), isNull); - - final context = tester.element(find.byType(UsbScreen)); - final l10n = AppLocalizations.of(context); - expect( - find.text( - l10n.scanner_connectedTo(connector.fakeActiveUsbPortDisplayLabel!), - ), - findsOneWidget, - ); - - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pump(const Duration(milliseconds: 60)); - }); - - group('Error Handling', () { - testWidgets('shows error SnackBar when listing ports fails', ( - tester, - ) async { - final connector = _FakeMeshCoreConnector(); - connector.listUsbPortsImpl = () async { - throw PlatformException( - code: 'usb_permission_denied', - message: 'Permission denied', - ); - }; - - await tester.pumpWidget( - _buildTestApp(connector: connector, child: const UsbScreen()), - ); - await tester.pumpAndSettle(); - - expect(find.text('USB permission was denied.'), findsOneWidget); + group('USB port name parsing', () { + test('normalizeUsbPortName extracts raw port before separator', () { + expect(normalizeUsbPortName('COM6 - USB Serial Device (COM6)'), 'COM6'); }); - testWidgets('connection failure shows SnackBar error', (tester) async { - final connector = _FakeMeshCoreConnector(ports: ['COM1']); - var connectAttempted = false; - connector.connectUsbImpl = ({required String portName}) async { - connectAttempted = true; - throw PlatformException(code: 'usb_busy', message: 'Device is busy'); - }; + test('normalizeUsbPortName returns input when no separator', () { + expect(normalizeUsbPortName('/dev/ttyUSB0'), '/dev/ttyUSB0'); + }); - await tester.pumpWidget( - _buildTestApp(connector: connector, child: const UsbScreen()), - ); - await tester.pumpAndSettle(); + test('normalizeUsbPortName trims whitespace', () { + expect(normalizeUsbPortName(' COM3 '), 'COM3'); + }); - await tester.tap(find.byType(ListTile).first); - await tester.pumpAndSettle(); - - expect(connectAttempted, isTrue); + test('friendlyUsbPortName extracts description field', () { expect( - find.text('Another USB connection request is already in progress.'), - findsOneWidget, + friendlyUsbPortName('COM6 - USB Serial Device (COM6) - HWID'), + 'USB Serial Device (COM6)', + ); + }); + + test( + 'friendlyUsbPortName falls back to raw name if description is n/a', + () { + expect(friendlyUsbPortName('COM6 - n/a'), 'COM6'); + }, + ); + + test('friendlyUsbPortName falls back when only one part', () { + expect(friendlyUsbPortName('/dev/ttyUSB0'), '/dev/ttyUSB0'); + }); + }); + + // -- Connect guard -------------------------------------------------------- + + group('USB connect guard', () { + test('allows connect when disconnected', () { + expect( + shouldAllowUsbConnect(MeshCoreConnectionState.disconnected), + isTrue, + ); + }); + + test('blocks connect when connected', () { + expect(shouldAllowUsbConnect(MeshCoreConnectionState.connected), isFalse); + }); + + test('blocks connect when connecting', () { + expect( + shouldAllowUsbConnect(MeshCoreConnectionState.connecting), + isFalse, + ); + }); + + test('blocks connect when scanning', () { + expect(shouldAllowUsbConnect(MeshCoreConnectionState.scanning), isFalse); + }); + + test('blocks connect when disconnecting', () { + expect( + shouldAllowUsbConnect(MeshCoreConnectionState.disconnecting), + isFalse, + ); + }); + }); + + // -- Status text ---------------------------------------------------------- + + group('USB status text', () { + String status({ + bool isLoadingPorts = false, + bool isUsbTransportConnected = false, + MeshCoreConnectionState state = MeshCoreConnectionState.disconnected, + MeshCoreTransportType transport = MeshCoreTransportType.usb, + String? activeUsbPortDisplayLabel, + }) => usbStatusText( + isLoadingPorts: isLoadingPorts, + isUsbTransportConnected: isUsbTransportConnected, + state: state, + transport: transport, + activeUsbPortDisplayLabel: activeUsbPortDisplayLabel, + searching: 'SEARCHING', + connectedTo: (label) => 'CONNECTED:$label', + disconnecting: 'DISCONNECTING', + connecting: 'CONNECTING', + notConnected: 'NOT_CONNECTED', + ); + + test('loading ports shows searching', () { + expect(status(isLoadingPorts: true), 'SEARCHING'); + }); + + test('connected USB with label', () { + expect( + status( + isUsbTransportConnected: true, + state: MeshCoreConnectionState.connected, + activeUsbPortDisplayLabel: 'COM6 - Device', + ), + 'CONNECTED:COM6 - Device', + ); + }); + + test('connected USB with null label falls back to USB', () { + expect( + status( + isUsbTransportConnected: true, + state: MeshCoreConnectionState.connected, + ), + 'CONNECTED:USB', + ); + }); + + test('USB transport connected but disconnecting', () { + expect( + status( + isUsbTransportConnected: true, + state: MeshCoreConnectionState.disconnecting, + ), + 'DISCONNECTING', + ); + }); + + test('USB transport connected but scanning falls to default', () { + expect( + status( + isUsbTransportConnected: true, + state: MeshCoreConnectionState.scanning, + ), + 'NOT_CONNECTED', + ); + }); + + test('connecting over USB shows connecting', () { + expect(status(state: MeshCoreConnectionState.connecting), 'CONNECTING'); + }); + + test('connecting over bluetooth falls through to not-connected', () { + expect( + status( + state: MeshCoreConnectionState.connecting, + transport: MeshCoreTransportType.bluetooth, + ), + 'NOT_CONNECTED', + ); + }); + + test('disconnected shows not-connected', () { + expect(status(), 'NOT_CONNECTED'); + }); + }); + + // -- Error mapping -------------------------------------------------------- + + group('USB friendly error mapping', () { + test('PlatformException usb_permission_denied', () { + expect( + usbFriendlyErrorKey(PlatformException(code: 'usb_permission_denied')), + 'permissionDenied', + ); + }); + + test('PlatformException usb_device_missing', () { + expect( + usbFriendlyErrorKey(PlatformException(code: 'usb_device_missing')), + 'deviceMissing', + ); + }); + + test('PlatformException usb_device_detached', () { + expect( + usbFriendlyErrorKey(PlatformException(code: 'usb_device_detached')), + 'deviceMissing', + ); + }); + + test('PlatformException usb_invalid_port', () { + expect( + usbFriendlyErrorKey(PlatformException(code: 'usb_invalid_port')), + 'invalidPort', + ); + }); + + test('PlatformException usb_busy', () { + expect(usbFriendlyErrorKey(PlatformException(code: 'usb_busy')), 'busy'); + }); + + test('PlatformException usb_not_connected', () { + expect( + usbFriendlyErrorKey(PlatformException(code: 'usb_not_connected')), + 'notConnected', + ); + }); + + test('PlatformException usb_open_failed', () { + expect( + usbFriendlyErrorKey(PlatformException(code: 'usb_open_failed')), + 'openFailed', + ); + }); + + test('PlatformException usb_driver_missing', () { + expect( + usbFriendlyErrorKey(PlatformException(code: 'usb_driver_missing')), + 'openFailed', + ); + }); + + test('PlatformException usb_connect_failed', () { + expect( + usbFriendlyErrorKey(PlatformException(code: 'usb_connect_failed')), + 'connectFailed', + ); + }); + + test('PlatformException with unknown code falls through', () { + expect( + usbFriendlyErrorKey(PlatformException(code: 'usb_whatever')), + 'unknown', + ); + }); + + test('UnsupportedError → unsupported', () { + expect(usbFriendlyErrorKey(UnsupportedError('nope')), 'unsupported'); + }); + + test('StateError "already active" → alreadyActive', () { + expect( + usbFriendlyErrorKey(StateError('already active')), + 'alreadyActive', + ); + }); + + test('StateError "No USB serial device selected" → noDeviceSelected', () { + expect( + usbFriendlyErrorKey(StateError('No USB serial device selected')), + 'noDeviceSelected', + ); + }); + + test('StateError "not open" → portClosed', () { + expect(usbFriendlyErrorKey(StateError('port not open')), 'portClosed'); + }); + + test('StateError "closed" → portClosed', () { + expect( + usbFriendlyErrorKey(StateError('connection closed')), + 'portClosed', + ); + }); + + test('StateError "Timed out" → connectTimedOut', () { + expect( + usbFriendlyErrorKey(StateError('Timed out waiting')), + 'connectTimedOut', + ); + }); + + test('StateError "Failed to open" → openFailed', () { + expect( + usbFriendlyErrorKey(StateError('Failed to open device')), + 'openFailed', + ); + }); + + test('TimeoutException → connectTimedOut', () { + expect(usbFriendlyErrorKey(TimeoutException('slow')), 'connectTimedOut'); + }); + + test('generic error → unknown', () { + expect(usbFriendlyErrorKey(Exception('boom')), 'unknown'); + }); + }); + + // -- Localized strings resolve correctly ---------------------------------- + + testWidgets('English USB localizations resolve without error', ( + tester, + ) async { + late AppLocalizations l10n; + + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + l10n = AppLocalizations.of(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(l10n.usbScreenTitle, isNotEmpty); + expect(l10n.usbScreenStatus, 'Select a USB device'); + expect(l10n.usbStatus_notConnected, isNotEmpty); + expect(l10n.usbStatus_connecting, isNotEmpty); + expect(l10n.usbStatus_searching, isNotEmpty); + expect(l10n.usbErrorPermissionDenied, isNotEmpty); + expect(l10n.usbErrorDeviceMissing, isNotEmpty); + expect(l10n.usbErrorInvalidPort, isNotEmpty); + expect(l10n.usbErrorBusy, isNotEmpty); + expect(l10n.usbErrorNotConnected, isNotEmpty); + expect(l10n.usbErrorOpenFailed, isNotEmpty); + expect(l10n.usbErrorConnectFailed, isNotEmpty); + expect(l10n.usbErrorUnsupported, isNotEmpty); + expect(l10n.usbErrorAlreadyActive, isNotEmpty); + expect(l10n.usbErrorNoDeviceSelected, isNotEmpty); + expect(l10n.usbErrorPortClosed, isNotEmpty); + expect(l10n.usbErrorConnectTimedOut, isNotEmpty); + expect(l10n.scanner_connectedTo('device'), contains('device')); + expect(l10n.scanner_disconnecting, isNotEmpty); + }); + + // -- Isolated widget: status bar Row with FittedBox overflow -------------- + + testWidgets('USB status bar with long text does not overflow at 320px', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(320, 100)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + const longText = + 'Connected to /dev/bus/usb/001/002 - KD3CGK mesh-utility.org very long label'; + const statusColor = Colors.green; + + // Exact widget tree from _buildStatusBar in UsbScreen. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + color: statusColor.withValues(alpha: 0.1), + child: Row( + children: [ + const Icon(Icons.circle, size: 12, color: statusColor), + const SizedBox(width: 8), + Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + longText, + style: const TextStyle( + color: statusColor, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text(longText), findsOneWidget); + }); + + // -- Isolated widget: bottom nav FittedBox overflow ----------------------- + + testWidgets('Bottom nav row with multiple FABs does not overflow at 320px', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(320, 200)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + // Mirrors the bottomNavigationBar structure from ScannerScreen / UsbScreen + // with all possible buttons visible. + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: const SizedBox.expand(), + bottomNavigationBar: SafeArea( + top: false, + minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FloatingActionButton.extended( + onPressed: () {}, + heroTag: 'usb', + icon: const Icon(Icons.usb), + label: const Text('USB'), + ), + const SizedBox(width: 12), + FloatingActionButton.extended( + onPressed: () {}, + heroTag: 'tcp', + icon: const Icon(Icons.lan), + label: const Text('TCP'), + ), + const SizedBox(width: 12), + FloatingActionButton.extended( + onPressed: () {}, + heroTag: 'ble', + icon: const Icon(Icons.bluetooth_searching), + label: const Text('Scan'), + ), + ], + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('USB'), findsOneWidget); + expect(find.text('TCP'), findsOneWidget); + expect(find.text('Scan'), findsOneWidget); + }); + + // -- describeWebUsbPort --------------------------------------------------- + + group('describeWebUsbPort', () { + test('null vendor and product returns requestPortLabel', () { + expect( + describeWebUsbPort(vendorId: null, productId: null), + 'Choose USB Device', + ); + }); + + test('known VID:PID uses knownUsbNames', () { + expect( + describeWebUsbPort( + vendorId: 0x1A86, + productId: 0x7523, + knownUsbNames: {'1a86:7523': 'CH340 Serial'}, + ), + 'CH340 Serial (VID:1A86 PID:7523)', + ); + }); + + test('unknown VID:PID uses fallback device name', () { + expect( + describeWebUsbPort( + vendorId: 0x1234, + productId: 0x5678, + fallbackDeviceName: 'My Device', + ), + 'My Device (VID:1234 PID:5678)', + ); + }); + }); + + // -- buildUsbDisplayLabel ------------------------------------------------- + + group('buildUsbDisplayLabel', () { + test('appends device name when present', () { + expect( + buildUsbDisplayLabel( + basePortLabel: 'COM6', + deviceName: 'MeshCore Node', + ), + 'COM6 - MeshCore Node', + ); + }); + + test('returns base label when device name is null', () { + expect( + buildUsbDisplayLabel(basePortLabel: 'COM6', deviceName: null), + 'COM6', + ); + }); + + test('returns base label when device name is whitespace', () { + expect( + buildUsbDisplayLabel(basePortLabel: 'COM6', deviceName: ' '), + 'COM6', ); }); });