mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
Merge d529ce9228 into 6b6a881c7a
This commit is contained in:
commit
7f9e142efc
9 changed files with 1464 additions and 538 deletions
|
|
@ -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<Channel> _cachedChannels = [];
|
||||
final Map<int, bool> _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<bool> 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<void> 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;
|
||||
|
|
@ -4914,6 +4973,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<Contact?>().firstWhere(
|
||||
(c) => c?.publicKeyHex == keyHex,
|
||||
orElse: () => null,
|
||||
);
|
||||
}
|
||||
|
||||
void _maybeIncrementChannelUnread(
|
||||
ChannelMessage message, {
|
||||
required bool isNew,
|
||||
|
|
|
|||
177
lib/main.dart
177
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<MeshCoreApp> createState() => _MeshCoreAppState();
|
||||
}
|
||||
|
||||
class _MeshCoreAppState extends State<MeshCoreApp> {
|
||||
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
|
||||
StreamSubscription<NotificationTapEvent>? _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<AppSettingsService>(
|
||||
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(),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -32,6 +33,20 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||
DiscoverySortOption discoverySortOption = DiscoverySortOption.lastSeen;
|
||||
Timer? _searchDebounce;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_clearAdvertNotifications();
|
||||
}
|
||||
|
||||
void _clearAdvertNotifications() {
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final ids = connector.allContacts.map((c) => c.publicKeyHex).toList();
|
||||
final ns = NotificationService();
|
||||
ns.clearAllAdvertNotifications();
|
||||
ns.clearAdvertNotifications(ids);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -44,6 +45,10 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||
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()),
|
||||
|
|
@ -54,6 +59,12 @@ class _ScannerScreenState extends State<ScannerScreen> {
|
|||
|
||||
_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) {
|
||||
|
|
|
|||
|
|
@ -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<void> 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<void> 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<void> 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<void> onStart(DateTime timestamp, TaskStarter starter) async {}
|
||||
Future<void> 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<void> onDestroy(DateTime timestamp, bool isTimeout) async {}
|
||||
|
|
|
|||
|
|
@ -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<NotificationTapEvent> _tapController =
|
||||
StreamController<NotificationTapEvent>.broadcast();
|
||||
|
||||
/// Listen to this stream to handle navigation when a notification
|
||||
/// is tapped.
|
||||
Stream<NotificationTapEvent> 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<void> _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<void> 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<void> 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<void> _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<void> _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 = <String>[];
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
33
lib/storage/last_device_store.dart
Normal file
33
lib/storage/last_device_store.dart
Normal file
|
|
@ -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<void> 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<void> clearPersistedDevice() async {
|
||||
final prefs = PrefsManager.instance;
|
||||
await prefs.remove(_prefKeyLastDeviceId);
|
||||
await prefs.remove(_prefKeyLastDeviceName);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> 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<MeshCoreConnector>.value(value: connector),
|
||||
ChangeNotifierProvider<AppSettingsService>(
|
||||
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<NavigatorState>(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<ButtonStyleButton>(
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>? ports,
|
||||
}) : _ports = ports ?? <String>[];
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure helpers extracted from UsbScreen logic.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
final MeshCoreConnectionState initialState;
|
||||
final List<String> _ports;
|
||||
|
||||
String? requestPortLabel;
|
||||
String? fallbackDeviceName;
|
||||
int connectUsbCalls = 0;
|
||||
String? lastConnectPortName;
|
||||
String? fakeActiveUsbPort;
|
||||
String? fakeActiveUsbPortDisplayLabel;
|
||||
bool fakeUsbTransportConnected = false;
|
||||
Future<List<String>> Function()? listUsbPortsImpl;
|
||||
Future<void> 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<List<String>> listUsbPorts() async {
|
||||
if (listUsbPortsImpl != null) {
|
||||
return listUsbPortsImpl!();
|
||||
}
|
||||
return List<String>.from(_ports);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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<MeshCoreConnector>.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: <String>['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: <String>['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: <String>['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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue