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', ); }); });