Compare commits

..

34 Commits

Author SHA1 Message Date
Enot (ded) Skelly d529ce9228 fix foreground service and add notification nav
wraps MaterialApp in WithForegroundService to keep alive when swiped away

persists last connected device and clears on manual disconnect to allow
reconnect after kill

added lifecycle tracking to iOS and keep android notification alive with
heartbeat

add notification navigation

change screen tests to be less brittle

address PR commnets
2026-04-13 08:09:22 -07:00
Enot (ded) Skelly 5354acb1d3 clean up after merge conflicts 2026-04-09 09:57:46 -07:00
Enot (ded) Skelly fae416fb34 Merge branch 'dev' of github.com:zjs81/meshcore-open into dev 2026-04-09 09:50:36 -07:00
Enot (ded) Skelly 69433b6d89 small clean up from PR #275
just removes extraneous assignment to _lastNonRepeatSnapshot and moves
the Navigator pop to after all uses of the context in _RadioSettingsDialog
2026-04-09 09:41:02 -07:00
just-stuff-tm ea3b9609fc fix(settings): use integer Hz comparison, unify snapshot conversion, gate debug logging
- Replace floating-point epsilon frequency comparison with integer Hz
- Add frequencyHz getter and fromMeshCoreSnapshot/toMeshCoreSnapshot
  conversion methods on _RadioSettingsSnapshot
- Move _toUiCodingRate/_toDeviceCodingRate to documented top-level functions
- Gate _logRadioSettingsState behind kDebugMode
- Use integer Hz in == and hashCode for _RadioSettingsSnapshot

