Compare commits

..

1 Commits

Author SHA1 Message Date
Enot (ded) Skelly bdd7fc0cdd remove unused macos path_provider_foundation
added in #299 but appears not needed, flutter removes when building
2026-04-08 14:56:34 -07:00
35 changed files with 559 additions and 1612 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
## BLE Frames & Protocol Notes
- Nordic UART Service (NUS) UUIDs: Service `6e400001-b5a3-f393-e0a9-e50e24dcca9e`, RX `6e400002-b5a3-f393-e0a9-e50e24dcca9e`, TX `6e400003-b5a3-f393-e0a9-e50e24dcca9e`.
- Discovery: scans for device names matching known prefixes and filters by `platformName`/`advertisementData.advName`.
- Discovery: scans for device name prefix `MeshCore-` and filters by `platformName`/`advertisementData.advName`.
- Frames are capped at `maxFrameSize = 172` bytes; byte 0 is the command/response/push code. I/O is `MeshCoreConnector.sendFrame` and `MeshCoreConnector.receivedFrames`.
- Command codes (to device): `cmdAppStart`=1, `cmdSendTxtMsg`=2, `cmdSendChannelTxtMsg`=3, `cmdGetContacts`=4, `cmdGetDeviceTime`=5, `cmdSetDeviceTime`=6, `cmdSendSelfAdvert`=7, `cmdSetAdvertName`=8, `cmdAddUpdateContact`=9, `cmdSyncNextMessage`=10, `cmdSetRadioParams`=11, `cmdSetRadioTxPower`=12, `cmdResetPath`=13, `cmdSetAdvertLatLon`=14, `cmdRemoveContact`=15, `cmdShareContact`=16, `cmdExportContact`=17, `cmdImportContact`=18, `cmdReboot`=19, `cmdSendLogin`=26, `cmdGetChannel`=31, `cmdSetChannel`=32, `cmdGetRadioSettings`=57.
- Response codes (from device): `respCodeOk`=0, `respCodeErr`=1, `respCodeContactsStart`=2, `respCodeContact`=3, `respCodeEndOfContacts`=4, `respCodeSelfInfo`=5, `respCodeSent`=6, `respCodeContactMsgRecv`=7, `respCodeChannelMsgRecv`=8, `respCodeCurrTime`=9, `respCodeNoMoreMessages`=10, `respCodeContactMsgRecvV3`=16, `respCodeChannelMsgRecvV3`=17, `respCodeChannelInfo`=18, `respCodeRadioSettings`=25.
+1 -1
View File
@@ -61,7 +61,7 @@ lib/
- **TX Characteristic**: `6e400003-b5a3-f393-e0a9-e50e24dcca9e` (Notify from device)
### Device Discovery
- Scans for devices with known name prefixes
- Scans for devices with name prefix `MeshCore-`
- Filters by `platformName` or `advertisementData.advName`
### Connection States
-71
View File
@@ -1,71 +0,0 @@
# How to contribute to Meshcore Open
Before submitting any pull requests (PR), please review the following information.
Unsolicited PRs without previous discussion or open issues may be
rejected. As may changes that are too broad (i.e. 100 files changed) or that
cover too many separate changes. If the changes are clearly AI generated they
may also be rejected. [See more](#ai-use)
## First Step Checklist
### **Did you find a bug?**
* **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/zjs81/meshcore-open/issues).
* If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/zjs81/meshcore-open/issues/new).
Be sure to include a **title and clear description**, as much relevant
information as possible, and a **code sample** or an **executable test case**
demonstrating the expected behavior that is not occurring. You can also include
screenshots or video.
* DO NOT start work and submit a PR at this time, please discuss the issue and
your implementation plan first.
### **Did you fix whitespace, format code, or make a purely cosmetic patch?**
Changes that are cosmetic in nature and do not add anything substantial to the
stability, functionality, or testability of the application will generally not
be accepted.
### **Do you intend to add a new feature or change an existing one?**
* Suggest your change in a new issue as a feature request.
* DO NOT start work and submit a PR at this time, please discuss the change and
your implementation plan first.
* After it is generally decided that the feature or change fits the goals of the
project you can start work or open a PR if you have already started.
## Submitting your patch
* All changes should be based on the `dev` branch. When creating your PR please
be sure to change the target to merge into dev, and when starting work on a new
branch be sure to start on latest `dev`.
* Ensure the PR description clearly describes the problem and solution. Include
the relevant issue number if applicable.
* The PR should contain **one commit** only, the commit message should have a
clear title followed by a new line and then brief description if needed. PR with
multiple commits will be squashed into one before merging if required. See
[Git Mastery](https://git-mastery.org/lessons/commitMessage/) for more
information on good commit messages.
* **Before committing changes** on your branch, be sure to run both
`dart format .` and `flutter analyze`. The continuous development checks will
fail if issues here are not addressed before hand.
## AI-use
Everyone loves some help, AI agents are a tool in many of our belts. The project
is not anti-AI.
There are some limits to acceptable use however. Generally:
* All code generated by AI should be thoroughly reviewed by the contributor.
* The changes should be tightly controlled to not change anything out of scope
for the patch, bug fix, etc.
* The contributor should have a good understanding of what the code does and how
the application works in order to effectively be able to manage the agent.
+2 -11
View File
@@ -150,8 +150,7 @@ lib/
├── main.dart # App entry point
├── connector/
│ ├── meshcore_connector.dart # BLE communication & state management
── meshcore_protocol.dart # Protocol definitions & frame parsing
│ └── meshcore_uuids.dart # Device names and IDs (add prefixes here!)
── meshcore_protocol.dart # Protocol definitions & frame parsing
├── screens/
│ ├── scanner_screen.dart # Device scanning (home screen)
│ ├── contacts_screen.dart # Contact list
@@ -185,15 +184,7 @@ lib/
### Device Discovery
Devices are discovered by scanning for BLE advertisements with known MeshCore device name prefixes. These are currently:
- `MeshCore-`
- `Whisper-`
- `WisCore-`
- `HT-`
- `LowMesh_MC_`
New device prefixes can be added in `lib/connector/meshcore_uuids.dart`.
Devices are discovered by scanning for BLE advertisements with the name prefix `MeshCore-`
### Message Format
+1 -6
View File
@@ -21,12 +21,7 @@ The MeshCore BLE protocol implements a binary frame-based communication system u
### Connection Flow
1. **Scan** for devices with known name prefixes (defined in `MeshCoreUuids.deviceNamePrefixes`):
- `MeshCore-`
- `Whisper-`
- `WisCore-`
- `HT-`
- `LowMesh_MC_`
1. **Scan** for devices with name prefix `MeshCore-`
2. **Connect** with 15-second timeout
3. **Request MTU** of 185 bytes (falls back to default if unsupported)
4. **Discover services** and locate NUS characteristics
+1 -6
View File
@@ -49,12 +49,7 @@ enum MeshCoreConnectionState {
## BLE Connection Lifecycle
1. **Scan** with known name prefixes (defined in `MeshCoreUuids.deviceNamePrefixes`):
- `MeshCore-`
- `Whisper-`
- `WisCore-`
- `HT-`
- `LowMesh_MC_`
1. **Scan** with keyword filters `["MeshCore-", "Whisper-"]`
2. **Connect** with 15-second timeout
3. **Request MTU** 185 bytes (non-web only)
4. **Discover services** and locate NUS
+1 -74
View File
@@ -40,7 +40,6 @@ 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';
@@ -282,7 +281,6 @@ 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 =
@@ -770,10 +768,6 @@ 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);
@@ -1885,7 +1879,6 @@ class MeshCoreConnector extends ChangeNotifier {
);
_setState(MeshCoreConnectionState.connected);
_lastDeviceStore.persistLastDevice(_deviceId!, _deviceDisplayName!);
if (_shouldGateInitialChannelSync) {
_hasReceivedDeviceInfo = false;
_pendingInitialChannelSync = true;
@@ -2232,56 +2225,6 @@ 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,
@@ -2302,8 +2245,6 @@ class MeshCoreConnector extends ChangeNotifier {
if (manual) {
_manualDisconnect = true;
_cancelReconnectTimer();
_lastDeviceStore.clearPersistedDevice();
_notificationService.cancelAll();
unawaited(_backgroundService?.stop());
} else {
_manualDisconnect = false;
@@ -4035,14 +3976,11 @@ class MeshCoreConnector extends ChangeNotifier {
tag: 'Connector',
);
// Preserve user-selected path settings and previously known GPS when
// refreshed frames omit coordinates (lat/lon encoded as 0,0).
// CRITICAL: Preserve user's path override when contact is refreshed from device
_contacts[existingIndex] = contact.copyWith(
lastMessageAt: mergedLastMessageAt,
pathOverride: existing.pathOverride, // Preserve user's path choice
pathOverrideBytes: existing.pathOverrideBytes,
latitude: contact.latitude ?? existing.latitude,
longitude: contact.longitude ?? existing.longitude,
);
appLogger.info(
@@ -4969,17 +4907,6 @@ 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,
-3
View File
@@ -7,9 +7,6 @@ class MeshCoreUuids {
"MeshCore-",
"Whisper-",
"WisCore-",
"Seeed",
"Lilygo",
"HT-",
"LowMesh_MC_",
];
}
-38
View File
@@ -1,38 +0,0 @@
class GifHelper {
/// Parse a known GIF format, which can be any of:
/// g:GIFID
/// https://media.giphy.com/media/GIFID/giphy.gif
/// https://giphy.com/gifs/Optional-title-with-dashes-GIFID
///
/// GIFID is a Giphy GIF ID. The https:// is optional (and
/// can also be http://). The giphy.com/gifs form can also
/// include a trailing slash.
///
/// Returns null if text is not a valid GIF format
static String? parseGif(String text) {
final trimmed = text.trim();
final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed);
if (match != null) {
return match.group(1);
}
final directUrlMatch = RegExp(
r'^(?:https?:\/\/)?media\.giphy\.com\/media\/([A-Za-z0-9_-]+)\/giphy\.gif$',
).firstMatch(trimmed);
if (directUrlMatch != null) {
return directUrlMatch.group(1);
}
// Giphy understands page URLs with just the ID, or any string and a
// dash before the ID, and redirects to a page with a dash-separated
// title, a dash, and the ID. IDs in this form *probably* can't
// contain dashes.
final pageMatch = RegExp(
r'^(?:https?:\/\/)?giphy\.com\/gifs\/(?:[^/?]*-)?([A-Za-z0-9_]+)\/?$',
).firstMatch(trimmed);
return pageMatch?.group(1);
}
/// Encode a GIF in a format that parseGif() can parse.
static String encodeGif(String gifId) {
return 'g:$gifId';
}
}
-5
View File
@@ -109,9 +109,4 @@ class ReactionHelper {
return ReactionInfo(targetHash: match.group(1)!, emoji: emoji);
}
/// Encode a reaction message that parseReaction() can parse.
static String encodeReaction(String hash, String emojiIndex) {
return 'r:$hash:$emojiIndex';
}
}
+1 -1
View File
@@ -2062,4 +2062,4 @@
"scanner_linuxPairingShowPin": "Покажи PIN",
"repeater_cliQuickClockSync": "Синхронизация на часовника",
"repeater_cliQuickDiscovery": "Открий Съседи"
}
}
+1 -1
View File
@@ -2090,4 +2090,4 @@
"scanner_linuxPairingPinPrompt": "Geben Sie die PIN für {deviceName} ein (leer lassen, falls keine).",
"repeater_cliQuickClockSync": "Uhr Synchronisieren",
"repeater_cliQuickDiscovery": "Entdecke Nachbarn"
}
}
+1 -1
View File
@@ -2090,4 +2090,4 @@
"translation_systemLanguage": "Idioma del sistema",
"repeater_cliQuickDiscovery": "Descubrir Vecinos",
"repeater_cliQuickClockSync": "Sincronización del reloj"
}
}
+1 -1
View File
@@ -2062,4 +2062,4 @@
"scanner_linuxPairingShowPin": "Afficher le code PIN",
"repeater_cliQuickClockSync": "Synchronisation de l'horloge",
"repeater_cliQuickDiscovery": "Découvrir les voisins"
}
}
+1 -2
View File
@@ -2098,7 +2098,6 @@
"translation_translateTo": "Fordítás {language}-ra",
"translation_translationOptions": "Fordítási lehetőségek",
"translation_systemLanguage": "Rendszer nyelvé",
"scanner_linuxPairingPinPrompt": "Adja meg a(z) {deviceName} PIN-kódját (hagyja üresen, ha nincs).",
"repeater_cliQuickClockSync": "Óra szinkronizálás",
"repeater_cliQuickDiscovery": "Fedezd fel a szomszédokat"
}
}
+1 -1
View File
@@ -2062,4 +2062,4 @@
"scanner_linuxPairingHidePin": "Nascondi il PIN",
"repeater_cliQuickClockSync": "Sincronizzazione dell'orologio",
"repeater_cliQuickDiscovery": "Scopri i Vicini"
}
}
+1 -1
View File
@@ -2100,4 +2100,4 @@
"scanner_linuxPairingPinPrompt": "{deviceName}のPINを入力してください(なしの場合は空欄のまま)。",
"repeater_cliQuickClockSync": "クロック同期",
"repeater_cliQuickDiscovery": "近隣を発見する"
}
}
+1 -1
View File
@@ -2100,4 +2100,4 @@
"translation_systemLanguage": "시스템 언어",
"repeater_cliQuickClockSync": "시계 동기화",
"repeater_cliQuickDiscovery": "이웃 발견하기"
}
}
+1 -1
View File
@@ -3677,7 +3677,7 @@ class AppLocalizationsHu extends AppLocalizations {
@override
String scanner_linuxPairingPinPrompt(String deviceName) {
return 'Adja meg a(z) $deviceName PIN-kódját (hagyja üresen, ha nincs).';
return 'Adja meg a PIN kódot a $deviceName számára (hagyja üresen, ha nincs).';
}
@override
+1 -1
View File
@@ -2062,4 +2062,4 @@
"scanner_linuxPairingPinTitle": "BluetoothkoppelingsPIN",
"repeater_cliQuickDiscovery": "Ontdek Buren",
"repeater_cliQuickClockSync": "Kloksynchronisatie"
}
}
+1 -1
View File
@@ -2062,4 +2062,4 @@
"scanner_linuxPairingPinTitle": "PIN de emparelhamento Bluetooth",
"repeater_cliQuickClockSync": "Sincronização do Relógio",
"repeater_cliQuickDiscovery": "Descobrir Vizinhos"
}
}
+1 -1
View File
@@ -2062,4 +2062,4 @@
"translation_systemLanguage": "Jazyk systému",
"repeater_cliQuickClockSync": "Synchronizácia hodin",
"repeater_cliQuickDiscovery": "Objaviť susedov"
}
}
+1 -1
View File
@@ -2062,4 +2062,4 @@
"scanner_linuxPairingPinTitle": "Bluetooth PIN za seznanjanje",
"repeater_cliQuickDiscovery": "Odkrijte sosede",
"repeater_cliQuickClockSync": "Usklajevanje ure"
}
}
+1 -1
View File
@@ -2067,4 +2067,4 @@
"translation_systemLanguage": "系统语言",
"repeater_cliQuickDiscovery": "发现邻居",
"repeater_cliQuickClockSync": "同步时钟"
}
}
+54 -129
View File
@@ -1,16 +1,10 @@
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';
@@ -131,7 +125,7 @@ https://creativecommons.org/licenses/by/4.0/
});
}
class MeshCoreApp extends StatefulWidget {
class MeshCoreApp extends StatelessWidget {
final MeshCoreConnector connector;
final MessageRetryService retryService;
final PathHistoryService pathHistoryService;
@@ -161,136 +155,67 @@ class MeshCoreApp extends StatefulWidget {
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: 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),
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),
],
child: Consumer<AppSettingsService>(
builder: (context, settingsService, child) {
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,
),
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
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(),
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,
),
),
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(),
);
},
),
+11 -6
View File
@@ -11,7 +11,6 @@ import '../connector/meshcore_connector.dart';
import '../utils/platform_info.dart';
import '../helpers/chat_scroll_controller.dart';
import '../connector/meshcore_protocol.dart';
import '../helpers/gif_helper.dart';
import '../helpers/reaction_helper.dart';
import '../helpers/utf8_length_limiter.dart';
import '../l10n/l10n.dart';
@@ -356,7 +355,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final settingsService = context.watch<AppSettingsService>();
final enableTracing = settingsService.settings.enableMessageTracing;
final isOutgoing = message.isOutgoing;
final gifId = GifHelper.parseGif(message.text);
final gifId = _parseGifId(message.text);
final poi = _parsePoiMessage(message.text);
final translatedDisplayText =
message.translatedText != null &&
@@ -700,7 +699,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final colorScheme = Theme.of(context).colorScheme;
final previewTextColor = colorScheme.onSurface.withValues(alpha: 0.7);
final gifId = GifHelper.parseGif(replyText);
final gifId = _parseGifId(replyText);
final poi = _parsePoiMessage(replyText);
Widget contentPreview;
@@ -812,6 +811,12 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
);
}
String? _parseGifId(String text) {
final trimmed = text.trim();
final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed);
return match?.group(1);
}
_PoiInfo? _parsePoiMessage(String text) {
final trimmed = text.trim();
final match = RegExp(
@@ -892,7 +897,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
isScrollControlled: true,
builder: (context) => GifPicker(
onGifSelected: (gifId) {
_textController.text = GifHelper.encodeGif(gifId);
_textController.text = 'g:$gifId';
},
),
);
@@ -1048,7 +1053,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController,
builder: (context, value, child) {
final gifId = GifHelper.parseGif(value.text);
final gifId = _parseGifId(value.text);
if (gifId != null) {
return Focus(
autofocus: true,
@@ -1317,7 +1322,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
message.senderName,
message.text,
);
final reactionText = ReactionHelper.encodeReaction(hash, emojiIndex);
final reactionText = 'r:$hash:$emojiIndex';
connector.sendChannelMessage(widget.channel, reactionText);
}
+16 -5
View File
@@ -16,7 +16,6 @@ import '../connector/meshcore_protocol.dart';
import '../helpers/reaction_helper.dart';
import '../widgets/message_status_icon.dart';
import '../helpers/chat_scroll_controller.dart';
import '../helpers/gif_helper.dart';
import '../helpers/path_helper.dart';
import '../helpers/utf8_length_limiter.dart';
import '../models/channel_message.dart';
@@ -524,7 +523,7 @@ class _ChatScreenState extends State<ChatScreen> {
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController,
builder: (context, value, child) {
final gifId = GifHelper.parseGif(value.text);
final gifId = _parseGifId(value.text);
if (gifId != null) {
return Focus(
autofocus: true,
@@ -602,13 +601,19 @@ class _ChatScreenState extends State<ChatScreen> {
);
}
String? _parseGifId(String text) {
final trimmed = text.trim();
final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed);
return match?.group(1);
}
void _showGifPicker(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => GifPicker(
onGifSelected: (gifId) {
_textController.text = GifHelper.encodeGif(gifId);
_textController.text = 'g:$gifId';
},
),
);
@@ -1541,7 +1546,7 @@ class _ChatScreenState extends State<ChatScreen> {
senderName,
message.text,
);
final reactionText = ReactionHelper.encodeReaction(hash, emojiIndex);
final reactionText = 'r:$hash:$emojiIndex';
connector.sendMessage(_resolveContact(connector), reactionText);
}
}
@@ -1571,7 +1576,7 @@ class _MessageBubble extends StatelessWidget {
final enableTracing = settingsService.settings.enableMessageTracing;
final isOutgoing = message.isOutgoing;
final colorScheme = Theme.of(context).colorScheme;
final gifId = GifHelper.parseGif(message.text);
final gifId = _parseGifId(message.text);
final poi = _parsePoiMessage(message.text);
final isFailed = message.status == MessageStatus.failed;
final bubbleColor = isFailed
@@ -1845,6 +1850,12 @@ class _MessageBubble extends StatelessWidget {
);
}
String? _parseGifId(String text) {
final trimmed = text.trim();
final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed);
return match?.group(1);
}
_PoiInfo? _parsePoiMessage(String text) {
final trimmed = text.trim();
final match = RegExp(
-15
View File
@@ -8,7 +8,6 @@ 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,20 +31,6 @@ 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();
-11
View File
@@ -7,7 +7,6 @@ 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,10 +43,6 @@ 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()),
@@ -58,12 +53,6 @@ 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) {
+34 -116
View File
@@ -1,121 +1,54 @@
import 'package:flutter/widgets.dart';
import '../utils/platform_info.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
/// 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 {
class BackgroundService {
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 (_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,
),
);
}
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,
),
);
_initialized = true;
}
Future<void> start() async {
if (!PlatformInfo.isMobile) return;
if (!PlatformInfo.isAndroid) return;
if (!_initialized) {
await initialize();
}
// 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;
final running = await FlutterForegroundTask.isRunningService;
if (running) return;
await FlutterForegroundTask.startService(
notificationTitle: 'MeshCore running',
notificationText: 'Keeping BLE connected',
callback: startCallback,
);
}
Future<void> stop() async {
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);
if (!PlatformInfo.isAndroid) return;
final running = await FlutterForegroundTask.isRunningService;
if (!running) return;
await FlutterForegroundTask.stopService();
}
}
// ---------------------------------------------------------------------------
// Foreground-service isolate entry point (Android)
// ---------------------------------------------------------------------------
@pragma('vm:entry-point')
void startCallback() {
FlutterForegroundTask.setTaskHandler(_MeshCoreTaskHandler());
@@ -123,25 +56,10 @@ void startCallback() {
class _MeshCoreTaskHandler extends TaskHandler {
@override
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.
}
Future<void> onStart(DateTime timestamp, TaskStarter starter) async {}
@override
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')}',
);
}
void onRepeatEvent(DateTime timestamp) {}
@override
Future<void> onDestroy(DateTime timestamp, bool isTimeout) async {}
+39 -187
View File
@@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:io' show Platform, File;
import 'dart:ui';
@@ -9,21 +8,6 @@ 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;
@@ -33,15 +17,6 @@ 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');
@@ -192,10 +167,6 @@ 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',
@@ -204,7 +175,6 @@ class NotificationService {
priority: Priority.high,
icon: '@mipmap/ic_launcher',
number: badgeCount,
groupKey: groupKey,
);
final iosDetails = DarwinNotificationDetails(
@@ -235,13 +205,6 @@ 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');
}
@@ -254,8 +217,6 @@ class NotificationService {
}) async {
if (!await _ensureInitialized()) return;
const groupKey = 'meshcore_adverts';
const androidDetails = AndroidNotificationDetails(
'adverts',
'Advertisements',
@@ -263,7 +224,6 @@ class NotificationService {
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
icon: '@mipmap/ic_launcher',
groupKey: groupKey,
);
const iosDetails = DarwinNotificationDetails(
@@ -294,15 +254,6 @@ 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');
}
@@ -316,12 +267,6 @@ 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',
@@ -330,7 +275,6 @@ class NotificationService {
priority: Priority.high,
icon: '@mipmap/ic_launcher',
number: badgeCount,
groupKey: groupKey,
);
final iosDetails = DarwinNotificationDetails(
@@ -366,70 +310,11 @@ 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)
@@ -447,42 +332,14 @@ class NotificationService {
void _onNotificationTapped(NotificationResponse response) {
final payload = response.payload;
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),
);
if (payload != null) {
debugPrint('Notification tapped: $payload');
// Handle navigation based on payload
// This can be extended to navigate to specific screens
}
}
Future<void> cancelAll() async {
_pendingNotifications.clear();
await _notifications.cancelAll();
}
@@ -495,11 +352,6 @@ 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);
@@ -510,13 +362,6 @@ 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);
@@ -530,21 +375,6 @@ 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.
@@ -715,13 +545,7 @@ class NotificationService {
Future<void> _showBatchSummary(List<_PendingNotification> batch) async {
if (!await _ensureInitialized()) return;
// Show each notification individually the Android
// groupKey on each type will cluster them automatically.
for (final notification in batch) {
await _showNotificationImmediately(notification);
}
// Debug logging
// Group by type
final messages = batch
.where((n) => n.type == _NotificationType.message)
.toList();
@@ -732,20 +556,48 @@ class NotificationService {
.where((n) => n.type == _NotificationType.channelMessage)
.toList();
// Build summary text using localized plurals
final parts = <String>[];
if (messages.isNotEmpty) {
parts.add('${messages.length} messages');
parts.add(_l10n.notification_messagesCount(messages.length));
}
if (channelMsgs.isNotEmpty) {
parts.add('${channelMsgs.length} channel msgs');
parts.add(_l10n.notification_channelMessagesCount(channelMsgs.length));
}
if (adverts.isNotEmpty) {
parts.add('${adverts.length} adverts');
parts.add(_l10n.notification_newNodesCount(adverts.length));
}
debugPrint(
'[Notification] batch dispatched: '
'${parts.join(", ")}',
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',
);
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');
}
}
}
+2 -5
View File
@@ -6,7 +6,6 @@ import 'package:llamadart/llamadart.dart';
import '../models/app_settings.dart';
import '../models/translation_support.dart';
import '../helpers/gif_helper.dart';
import '../utils/app_logger.dart';
import 'app_settings_service.dart';
import 'translation_file_store.dart';
@@ -510,10 +509,8 @@ class TranslationService extends ChangeNotifier {
if (trimmed.isEmpty) {
return false;
}
if (GifHelper.parseGif(trimmed) != null) {
return false;
}
return !(trimmed.startsWith('m:') ||
return !(trimmed.startsWith('g:') ||
trimmed.startsWith('m:') ||
trimmed.startsWith('V1|') ||
trimmed.startsWith('r:'));
}
-33
View File
@@ -1,33 +0,0 @@
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);
}
}
+161 -345
View File
@@ -1,382 +1,198 @@
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/widgets/adaptive_app_bar_title.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';
// ---------------------------------------------------------------------------
// Pure helpers extracted from TcpScreen logic so we can unit-test them
// without pumping the full screen widget tree.
// ---------------------------------------------------------------------------
class _FakeMeshCoreConnector extends MeshCoreConnector {
_FakeMeshCoreConnector();
/// 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;
}
MeshCoreConnectionState initialState = MeshCoreConnectionState.disconnected;
MeshCoreTransportType initialTransport = MeshCoreTransportType.bluetooth;
String? initialEndpoint;
int connectTcpCalls = 0;
String? lastHost;
int? lastPort;
/// 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,
}) {
if (isTcpConnected) return connectedTo(activeTcpEndpoint ?? 'TCP');
if (state == MeshCoreConnectionState.connecting &&
transport == MeshCoreTransportType.tcp) {
return connectingTo(connectingEndpoint);
@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;
}
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,
Widget _buildTestApp({
required MeshCoreConnector connector,
required Widget child,
Locale? locale,
}) {
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());
return MultiProvider(
providers: [
ChangeNotifierProvider<MeshCoreConnector>.value(value: connector),
ChangeNotifierProvider<AppSettingsService>(
create: (_) => AppSettingsService(),
),
],
child: MaterialApp(
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: child,
),
);
}
/// 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() {
// -- 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;
testWidgets('TcpScreen uses localized TCP copy', (tester) async {
final connector = _FakeMeshCoreConnector();
await tester.pumpWidget(
MaterialApp(
_buildTestApp(
connector: connector,
child: const TcpScreen(),
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.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'));
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);
});
// -- Isolated widget: AdaptiveAppBarTitle overflow ------------------------
testWidgets('AdaptiveAppBarTitle does not overflow with long text', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(320, 100));
addTearDown(() => tester.binding.setSurfaceSize(null));
testWidgets('TcpScreen validation errors are localized', (tester) async {
final connector = _FakeMeshCoreConnector();
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: SizedBox(
width: 200,
child: AdaptiveAppBarTitle(
'This is a very long title that would normally overflow',
),
),
),
_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', (
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));
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'),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
final context = tester.element(find.byType(TcpScreen));
final l10n = AppLocalizations.of(context);
expect(
find.text('This is a very long title that would normally overflow'),
find.text(l10n.scanner_connectedTo(connector.initialEndpoint!)),
findsOneWidget,
);
});
// -- 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);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(milliseconds: 60));
});
}
+221 -529
View File
@@ -1,584 +1,276 @@
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/utils/usb_port_labels.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';
// ---------------------------------------------------------------------------
// Pure helpers extracted from UsbScreen logic.
// ---------------------------------------------------------------------------
class _FakeMeshCoreConnector extends MeshCoreConnector {
_FakeMeshCoreConnector({
this.initialState = MeshCoreConnectionState.disconnected,
List<String>? ports,
}) : _ports = ports ?? <String>[];
/// 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,
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,
}) {
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;
return ChangeNotifierProvider<MeshCoreConnector>.value(
value: connector,
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: child,
),
);
}
/// 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() {
// -- Port name helpers (normalizeUsbPortName / friendlyUsbPortName) -------
group('USB port name parsing', () {
test('normalizeUsbPortName extracts raw port before separator', () {
expect(normalizeUsbPortName('COM6 - USB Serial Device (COM6)'), 'COM6');
});
test('normalizeUsbPortName returns input when no separator', () {
expect(normalizeUsbPortName('/dev/ttyUSB0'), '/dev/ttyUSB0');
});
test('normalizeUsbPortName trims whitespace', () {
expect(normalizeUsbPortName(' COM3 '), 'COM3');
});
test('friendlyUsbPortName extracts description field', () {
expect(
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', (
testWidgets('UsbScreen passes localized chooser label to connector', (
tester,
) async {
late AppLocalizations l10n;
final connector = _FakeMeshCoreConnector();
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();
},
),
),
_buildTestApp(connector: connector, child: const UsbScreen()),
);
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);
expect(connector.requestPortLabel, 'Select a USB device');
});
// -- Isolated widget: status bar Row with FittedBox overflow --------------
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)'],
);
testWidgets('USB status bar with long text does not overflow at 320px', (
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 {
await tester.binding.setSurfaceSize(const Size(320, 100));
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));
const longText =
'Connected to /dev/bus/usb/001/002 - KD3CGK mesh-utility.org very long label';
const statusColor = Colors.green;
final connector = _FakeMeshCoreConnector();
// 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,
),
),
),
),
],
),
),
),
),
_buildTestApp(connector: connector, child: const ScannerScreen()),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text(longText), findsOneWidget);
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));
});
// -- Isolated widget: bottom nav FittedBox overflow -----------------------
testWidgets('Bottom nav row with multiple FABs does not overflow at 320px', (
testWidgets('UsbScreen narrow width long status text does not overflow', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(320, 200));
await tester.binding.setSurfaceSize(const Size(320, 700));
addTearDown(() => tester.binding.setSurfaceSize(null));
// Mirrors the bottomNavigationBar structure from ScannerScreen / UsbScreen
// with all possible buttons visible.
final connector =
_FakeMeshCoreConnector(initialState: MeshCoreConnectionState.connected)
..fakeUsbTransportConnected = true
..fakeActiveUsbPortDisplayLabel =
'/dev/bus/usb/001/002 - KD3CGK mesh-utility.org very long label';
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'),
),
],
),
),
),
),
),
_buildTestApp(connector: connector, child: const UsbScreen()),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('USB'), findsOneWidget);
expect(find.text('TCP'), findsOneWidget);
expect(find.text('Scan'), findsOneWidget);
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));
});
// -- describeWebUsbPort ---------------------------------------------------
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',
);
};
group('describeWebUsbPort', () {
test('null vendor and product returns requestPortLabel', () {
expect(
describeWebUsbPort(vendorId: null, productId: null),
'Choose USB Device',
await tester.pumpWidget(
_buildTestApp(connector: connector, child: const UsbScreen()),
);
await tester.pumpAndSettle();
expect(find.text('USB permission was denied.'), findsOneWidget);
});
test('known VID:PID uses knownUsbNames', () {
expect(
describeWebUsbPort(
vendorId: 0x1A86,
productId: 0x7523,
knownUsbNames: {'1a86:7523': 'CH340 Serial'},
),
'CH340 Serial (VID:1A86 PID:7523)',
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');
};
await tester.pumpWidget(
_buildTestApp(connector: connector, child: const UsbScreen()),
);
});
await tester.pumpAndSettle();
test('unknown VID:PID uses fallback device name', () {
await tester.tap(find.byType(ListTile).first);
await tester.pumpAndSettle();
expect(connectAttempted, isTrue);
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',
find.text('Another USB connection request is already in progress.'),
findsOneWidget,
);
});
});