Addresses code review findings on preset/off-grid repeat toggle PR.
2026-04-09 09:41:02 -07:00
just-stuff-tm 20a9939314 fix(settings): scope repeat preset memory to saved state 2026-04-09 09:41:02 -07:00
just-stuff-tm c7b7deb0f6 fix(settings): preserve preset across off-grid repeat 2026-04-09 09:41:02 -07:00
just-stuff-tm 82e04e8090 Reapply "Fixed Preset on offgrid repeat toggle enhancemet #183"
This reverts commit 758619bbaa6ce5895c7146bbfc3b89054e759527.
2026-04-09 09:41:02 -07:00
Enot (ded) Skelly f299608296 use l10n strings for discovered menu item 2026-04-09 09:41:02 -07:00
ericz 7dcec5b4ee moved _getRepeaterPrefixMatchNearLocation since I don't need the function anywhere else anymore. 2026-04-09 09:41:02 -07:00
ericszimmermann e4684b585a codex suggested fix: explicit check if contact location is not null
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-09 09:41:02 -07:00
ericz 8386f262e1 reimplement location aware snr-indikator after alpha7 2026-04-09 09:41:02 -07:00
Enot (ded) Skelly 45cd8a56a3 add jni to generated plugins
linux and windows were missing jni which was being added on fresh builds from dev
2026-04-09 09:41:02 -07:00
Enot (ded) Skelly 754f8a6c62 add fvm directory and rc file to gitignore 2026-04-09 09:41:02 -07:00
Enot (ded) Skelly c4f54efd77 add tooltip to send message buttons 2026-04-09 09:41:02 -07:00
Winston Lowe 637e08d22c Update ML timeout handling and adjust distance threshold for path hops 2026-04-09 09:40:24 -07:00
Winston Lowe 32dc0fca22 Refactor contact handling and other improvments (#317)
* Refactor contact filtering and improve localization strings; enhance path trace handling

* Add localization for new CLI commands and update existing strings

* Enhance contact handling and UI updates across multiple screens
add unfiltered contact access and improve last seen resolution

* Add polling interval configuration and improve contact handling

* Reorder command constants for better organization and clarity

* Refactor contact handling by removing unnecessary mapping and improving clarity across multiple screens

* Moved RadioStatsIconButton in chat screen for improved UI consistency

* Added indicators to AppBar for channels

* Ignore contacts with self public key in contact handling

* Simplify path removal logic and clean up unused imports in path management dialog

* Enhance path hop resolution by adding distance checks to improve candidate selection accuracy

* Remove unnecessary reset of radio stats poll reference count in polling interval setter
2026-04-09 09:40:06 -07:00
n-kam b5aa294fc1 make unread badge max out at 9999+ not 99+ 2026-04-09 09:30:25 -07:00
Winston Lowe 26516baf67 Update ML timeout handling and adjust distance threshold for path hops 2026-04-09 09:30:25 -07:00
Winston Lowe 4879b136f8 Refactor contact handling and other improvments (#317)
* Refactor contact filtering and improve localization strings; enhance path trace handling

* Add localization for new CLI commands and update existing strings

* Enhance contact handling and UI updates across multiple screens
add unfiltered contact access and improve last seen resolution

* Add polling interval configuration and improve contact handling

* Reorder command constants for better organization and clarity

* Refactor contact handling by removing unnecessary mapping and improving clarity across multiple screens

* Moved RadioStatsIconButton in chat screen for improved UI consistency

* Added indicators to AppBar for channels

* Ignore contacts with self public key in contact handling

* Simplify path removal logic and clean up unused imports in path management dialog

* Enhance path hop resolution by adding distance checks to improve candidate selection accuracy

* Remove unnecessary reset of radio stats poll reference count in polling interval setter
2026-04-09 09:30:25 -07:00
Ded 89a14c2719 Merge pull request #347 from zjs81/add-contribution
init contributing.md
2026-04-07 14:37:35 -07:00
Enot (ded) Skelly 4ad01ed43c init contributing.md 2026-04-07 13:01:46 -07:00
zjs81 ffaa4033ae Merge pull request #321 from just-stuff-tm/main
Add additional device name prefixes to MeshCoreUuids
2026-04-06 23:04:29 -07:00
zjs81 1a4fd1b477 Merge pull request #339 from ericszimmermann/ez_fix_coordinates
Preserve Coordinates with contact.copyWith() function
2026-04-06 22:58:21 -07:00
zjs81 e1555ce380 Merge pull request #337 from interfect/lowmesh
Add LowMesh prefix and explain how to add more
2026-04-06 22:51:44 -07:00
zjs81 c7933d363b Merge pull request #342 from interfect/graceful-gif-render
Support receiving more formats of GIF message
2026-04-06 14:28:19 -07:00
Zach 08ffb978cf fix: gif trnslat 2026-04-06 14:26:42 -07:00
Adam Novak c5ec60638c Put reaction and GIF helpers in charge of encoding 2026-04-06 02:09:40 -04:00
Adam Novak 75ec3b6116 Centralize GIF parsing in a helper like for reactions 2026-04-06 01:57:51 -04:00
Adam Novak 45c9823c6f Escape forward slashes in regexes 2026-04-05 22:51:48 -04:00
Adam Novak 45658a7612 Understand more kinds of Giphy reference as GIF
This adds Giphy page URLs and `media.giphy.com` URLs (with and without
protocols) as *accepted* encodings for GIF messages, alongside the `g:`
syntax.

When someone posts such a URL by itself as a message, it will be rendered inline just like `g:` messages are now.

This does not change the encoding that GIF messages are *sent* in; that
is still the `g:` syntax.
2026-04-05 22:39:20 -04:00
ericz 7633327f45 Previously, the merge only preserved path override fields and could overwrite existing GPS with null when the incoming frame had 0,0 coordinates.
Now it also preserves prior coordinates when the incoming update omits location.
2026-04-05 14:06:23 +02:00
Adam Novak 6b4b2d7ce6 Add LowMesh prefix and explain how to add more 2026-04-04 19:40:39 -04:00
just-stuff-tm e4e8bfa4ef Add additional device name prefixes to MeshCoreUuids 2026-03-28 12:20:27 -04:00
35 changed files with 1641 additions and 588 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 name prefix `MeshCore-` and filters by `platformName`/`advertisementData.advName`.
- Discovery: scans for device names matching known prefixes 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 name prefix `MeshCore-`
- Scans for devices with known name prefixes
- Filters by `platformName` or `advertisementData.advName`
### Connection States
+71
View File
@@ -0,0 +1,71 @@
# 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.
+11 -2
View File
@@ -150,7 +150,8 @@ lib/
├── main.dart # App entry point
├── connector/
│ ├── meshcore_connector.dart # BLE communication & state management
── meshcore_protocol.dart # Protocol definitions & frame parsing
── meshcore_protocol.dart # Protocol definitions & frame parsing
│ └── meshcore_uuids.dart # Device names and IDs (add prefixes here!)
├── screens/
│ ├── scanner_screen.dart # Device scanning (home screen)
│ ├── contacts_screen.dart # Contact list
@@ -184,7 +185,15 @@ lib/
### Device Discovery
Devices are discovered by scanning for BLE advertisements with the name prefix `MeshCore-`
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`.
### Message Format
+6 -1
View File
@@ -21,7 +21,12 @@ The MeshCore BLE protocol implements a binary frame-based communication system u
### Connection Flow
1. **Scan** for devices with name prefix `MeshCore-`
1. **Scan** for devices with known name prefixes (defined in `MeshCoreUuids.deviceNamePrefixes`):
- `MeshCore-`
- `Whisper-`
- `WisCore-`
- `HT-`
- `LowMesh_MC_`
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
+6 -1
View File
@@ -49,7 +49,12 @@ enum MeshCoreConnectionState {
## BLE Connection Lifecycle
1. **Scan** with keyword filters `["MeshCore-", "Whisper-"]`
1. **Scan** with known name prefixes (defined in `MeshCoreUuids.deviceNamePrefixes`):
- `MeshCore-`
- `Whisper-`
- `WisCore-`
- `HT-`
- `LowMesh_MC_`
2. **Connect** with 15-second timeout
3. **Request MTU** 185 bytes (non-web only)
4. **Discover services** and locate NUS
+74 -1
View File
@@ -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;
@@ -3976,11 +4035,14 @@ class MeshCoreConnector extends ChangeNotifier {
tag: 'Connector',
);
// CRITICAL: Preserve user's path override when contact is refreshed from device
// Preserve user-selected path settings and previously known GPS when
// refreshed frames omit coordinates (lat/lon encoded as 0,0).
_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(
@@ -4907,6 +4969,17 @@ class MeshCoreConnector extends ChangeNotifier {
);
}
/// Public accessor to find a channel by its index.
Channel? findChannelByIndex(int index) => _findChannelByIndex(index);
/// Find a contact by its public key hex string.
Contact? findContactByKeyHex(String keyHex) {
return _contacts.cast<Contact?>().firstWhere(
(c) => c?.publicKeyHex == keyHex,
orElse: () => null,
);
}
void _maybeIncrementChannelUnread(
ChannelMessage message, {
required bool isNew,
+3
View File
@@ -7,6 +7,9 @@ class MeshCoreUuids {
"MeshCore-",
"Whisper-",
"WisCore-",
"Seeed",
"Lilygo",
"HT-",
"LowMesh_MC_",
];
}
+38
View File
@@ -0,0 +1,38 @@
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,4 +109,9 @@ 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"
}
}
+2 -1
View File
@@ -2098,6 +2098,7 @@
"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 PIN kódot a $deviceName számára (hagyja üresen, ha nincs).';
return 'Adja meg a(z) $deviceName PIN-kódját (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": "同步时钟"
}
}
+126 -51
View File
@@ -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(),
);
},
),
+6 -11
View File
@@ -11,6 +11,7 @@ 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';
@@ -355,7 +356,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final settingsService = context.watch<AppSettingsService>();
final enableTracing = settingsService.settings.enableMessageTracing;
final isOutgoing = message.isOutgoing;
final gifId = _parseGifId(message.text);
final gifId = GifHelper.parseGif(message.text);
final poi = _parsePoiMessage(message.text);
final translatedDisplayText =
message.translatedText != null &&
@@ -699,7 +700,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final colorScheme = Theme.of(context).colorScheme;
final previewTextColor = colorScheme.onSurface.withValues(alpha: 0.7);
final gifId = _parseGifId(replyText);
final gifId = GifHelper.parseGif(replyText);
final poi = _parsePoiMessage(replyText);
Widget contentPreview;
@@ -811,12 +812,6 @@ 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(
@@ -897,7 +892,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
isScrollControlled: true,
builder: (context) => GifPicker(
onGifSelected: (gifId) {
_textController.text = 'g:$gifId';
_textController.text = GifHelper.encodeGif(gifId);
},
),
);
@@ -1053,7 +1048,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController,
builder: (context, value, child) {
final gifId = _parseGifId(value.text);
final gifId = GifHelper.parseGif(value.text);
if (gifId != null) {
return Focus(
autofocus: true,
@@ -1322,7 +1317,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
message.senderName,
message.text,
);
final reactionText = 'r:$hash:$emojiIndex';
final reactionText = ReactionHelper.encodeReaction(hash, emojiIndex);
connector.sendChannelMessage(widget.channel, reactionText);
}
+5 -16
View File
@@ -16,6 +16,7 @@ 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';
@@ -523,7 +524,7 @@ class _ChatScreenState extends State<ChatScreen> {
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController,
builder: (context, value, child) {
final gifId = _parseGifId(value.text);
final gifId = GifHelper.parseGif(value.text);
if (gifId != null) {
return Focus(
autofocus: true,
@@ -601,19 +602,13 @@ 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 = 'g:$gifId';
_textController.text = GifHelper.encodeGif(gifId);
},
),
);
@@ -1546,7 +1541,7 @@ class _ChatScreenState extends State<ChatScreen> {
senderName,
message.text,
);
final reactionText = 'r:$hash:$emojiIndex';
final reactionText = ReactionHelper.encodeReaction(hash, emojiIndex);
connector.sendMessage(_resolveContact(connector), reactionText);
}
}
@@ -1576,7 +1571,7 @@ class _MessageBubble extends StatelessWidget {
final enableTracing = settingsService.settings.enableMessageTracing;
final isOutgoing = message.isOutgoing;
final colorScheme = Theme.of(context).colorScheme;
final gifId = _parseGifId(message.text);
final gifId = GifHelper.parseGif(message.text);
final poi = _parsePoiMessage(message.text);
final isFailed = message.status == MessageStatus.failed;
final bubbleColor = isFailed
@@ -1850,12 +1845,6 @@ 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,6 +8,7 @@ import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
import '../services/notification_service.dart';
import '../utils/contact_search.dart';
import '../utils/platform_info.dart';
import '../widgets/app_bar.dart';
@@ -31,6 +32,20 @@ class _DiscoveryScreenState extends State<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,6 +7,7 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../services/linux_ble_error_classifier.dart';
import '../services/notification_service.dart';
import '../utils/app_logger.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/device_tile.dart';
@@ -43,6 +44,10 @@ class _ScannerScreenState extends State<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()),
@@ -53,6 +58,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) {
+116 -34
View File
@@ -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 {}
+187 -39
View File
@@ -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');
}
}
}
+5 -2
View File
@@ -6,6 +6,7 @@ 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';
@@ -509,8 +510,10 @@ class TranslationService extends ChangeNotifier {
if (trimmed.isEmpty) {
return false;
}
return !(trimmed.startsWith('g:') ||
trimmed.startsWith('m:') ||
if (GifHelper.parseGif(trimmed) != null) {
return false;
}
return !(trimmed.startsWith('m:') ||
trimmed.startsWith('V1|') ||
trimmed.startsWith('r:'));
}
+33
View 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);
}
}
+343 -159
View File
@@ -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);
});
}
+563 -255
View File
@@ -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',
);
});
});