From b6a801166af370adb6b732f8c4f41fa86a40ac26 Mon Sep 17 00:00:00 2001 From: Strycher Date: Thu, 23 Jul 2026 00:26:26 -0400 Subject: [PATCH 01/62] chore(#373): add FUNDING.yml sponsor button --- .github/FUNDING.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..a5bf67c --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: [Strycher] +ko_fi: offband From d68c6dfdd889a7638785006abdbd99331bc680e0 Mon Sep 17 00:00:00 2001 From: Strycher Date: Sun, 26 Jul 2026 19:48:19 -0400 Subject: [PATCH 02/62] fix(#385): surface storage-init failure with a persistent warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the database can't open (e.g. the native sqlite library fails to load), every read/write silently failed and the app opened to an empty, normal-looking screen — the user thinks their history was wiped (SAFELANE §6 violation). Probe the storage layer in main() before any store reads (BlobStore.verifyReadWrite). On failure, a StorageHealthService one-way latch records it, and a persistent, non-dismissable banner is shown above the whole app: history is not lost, storage is unavailable, messages are NOT being saved, restart after fixing. Cross-platform (probe goes through drift, covers web too). Tests: service state/latch + banner show/hide widget test. Gemini-reviewed. Co-Authored-By: Claude Opus 4.8 --- lib/l10n/app_en.arb | 10 ++- lib/l10n/app_localizations.dart | 12 ++++ lib/l10n/app_localizations_bg.dart | 7 ++ lib/l10n/app_localizations_de.dart | 7 ++ lib/l10n/app_localizations_en.dart | 7 ++ lib/l10n/app_localizations_es.dart | 7 ++ lib/l10n/app_localizations_fr.dart | 7 ++ lib/l10n/app_localizations_hu.dart | 7 ++ lib/l10n/app_localizations_it.dart | 7 ++ lib/l10n/app_localizations_ja.dart | 7 ++ lib/l10n/app_localizations_ko.dart | 7 ++ lib/l10n/app_localizations_nl.dart | 7 ++ lib/l10n/app_localizations_pl.dart | 7 ++ lib/l10n/app_localizations_pt.dart | 7 ++ lib/l10n/app_localizations_ru.dart | 7 ++ lib/l10n/app_localizations_sk.dart | 7 ++ lib/l10n/app_localizations_sl.dart | 7 ++ lib/l10n/app_localizations_sv.dart | 7 ++ lib/l10n/app_localizations_uk.dart | 7 ++ lib/l10n/app_localizations_zh.dart | 7 ++ lib/main.dart | 29 +++++++- lib/services/storage_health_service.dart | 29 ++++++++ lib/storage/drift/blob_store.dart | 5 ++ lib/widgets/storage_unavailable_banner.dart | 70 +++++++++++++++++++ .../services/storage_health_service_test.dart | 37 ++++++++++ .../storage_unavailable_banner_test.dart | 39 +++++++++++ 26 files changed, 355 insertions(+), 2 deletions(-) create mode 100644 lib/services/storage_health_service.dart create mode 100644 lib/widgets/storage_unavailable_banner.dart create mode 100644 test/services/storage_health_service_test.dart create mode 100644 test/widgets/storage_unavailable_banner_test.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a7b3413..6b30e10 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2623,5 +2623,13 @@ "block_namesHint": "Name-only blocks (channel senders) — each upgrades to a full block once we learn their key.", "block_offloadActive": "Firmware offload active — blocks sync to this radio", "block_offloadStoreFull": "Radio block list full (32) — extra blocks stay app-only", - "block_composerNotice": "You've blocked this contact" + "block_composerNotice": "You've blocked this contact", + "storageUnavailableTitle": "Message storage unavailable", + "storageUnavailableBody": "Your history is not lost, but the app can't open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.", + "@storageUnavailableTitle": { + "description": "Persistent banner title shown when the storage/database layer fails to open (#385)." + }, + "@storageUnavailableBody": { + "description": "Persistent banner body explaining that storage is unavailable and messages are not being saved (#385)." + } } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 0ad7394..491bb49 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -7953,6 +7953,18 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'You\'ve blocked this contact'** String get block_composerNotice; + + /// Persistent banner title shown when the storage/database layer fails to open (#385). + /// + /// In en, this message translates to: + /// **'Message storage unavailable'** + String get storageUnavailableTitle; + + /// Persistent banner body explaining that storage is unavailable and messages are not being saved (#385). + /// + /// In en, this message translates to: + /// **'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'** + String get storageUnavailableBody; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index a379cc6..7c85ff9 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -4654,4 +4654,11 @@ class AppLocalizationsBg extends AppLocalizations { @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; } diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index b60bea3..ce96966 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -4672,4 +4672,11 @@ class AppLocalizationsDe extends AppLocalizations { @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index db7573f..666c336 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -4576,4 +4576,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 9253a00..b6d719b 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -4659,4 +4659,11 @@ class AppLocalizationsEs extends AppLocalizations { @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 19bd894..c204f9e 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -4688,4 +4688,11 @@ class AppLocalizationsFr extends AppLocalizations { @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; } diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index 98933ef..9102572 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -4675,4 +4675,11 @@ class AppLocalizationsHu extends AppLocalizations { @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; } diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index b0197a8..0629ef0 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -4664,4 +4664,11 @@ class AppLocalizationsIt extends AppLocalizations { @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index b244d39..ecc9140 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -4429,4 +4429,11 @@ class AppLocalizationsJa extends AppLocalizations { @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 90228a2..455161b 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -4430,4 +4430,11 @@ class AppLocalizationsKo extends AppLocalizations { @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 440530b..6d10c20 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -4639,4 +4639,11 @@ class AppLocalizationsNl extends AppLocalizations { @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; } diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index be65e06..095dfe9 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -4676,4 +4676,11 @@ class AppLocalizationsPl extends AppLocalizations { @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index efcfad0..46f00b4 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -4652,4 +4652,11 @@ class AppLocalizationsPt extends AppLocalizations { @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 63b5679..9f6f794 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -4670,4 +4670,11 @@ class AppLocalizationsRu extends AppLocalizations { @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; } diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index be75989..f2a340e 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -4635,4 +4635,11 @@ class AppLocalizationsSk extends AppLocalizations { @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; } diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 4b9518c..24fcf5b 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -4633,4 +4633,11 @@ class AppLocalizationsSl extends AppLocalizations { @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; } diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index ab1047d..b7bbe33 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -4608,4 +4608,11 @@ class AppLocalizationsSv extends AppLocalizations { @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; } diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index ee32259..f16fd52 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -4671,4 +4671,11 @@ class AppLocalizationsUk extends AppLocalizations { @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 7ae7266..b7e8ae8 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -4303,4 +4303,11 @@ class AppLocalizationsZh extends AppLocalizations { @override String get block_composerNotice => 'You\'ve blocked this contact'; + + @override + String get storageUnavailableTitle => 'Message storage unavailable'; + + @override + String get storageUnavailableBody => + 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; } diff --git a/lib/main.dart b/lib/main.dart index 5e7ae1a..655beaf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -29,7 +29,9 @@ import 'services/observer_config_service.dart'; import 'services/block_service.dart'; import 'services/window_geometry_service.dart'; import 'services/store_consolidation_service.dart'; +import 'services/storage_health_service.dart'; import 'storage/drift/blob_store.dart'; +import 'widgets/storage_unavailable_banner.dart'; import 'storage/prefs_manager.dart'; import 'utils/app_logger.dart'; @@ -39,6 +41,22 @@ void main() async { // Initialize SharedPreferences cache await PrefsManager.initialize(); + // Probe the storage layer up front (#385). If the database can't open — e.g. + // the native sqlite library fails to load — every read/write silently fails + // and the app looks wiped. Capture that here so the UI can warn loudly + // instead of showing an empty, normal-looking screen (SAFELANE §6). + final storageHealth = StorageHealthService(); + try { + if (!await BlobStore.instance.verifyReadWrite()) { + storageHealth.markUnavailable('database read-back mismatch'); + } + } catch (e) { + storageHealth.markUnavailable(e); + // appLogger is not initialized this early; debugPrint is safe here and the + // per-store operations still log via appLogger once it is up. + debugPrint('[Storage] Health probe failed; storage unavailable: $e'); + } + // Move bulk data (message history, contacts) out of SharedPreferences into // drift (#335). Must run after prefs are up and BEFORE any store reads, so // no code sees a half-migrated state. Idempotent: a no-op once done. @@ -123,6 +141,7 @@ void main() async { runApp( MeshCoreApp( + storageHealth: storageHealth, connector: connector, retryService: retryService, pathHistoryService: pathHistoryService, @@ -163,6 +182,7 @@ https://creativecommons.org/licenses/by/4.0/ } class MeshCoreApp extends StatelessWidget { + final StorageHealthService storageHealth; final MeshCoreConnector connector; final MessageRetryService retryService; final PathHistoryService pathHistoryService; @@ -180,6 +200,7 @@ class MeshCoreApp extends StatelessWidget { const MeshCoreApp({ super.key, + required this.storageHealth, required this.connector, required this.retryService, required this.pathHistoryService, @@ -200,6 +221,7 @@ class MeshCoreApp extends StatelessWidget { Widget build(BuildContext context) { return MultiProvider( providers: [ + ChangeNotifierProvider.value(value: storageHealth), ChangeNotifierProvider.value(value: connector), ChangeNotifierProvider.value(value: retryService), ChangeNotifierProvider.value(value: pathHistoryService), @@ -257,7 +279,12 @@ class MeshCoreApp extends StatelessWidget { NotificationService().setLocale(locale); return AnnotatedRegion( value: _systemUiOverlayStyle(context), - child: child ?? const SizedBox.shrink(), + child: Consumer( + builder: (context, health, _) => StorageUnavailableBanner( + show: !health.available, + child: child ?? const SizedBox.shrink(), + ), + ), ); }, home: (PlatformInfo.isWeb && !PlatformInfo.isChrome) diff --git a/lib/services/storage_health_service.dart b/lib/services/storage_health_service.dart new file mode 100644 index 0000000..ce62c9a --- /dev/null +++ b/lib/services/storage_health_service.dart @@ -0,0 +1,29 @@ +import 'package:flutter/foundation.dart'; + +/// Tracks whether the app's storage layer (drift/SQLite) opened successfully at +/// startup (#385). +/// +/// When the database can't open — e.g. the native `sqlite3` library fails to +/// load — every read and write silently fails and the app looks wiped. This +/// holds that state so the UI can show a loud, persistent warning instead of an +/// empty, normal-looking screen (SAFELANE §6: no silent failures). +class StorageHealthService extends ChangeNotifier { + bool _available = true; + String? _error; + + /// True until a startup probe proves storage cannot be read/written. + bool get available => _available; + + /// The underlying error, for the log and diagnostics. Null when healthy. + String? get error => _error; + + /// Marks storage as unavailable and records [error]. A one-way latch: once + /// unavailable it stays that way for the session (recovery is a restart), so + /// a later call is a no-op and does not re-notify. + void markUnavailable(Object error) { + if (!_available) return; + _available = false; + _error = error.toString(); + notifyListeners(); + } +} diff --git a/lib/storage/drift/blob_store.dart b/lib/storage/drift/blob_store.dart index 96aba56..8b8243c 100644 --- a/lib/storage/drift/blob_store.dart +++ b/lib/storage/drift/blob_store.dart @@ -97,6 +97,11 @@ class BlobStore { return row?.value; } + /// Proves the database opens and round-trips a write. Throws (or returns + /// false) if storage is unusable — e.g. the native sqlite library can't load + /// (#385). Used by the startup health probe. + Future verifyReadWrite() => _db.verifyReadWrite(); + Future write(String key, String value) async { await _db .into(_db.storedBlobs) diff --git a/lib/widgets/storage_unavailable_banner.dart b/lib/widgets/storage_unavailable_banner.dart new file mode 100644 index 0000000..db53efd --- /dev/null +++ b/lib/widgets/storage_unavailable_banner.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +import '../l10n/l10n.dart'; + +/// A persistent, non-dismissable banner shown above the whole app when the +/// storage layer failed to open (#385). +/// +/// SAFELANE §6: a storage failure must be loud and stay visible — not a 4s +/// toast, and never a silent empty screen. When [show] is false this is a +/// transparent pass-through and adds no layout. +class StorageUnavailableBanner extends StatelessWidget { + final bool show; + final Widget child; + + const StorageUnavailableBanner({ + super.key, + required this.show, + required this.child, + }); + + @override + Widget build(BuildContext context) { + if (!show) return child; + + final scheme = Theme.of(context).colorScheme; + return Column( + children: [ + Material( + color: scheme.errorContainer, + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.warning_amber_rounded, + color: scheme.onErrorContainer, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.storageUnavailableTitle, + style: TextStyle( + fontWeight: FontWeight.bold, + color: scheme.onErrorContainer, + ), + ), + const SizedBox(height: 2), + Text( + context.l10n.storageUnavailableBody, + style: TextStyle(color: scheme.onErrorContainer), + ), + ], + ), + ), + ], + ), + ), + ), + ), + Expanded(child: child), + ], + ); + } +} diff --git a/test/services/storage_health_service_test.dart b/test/services/storage_health_service_test.dart new file mode 100644 index 0000000..ce761a4 --- /dev/null +++ b/test/services/storage_health_service_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/services/storage_health_service.dart'; + +/// #385: the service must default to healthy and flip loudly (notifying) the +/// moment storage is proven unusable. +void main() { + test('defaults to available with no error', () { + final s = StorageHealthService(); + expect(s.available, isTrue); + expect(s.error, isNull); + }); + + test('markUnavailable flips state, records the error, and notifies', () { + final s = StorageHealthService(); + var notifications = 0; + s.addListener(() => notifications++); + + s.markUnavailable(Exception('sqlite3.dll not found')); + + expect(s.available, isFalse); + expect(s.error, contains('sqlite3.dll not found')); + expect(notifications, 1); + }); + + test('one-way latch: a later mark is a no-op and keeps the first error', () { + final s = StorageHealthService(); + var notifications = 0; + s.addListener(() => notifications++); + + s.markUnavailable('first'); + s.markUnavailable('second'); + + expect(notifications, 1, reason: 'stays unavailable, does not re-notify'); + expect(s.available, isFalse); + expect(s.error, contains('first'), reason: 'first error is retained'); + }); +} diff --git a/test/widgets/storage_unavailable_banner_test.dart b/test/widgets/storage_unavailable_banner_test.dart new file mode 100644 index 0000000..58b32c7 --- /dev/null +++ b/test/widgets/storage_unavailable_banner_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/l10n/app_localizations.dart'; +import 'package:meshcore_open/widgets/storage_unavailable_banner.dart'; + +/// #385: the banner must be invisible when healthy and a loud, persistent +/// warning above the app when storage is down. +Widget _wrap(Widget child) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: child), +); + +void main() { + testWidgets('hidden when show=false: only the child renders', (tester) async { + await tester.pumpWidget( + _wrap(const StorageUnavailableBanner(show: false, child: Text('APP'))), + ); + + expect(find.text('APP'), findsOneWidget); + expect(find.byIcon(Icons.warning_amber_rounded), findsNothing); + expect(find.textContaining('NOT being saved'), findsNothing); + }); + + testWidgets('shown when show=true: warning + restart hint above the child', ( + tester, + ) async { + await tester.pumpWidget( + _wrap(const StorageUnavailableBanner(show: true, child: Text('APP'))), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget); + expect(find.textContaining('NOT being saved'), findsOneWidget); + expect(find.textContaining('Restart the app'), findsOneWidget); + // The app content is still present below the banner. + expect(find.text('APP'), findsOneWidget); + }); +} From 1ecf1ce55ded9f44fa6760ce6da4a62994d9ee4a Mon Sep 17 00:00:00 2001 From: Strycher Date: Sun, 26 Jul 2026 23:10:31 -0400 Subject: [PATCH 03/62] fix(#389): Back pops out of a channel; only top-level tabs background AppShell decided top-level vs detail by `selectedIndex != null`, but pushed detail screens (a channel chat, the LOS map) also set selectedIndex to keep the bottom bar visible. So Back from inside a channel backgrounded/left the app instead of popping to the channel list. Decouple the two concerns: add `isTopLevel` (default true), separate from `selectedIndex`. Detail screens pass `isTopLevel: false` (keep the bar, but Back pops). The decision is extracted into a pure `AppShell.backAction` (drawer -> close; detail with a route below -> pop; else -> background), with an assert that a detail is actually poppable. Unit tests cover the matrix; widget tests exercise the real system-Back -> PopScope -> pop wiring. Follow-up #390 tracks the separate AppBar back-arrow path. Co-Authored-By: Claude Opus 4.8 --- lib/screens/channel_chat_screen.dart | 3 + lib/screens/line_of_sight_map_screen.dart | 3 + lib/widgets/app_shell.dart | 92 ++++++++++++----- test/widgets/app_shell_back_test.dart | 116 ++++++++++++++++++++++ 4 files changed, 187 insertions(+), 27 deletions(-) create mode 100644 test/widgets/app_shell_back_test.dart diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 6917817..dfa8959 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -347,6 +347,9 @@ class _ChannelChatScreenState extends State { // a channel on desktop, where there is no system back button and the // hamburger has taken the back arrow's place. selectedIndex: 1, + // Pushed detail: Back pops to the channel list, not to the background + // (#389). The bar above is only for tab highlighting/switching. + isTopLevel: false, onDestinationSelected: _handleQuickSwitch, contactsUnreadCount: context .watch() diff --git a/lib/screens/line_of_sight_map_screen.dart b/lib/screens/line_of_sight_map_screen.dart index 4a68c22..2e83150 100644 --- a/lib/screens/line_of_sight_map_screen.dart +++ b/lib/screens/line_of_sight_map_screen.dart @@ -411,6 +411,9 @@ class _LineOfSightMapScreenState extends State { return AppShell( selectedIndex: 2, + // Pushed detail (LOS analysis over the map): Back pops to the map, not to + // the background (#389). + isTopLevel: false, onDestinationSelected: (index) => _handleQuickSwitch(index, context), contactsUnreadCount: context .watch() diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index d07c3ec..106255f 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -6,6 +6,9 @@ import '../services/ui_view_state_service.dart'; import '../utils/app_backgrounder.dart'; import 'quick_switch_bar.dart'; +/// What the system Back button should do inside an [AppShell] (#389). +enum AppShellBackAction { closeDrawer, pop, background } + /// Shared shell for the primary views (Contacts / Channels / Map). /// /// Owns the bottom [QuickSwitchBar] that each view previously mounted itself, @@ -17,9 +20,18 @@ class AppShell extends StatefulWidget { static const double wideBreakpoint = 720; static const double _drawerWidth = 300; - /// Bottom bar tab. Null on pushed detail screens (a channel chat), which - /// carry the nav panel but no bottom bar. + /// Bottom bar tab to highlight. A pushed detail screen (a channel chat) still + /// sets this so the bar stays visible; it is NOT what decides Back behavior — + /// [isTopLevel] is. Null renders no bottom bar. final int? selectedIndex; + + /// Whether this is a genuine top-level landing screen — a bottom-bar tab + /// (Contacts/Channels/Map). On a top-level screen, Back sends the app to the + /// background; on a pushed detail screen (a channel chat, the LOS map) Back + /// pops to the list it came from. Kept separate from [selectedIndex] so a + /// detail can keep the bar visible without Back treating it as top-level + /// (#389). Defaults to true. + final bool isTopLevel; final ValueChanged? onDestinationSelected; final int contactsUnreadCount; final int channelsUnreadCount; @@ -48,6 +60,7 @@ class AppShell extends StatefulWidget { super.key, required this.body, this.selectedIndex, + this.isTopLevel = true, this.onDestinationSelected, this.appBar, this.appBarBuilder, @@ -59,6 +72,23 @@ class AppShell extends StatefulWidget { this.channelsUnreadCount = 0, }); + /// Pure back-button decision (#389), extracted so it is testable without the + /// widget tree. An open drawer closes first; a pushed detail ([isTopLevel] + /// false) that has a route below pops to its list; anything else — a + /// top-level tab, or a detail with nothing to pop — backgrounds the app. A + /// top-level tab CAN pop (the scanner sits below it) but must not, or Back + /// would strand the user on the radio-connect screen. + @visibleForTesting + static AppShellBackAction backAction({ + required bool drawerOpen, + required bool isTopLevel, + required bool canPop, + }) { + if (drawerOpen) return AppShellBackAction.closeDrawer; + if (!isTopLevel && canPop) return AppShellBackAction.pop; + return AppShellBackAction.background; + } + @override State createState() => _AppShellState(); } @@ -68,35 +98,43 @@ class _AppShellState extends State { /// System back, in priority order: /// 1. an open drawer closes, - /// 2. on a detail screen, pop back to the list it came from, - /// 3. on a primary view, send the app to the background so Android - /// returns to the home screen or the previous app. - /// - /// Step 3 must NOT pop, even though the route below can be popped. The - /// primary views sit on top of the scanner, so popping would dump a - /// connected user back onto the radio-connect list. Reaching the scanner is - /// what Disconnect is for, not what Back is for. + /// 2. on a pushed detail screen (a channel chat), pop back to the list it + /// came from, + /// 3. on a top-level tab, send the app to the background so Android returns + /// to the home screen or the previous app. /// - /// A primary view is one carrying the bottom bar; a detail screen (a channel - /// chat) has no [selectedIndex] and is genuinely pushed. + /// Top-level is decided by [AppShell.isTopLevel], NOT by whether a route can + /// be popped: a top-level tab sits on top of the scanner, so it CAN pop, but + /// popping would dump a connected user back onto the radio-connect list. + /// Reaching the scanner is what Disconnect is for, not what Back is for. A + /// detail screen keeps the bottom bar ([selectedIndex]) yet is not top-level, + /// so Back pops it (#389). Future _handleBack() async { final scaffold = _scaffoldKey.currentState; - if (scaffold?.isDrawerOpen ?? false) { - scaffold!.closeDrawer(); - return; - } - - final isPrimaryView = widget.selectedIndex != null; - final navigator = Navigator.of(context); - if (!isPrimaryView && navigator.canPop()) { - navigator.pop(); - return; + // Guardrail (#389): a screen marked as a pushed detail must actually be + // poppable, or Back would fall through to backgrounding the app instead of + // returning to its list. Catches a detail wired without a route below it. + assert( + widget.isTopLevel || Navigator.of(context).canPop(), + 'AppShell(isTopLevel: false) must be a pushed route so Back pops to its ' + 'list', + ); + final action = AppShell.backAction( + drawerOpen: scaffold?.isDrawerOpen ?? false, + isTopLevel: widget.isTopLevel, + canPop: Navigator.of(context).canPop(), + ); + switch (action) { + case AppShellBackAction.closeDrawer: + scaffold!.closeDrawer(); + case AppShellBackAction.pop: + Navigator.of(context).pop(); + case AppShellBackAction.background: + // Background, do NOT finish. SystemNavigator.pop() would call finish() + // on the activity, tearing down the Flutter engine and dropping the + // radio connection, so reopening would show a disconnected radio. + await AppBackgrounder.moveToBackground(); } - - // Background, do NOT finish. SystemNavigator.pop() would call finish() on - // the activity, tearing down the Flutter engine and dropping the radio - // connection, so reopening the app would show a disconnected radio. - await AppBackgrounder.moveToBackground(); } @override diff --git a/test/widgets/app_shell_back_test.dart b/test/widgets/app_shell_back_test.dart new file mode 100644 index 0000000..47b2fa8 --- /dev/null +++ b/test/widgets/app_shell_back_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/l10n/app_localizations.dart'; +import 'package:meshcore_open/services/ui_view_state_service.dart'; +import 'package:meshcore_open/widgets/app_shell.dart'; +import 'package:provider/provider.dart'; + +/// #389: Back must pop out of a pushed detail (a channel chat) to its list, and +/// background the app only from a genuine top-level tab — not the other way +/// round. These pin the full decision matrix. +void main() { + group('AppShell.backAction', () { + test('an open drawer closes first, regardless of anything else', () { + expect( + AppShell.backAction(drawerOpen: true, isTopLevel: false, canPop: true), + AppShellBackAction.closeDrawer, + ); + expect( + AppShell.backAction(drawerOpen: true, isTopLevel: true, canPop: true), + AppShellBackAction.closeDrawer, + ); + }); + + test('a pushed detail (channel chat) pops to its list', () { + expect( + AppShell.backAction(drawerOpen: false, isTopLevel: false, canPop: true), + AppShellBackAction.pop, + ); + }); + + test('a top-level tab backgrounds even though it CAN pop', () { + // The scanner sits below a top-level tab, so canPop is true, but popping + // would strand the user on the radio-connect screen. + expect( + AppShell.backAction(drawerOpen: false, isTopLevel: true, canPop: true), + AppShellBackAction.background, + ); + }); + + test('a detail with nothing left to pop backgrounds', () { + expect( + AppShell.backAction( + drawerOpen: false, + isTopLevel: false, + canPop: false, + ), + AppShellBackAction.background, + ); + }); + + test('a top-level with nothing to pop backgrounds', () { + expect( + AppShell.backAction(drawerOpen: false, isTopLevel: true, canPop: false), + AppShellBackAction.background, + ); + }); + }); + + group('system Back wiring', () { + Widget host(GlobalKey navKey) => MultiProvider( + providers: [ChangeNotifierProvider(create: (_) => UiViewStateService())], + child: MaterialApp( + navigatorKey: navKey, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: Center(child: Text('HOME'))), + ), + ); + + Future pushShell( + WidgetTester tester, + GlobalKey navKey, { + required bool isTopLevel, + }) async { + navKey.currentState!.push( + MaterialPageRoute( + builder: (_) => AppShell( + isTopLevel: isTopLevel, + selectedIndex: 1, + onDestinationSelected: (_) {}, + body: const Text('SHELL'), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('a pushed detail pops back to its list on system Back', ( + tester, + ) async { + final navKey = GlobalKey(); + await tester.pumpWidget(host(navKey)); + await pushShell(tester, navKey, isTopLevel: false); + expect(find.text('SHELL'), findsOneWidget); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(find.text('SHELL'), findsNothing, reason: 'popped to the list'); + expect(find.text('HOME'), findsOneWidget); + }); + + testWidgets('a top-level tab does NOT pop on system Back', (tester) async { + final navKey = GlobalKey(); + await tester.pumpWidget(host(navKey)); + await pushShell(tester, navKey, isTopLevel: true); + expect(find.text('SHELL'), findsOneWidget); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + // moveToBackground is a no-op off Android, so the route stays put. + expect(find.text('SHELL'), findsOneWidget, reason: 'top-level stays'); + }); + }); +} From 0e926ad0c2a91714620220bd8ce8e56cdb4dd448 Mon Sep 17 00:00:00 2001 From: Strycher Date: Fri, 24 Jul 2026 03:13:54 -0400 Subject: [PATCH 04/62] feat(#380): parse PocketMesh / MeshCore One emoji reactions Reactions sent from MeshCore One arrived as junk text: an emoji line followed by an 8-character token such as "dyps6yf0". Those tokens are Crockford Base32 target-message hashes, the second line of a two-line reaction payload we did not recognise. Receive-side only. Offband keeps sending its own r:hhhh:ii format; their client already parses ours, so nothing about what we transmit changes. Wire format (confirmed against a live capture, see #378): channel: {emoji}@[{targetSender}]\n{hash} direct: {emoji}\n{hash} hash: sha256(body utf8 + timestamp uint32 LE seconds)[0:5], Crockford Base32, 8 chars, lowercase The body is hashed without the channel "SenderName: " prefix, which is why the sender travels in @[...] instead. - crockford_base32.dart: encode and normalise, 12-bit accumulator so the web target's 32-bit bitwise ops cannot truncate a 40-bit value - pocketmesh_reaction.dart: hash and a parser mirroring the reference implementation, with a conservative leading-emoji check so a real message is never swallowed - reaction_helper.dart: ReactionInfo carries a dialect; applyReaction picks the matching hash and, for the channel form, requires an exact sender-name match (a node name can carry emoji and variation selectors) - pending_reactions.dart: a reaction arriving before its target is held and retried rather than silently dropped, bounded at 50 entries with a 15 minute TTL and a warn-level log on expiry (closes the silent-drop path in #382) - the channel dedup key now includes the reacting sender, so two people sending the same emoji no longer collapse into one - notification tray summarises the foreign format as a reaction instead of showing the raw token Known limitation: on channels with Smaz or Cyr2Lat enabled the wire text differs from the text we store, so a hash computed by another client will not match. Flagged for a decision rather than worked around. Epic #376. Fast-follow #383 adds reactor identity. Co-Authored-By: Claude Opus 4.8 --- lib/connector/meshcore_connector.dart | 87 ++++++++-- lib/helpers/crockford_base32.dart | 56 +++++++ lib/helpers/pending_reactions.dart | 84 ++++++++++ lib/helpers/pocketmesh_reaction.dart | 121 ++++++++++++++ lib/helpers/reaction_helper.dart | 51 +++++- lib/services/notification_service.dart | 10 ++ test/helpers/crockford_base32_test.dart | 85 ++++++++++ test/helpers/pending_reactions_test.dart | 102 ++++++++++++ test/helpers/pocketmesh_reaction_test.dart | 177 +++++++++++++++++++++ test/helpers/reaction_dialect_test.dart | 142 +++++++++++++++++ 10 files changed, 897 insertions(+), 18 deletions(-) create mode 100644 lib/helpers/crockford_base32.dart create mode 100644 lib/helpers/pending_reactions.dart create mode 100644 lib/helpers/pocketmesh_reaction.dart create mode 100644 test/helpers/crockford_base32_test.dart create mode 100644 test/helpers/pending_reactions_test.dart create mode 100644 test/helpers/pocketmesh_reaction_test.dart create mode 100644 test/helpers/reaction_dialect_test.dart diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index c8e434b..4289d8b 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -17,6 +17,8 @@ import '../models/message.dart'; import '../models/offband_gps_status.dart'; import '../models/path_selection.dart'; import '../models/translation_support.dart'; +import '../helpers/pending_reactions.dart'; +import '../helpers/pocketmesh_reaction.dart'; import '../helpers/reaction_helper.dart'; import '../helpers/time_anomaly.dart'; import '../helpers/cyr2lat.dart'; @@ -236,9 +238,10 @@ class MeshCoreConnector extends ChangeNotifier { int _reactionSendQueueSequence = 0; final Set _loadedConversationKeys = {}; final Map> _processedChannelReactions = - {}; // channelIndex -> Set of "targetHash_emoji" + {}; // channelIndex -> Set of "targetHash_emoji_reactingSender" final Map> _processedContactReactions = {}; // contactPubKeyHex -> Set of "targetHash_emoji" + final PendingReactions _pendingReactions = PendingReactions(); StreamSubscription>? _scanSubscription; StreamSubscription? _connectionSubscription; @@ -6534,8 +6537,14 @@ class MeshCoreConnector extends ChangeNotifier { _conversations.putIfAbsent(pubKeyHex, () => []); final messages = _conversations[pubKeyHex]!; - // Parse reaction info - final reactionInfo = Message.parseReaction(message.text); + // Parse reaction info. Our own format first, then the PocketMesh / + // MeshCore One format (#380). A room server behaves like a channel (many + // senders) while 1:1 does not, and the two forms are mutually exclusive by + // shape, so try both rather than guessing which one applies. + final reactionInfo = + Message.parseReaction(message.text) ?? + _parsePocketMeshReaction(message.text, isDm: true) ?? + _parsePocketMeshReaction(message.text, isDm: false); if (reactionInfo != null) { // Check if we've already processed this exact reaction _processedContactReactions.putIfAbsent(pubKeyHex, () => {}); @@ -6548,7 +6557,20 @@ class MeshCoreConnector extends ChangeNotifier { if (!isDuplicate) { // New reaction - process it - _processContactReaction(messages, reactionInfo, pubKeyHex); + final matched = _processContactReaction( + messages, + reactionInfo, + pubKeyHex, + ); + if (!matched) { + // Early arrival, not junk. Hold it for the target (#382). + _pendingReactions.add( + _contactScopeKey(pubKeyHex), + reactionInfo, + pubKeyHex, + DateTime.now(), + ); + } _messageStore.saveMessages(pubKeyHex, messages); // Mark as processed @@ -6560,11 +6582,20 @@ class MeshCoreConnector extends ChangeNotifier { } messages.add(message); + // A message just landed, so a reaction that arrived before it may now have + // its target (#382). + _pendingReactions.retry( + _contactScopeKey(pubKeyHex), + (info) => _processContactReaction(messages, info, pubKeyHex), + DateTime.now(), + ); _messageStore.saveMessages(pubKeyHex, messages); notifyListeners(); } - void _processContactReaction( + String _contactScopeKey(String pubKeyHex) => 'contact:$pubKeyHex'; + + bool _processContactReaction( List messages, ReactionInfo reactionInfo, String contactPubKeyHex, @@ -6575,7 +6606,7 @@ class MeshCoreConnector extends ChangeNotifier { ); final isRoomServer = contact?.type == advTypeRoom; - ReactionHelper.applyReaction( + return ReactionHelper.applyReaction( messages: messages, reactionInfo: reactionInfo, // Incoming reactions in 1:1: match against outgoing messages only @@ -6780,13 +6811,19 @@ class MeshCoreConnector extends ChangeNotifier { _channelMessages.putIfAbsent(channelIndex, () => []); final messages = _channelMessages[channelIndex]!; - // Parse reaction info - final reactionInfo = ChannelMessage.parseReaction(message.text); + // Parse reaction info. Our own format first, then the PocketMesh / + // MeshCore One format other clients send (#380). + final reactionInfo = + ChannelMessage.parseReaction(message.text) ?? + _parsePocketMeshReaction(message.text, isDm: false); if (reactionInfo != null) { // Check if we've already processed this exact reaction _processedChannelReactions.putIfAbsent(channelIndex, () => {}); + // The reacting sender belongs in the key: without it, two people sending + // the same emoji to the same message collapse into one and the count + // never leaves 1. final reactionIdentifier = - '${reactionInfo.targetHash}_${reactionInfo.emoji}'; + '${reactionInfo.targetHash}_${reactionInfo.emoji}_${message.senderName}'; final isDuplicate = _processedChannelReactions[channelIndex]!.contains( reactionIdentifier, @@ -6794,7 +6831,16 @@ class MeshCoreConnector extends ChangeNotifier { if (!isDuplicate) { // New reaction - process it - _processReaction(messages, reactionInfo); + final matched = _processReaction(messages, reactionInfo); + if (!matched) { + // Early arrival, not junk. Hold it for the target (#382). + _pendingReactions.add( + _channelScopeKey(channelIndex), + reactionInfo, + message.senderName, + DateTime.now(), + ); + } // Save updated messages _channelMessageStore.saveChannelMessages(channelIndex, messages); @@ -6849,16 +6895,33 @@ class MeshCoreConnector extends ChangeNotifier { messages.add(processedMessage); } + // A message just landed, so a reaction that arrived before it may now have + // its target (#382). + _pendingReactions.retry( + _channelScopeKey(channelIndex), + (info) => _processReaction(messages, info), + DateTime.now(), + ); + // Save to persistent storage _channelMessageStore.saveChannelMessages(channelIndex, messages); return isNew; } - void _processReaction( + String _channelScopeKey(int channelIndex) => 'channel:$channelIndex'; + + /// The PocketMesh / MeshCore One reaction format, wrapped for matching. + /// Receive-only: we never emit it. + ReactionInfo? _parsePocketMeshReaction(String text, {required bool isDm}) { + final parsed = PocketMeshReaction.parse(text, isDm: isDm); + return parsed == null ? null : ReactionInfo.pocketMesh(parsed); + } + + bool _processReaction( List messages, ReactionInfo reactionInfo, ) { - ReactionHelper.applyReaction( + return ReactionHelper.applyReaction( messages: messages, reactionInfo: reactionInfo, shouldSkip: (_) => false, diff --git a/lib/helpers/crockford_base32.dart b/lib/helpers/crockford_base32.dart new file mode 100644 index 0000000..208939f --- /dev/null +++ b/lib/helpers/crockford_base32.dart @@ -0,0 +1,56 @@ +/// Crockford Base32, as used by the PocketMesh / MeshCore One reaction format. +/// +/// Encode-only: a reaction hash is compared as a string and never decoded back +/// to bytes. The alphabet omits i, l, o and u to avoid visual ambiguity. +class CrockfordBase32 { + static const String alphabet = '0123456789abcdefghjkmnpqrstvwxyz'; + + /// Encode exactly 5 bytes (40 bits) as 8 characters, most significant first. + /// + /// Accumulates at most 12 bits at a time rather than packing all 40 into one + /// int: bitwise operators are 32-bit on the web target, so a 40-bit shift + /// would silently truncate there. + static String encode5(List bytes) { + if (bytes.length != 5) { + throw ArgumentError.value( + bytes.length, + 'bytes', + 'Crockford Base32 encode5 needs exactly 5 bytes', + ); + } + final out = StringBuffer(); + var buffer = 0; + var bits = 0; + for (final byte in bytes) { + buffer = (buffer << 8) | byte; + bits += 8; + while (bits >= 5) { + bits -= 5; + out.write(alphabet[(buffer >> bits) & 0x1F]); + } + } + return out.toString(); + } + + /// The lowercase canonical form of an 8-character hash, or null if [text] is + /// not valid Crockford Base32. + /// + /// Resolves the ambiguity aliases the format defines on input (O to 0, I and + /// L to 1, either case) so a sender that emits them still matches. `u` has no + /// alias and is rejected. + static String? normalize8(String text) { + if (text.length != 8) return null; + final out = StringBuffer(); + for (var i = 0; i < 8; i++) { + final lower = text[i].toLowerCase(); + final resolved = switch (lower) { + 'o' => '0', + 'i' || 'l' => '1', + _ => lower, + }; + if (!alphabet.contains(resolved)) return null; + out.write(resolved); + } + return out.toString(); + } +} diff --git a/lib/helpers/pending_reactions.dart b/lib/helpers/pending_reactions.dart new file mode 100644 index 0000000..43d3870 --- /dev/null +++ b/lib/helpers/pending_reactions.dart @@ -0,0 +1,84 @@ +import '../utils/app_logger.dart'; +import 'reaction_helper.dart'; + +class _Pending { + final String scopeKey; + final ReactionInfo info; + final String reactingSender; + final DateTime queuedAt; + + const _Pending(this.scopeKey, this.info, this.reactingSender, this.queuedAt); +} + +/// Reactions that arrived before the message they point at. +/// +/// Out-of-order delivery is normal on a mesh, so a reaction with no local +/// target is not junk, it is early. Before this existed the connector consumed +/// such a reaction and dropped it with no badge, no message and no log, which +/// is indistinguishable from nobody having reacted (GH #382). +/// +/// Entries are bounded and expire. A drop is logged at warn so it surfaces in +/// the in-app log and the file log rather than vanishing (SAFELANE 6). Whether +/// dropping is the right end state is still open; the ratio of late matches to +/// expiries in the log is the evidence for revisiting it. +class PendingReactions { + static const int maxEntries = 50; + static const Duration ttl = Duration(minutes: 15); + + final List<_Pending> _entries = []; + + int get length => _entries.length; + + void add( + String scopeKey, + ReactionInfo info, + String reactingSender, + DateTime now, + ) { + expire(now); + _entries.add(_Pending(scopeKey, info, reactingSender, now)); + if (_entries.length > maxEntries) { + _drop(_entries.removeAt(0), now, 'queue full'); + } + appLogger.info( + 'Queued reaction ${info.emoji} from $reactingSender for unseen target ' + '${info.targetHash} in $scopeKey (${_entries.length}/$maxEntries pending)', + tag: 'Reactions', + ); + } + + /// Re-attempt every live entry for [scopeKey]. [apply] reports whether the + /// reaction found its target; matched entries are removed, the rest stay. + void retry(String scopeKey, bool Function(ReactionInfo) apply, DateTime now) { + expire(now); + _entries.removeWhere((entry) { + if (entry.scopeKey != scopeKey) return false; + if (!apply(entry.info)) return false; + appLogger.info( + 'Late-matched reaction ${entry.info.emoji} from ${entry.reactingSender} ' + 'to ${entry.info.targetHash} in $scopeKey after ' + '${now.difference(entry.queuedAt).inSeconds}s', + tag: 'Reactions', + ); + return true; + }); + } + + void expire(DateTime now) { + _entries.removeWhere((entry) { + if (now.difference(entry.queuedAt) < ttl) return false; + _drop(entry, now, 'expired after ${ttl.inMinutes}m'); + return true; + }); + } + + void _drop(_Pending entry, DateTime now, String why) { + appLogger.warn( + 'Dropping unmatched reaction ${entry.info.emoji} from ' + '${entry.reactingSender} for target ${entry.info.targetHash} in ' + '${entry.scopeKey} ($why, age ${now.difference(entry.queuedAt).inSeconds}s). ' + 'The message it points at was never seen locally.', + tag: 'Reactions', + ); + } +} diff --git a/lib/helpers/pocketmesh_reaction.dart b/lib/helpers/pocketmesh_reaction.dart new file mode 100644 index 0000000..234dcae --- /dev/null +++ b/lib/helpers/pocketmesh_reaction.dart @@ -0,0 +1,121 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +import 'crockford_base32.dart'; + +/// A reaction in the PocketMesh / MeshCore One wire format. +/// +/// Channel form: `{emoji}@[{targetSenderName}]\n{hash}` +/// Direct form: `{emoji}\n{hash}` +/// +/// The hash identifies the target message as sha256 over the target's body +/// text (UTF-8) followed by its sender-claimed timestamp as a little-endian +/// uint32 of epoch seconds, truncated to the first 5 bytes and encoded as 8 +/// Crockford Base32 characters. The channel `SenderName: ` prefix is NOT part +/// of the hashed text, which is why the sender travels in `@[...]` instead. +/// +/// Confirmed against a live capture from a MeshCore One peer, see GH #378. +/// Receive-only: Offband still sends its own `r:hhhh:ii` format (GH #379). +class PocketMeshReaction { + final String emoji; + + /// The target message's sender, present in the channel form only. In a direct + /// conversation the sender is implicit. + final String? targetSenderName; + + /// 8 characters, lowercase canonical form. + final String targetHash; + + const PocketMeshReaction({ + required this.emoji, + required this.targetHash, + this.targetSenderName, + }); + + static String computeHash(String bodyText, int timestampSeconds) { + final body = utf8.encode(bodyText); + final input = Uint8List(body.length + 4); + input.setRange(0, body.length, body); + // Division rather than shifts: `>>` is signed 32-bit on the web target and + // would misencode any timestamp past 2038. + final seconds = timestampSeconds % 4294967296; + input[body.length] = seconds % 256; + input[body.length + 1] = (seconds ~/ 256) % 256; + input[body.length + 2] = (seconds ~/ 65536) % 256; + input[body.length + 3] = (seconds ~/ 16777216) % 256; + return CrockfordBase32.encode5(sha256.convert(input).bytes.sublist(0, 5)); + } + + /// Parse [text] as a reaction, or null if it is an ordinary message. + /// + /// Mirrors the reference parser: the last line must be exactly 8 valid + /// Crockford Base32 characters, and the part before it must start with an + /// emoji. Both checks matter, since anything accepted here is swallowed + /// instead of being shown as a message. + static PocketMeshReaction? parse(String text, {required bool isDm}) { + final newline = text.lastIndexOf('\n'); + if (newline < 0) return null; + + final hash = CrockfordBase32.normalize8(text.substring(newline + 1)); + if (hash == null) return null; + + final head = text.substring(0, newline); + + if (isDm) { + if (head.contains('@[')) return null; + return _build(emoji: head, sender: null, hash: hash); + } + + final bracket = head.indexOf('@['); + if (bracket < 0) return null; + final afterBracket = head.substring(bracket + 2); + if (!afterBracket.endsWith(']')) return null; + final sender = afterBracket.substring(0, afterBracket.length - 1); + if (sender.isEmpty) return null; + + return _build( + emoji: head.substring(0, bracket), + sender: sender, + hash: hash, + ); + } + + static PocketMeshReaction? _build({ + required String emoji, + required String? sender, + required String hash, + }) { + if (emoji.isEmpty || !_startsWithEmoji(emoji)) return null; + return PocketMeshReaction( + emoji: emoji, + targetSenderName: sender, + targetHash: hash, + ); + } + + /// Deliberately conservative: a missed emoji only means the reaction renders + /// as text, which is the behaviour we have today, while a false positive + /// would swallow a real message. + static const List> _emojiRanges = [ + [0x1F000, 0x1FAFF], + [0x2600, 0x27BF], + [0x2B00, 0x2BFF], + [0x2190, 0x21FF], + [0x2934, 0x2935], + [0x3030, 0x3030], + [0x303D, 0x303D], + [0x3297, 0x3299], + ]; + + static bool _startsWithEmoji(String text) { + final runes = text.runes; + if (runes.isEmpty) return false; + final first = runes.first; + for (final range in _emojiRanges) { + if (first >= range[0] && first <= range[1]) return true; + } + return false; + } +} diff --git a/lib/helpers/reaction_helper.dart b/lib/helpers/reaction_helper.dart index 36118ca..15ecb45 100644 --- a/lib/helpers/reaction_helper.dart +++ b/lib/helpers/reaction_helper.dart @@ -1,10 +1,37 @@ import '../widgets/emoji_picker.dart'; +import 'pocketmesh_reaction.dart'; + +/// Which client's reaction format a [ReactionInfo] came from. The two use +/// different target hashes, so matching has to know which one it holds. +enum ReactionDialect { + /// Offband's own `r:hhhh:ii`. + offband, + + /// PocketMesh / MeshCore One, `{emoji}@[{sender}]\n{hash}`. Receive-only. + pocketMesh, +} class ReactionInfo { final String targetHash; final String emoji; + final ReactionDialect dialect; - ReactionInfo({required this.targetHash, required this.emoji}); + /// The target message's sender, carried by the PocketMesh channel form only. + /// When set, a candidate must match it as well as the hash. + final String? targetSenderName; + + ReactionInfo({ + required this.targetHash, + required this.emoji, + this.dialect = ReactionDialect.offband, + this.targetSenderName, + }); + + ReactionInfo.pocketMesh(PocketMeshReaction reaction) + : targetHash = reaction.targetHash, + emoji = reaction.emoji, + dialect = ReactionDialect.pocketMesh, + targetSenderName = reaction.targetSenderName; } class ReactionHelper { @@ -32,15 +59,27 @@ class ReactionHelper { updateMessage, }) { final targetHash = reactionInfo.targetHash; + final targetSender = reactionInfo.targetSenderName; for (int i = messages.length - 1; i >= 0; i--) { final msg = messages[i]; if (shouldSkip(msg)) continue; - final msgHash = computeReactionHash( - getTimestampSecs(msg), - getSenderName(msg), - getMessageText(msg), - ); + // Exact compare, no normalising: a node name can carry emoji and + // variation selectors (a live capture used "Strycher WM\u{1F6F0}\u{FE0F}") + // and any folding would break the match. + if (targetSender != null && getSenderName(msg) != targetSender) continue; + + final msgHash = switch (reactionInfo.dialect) { + ReactionDialect.offband => computeReactionHash( + getTimestampSecs(msg), + getSenderName(msg), + getMessageText(msg), + ), + ReactionDialect.pocketMesh => PocketMeshReaction.computeHash( + getMessageText(msg), + getTimestampSecs(msg), + ), + }; if (msgHash == targetHash) { final currentReactions = Map.from(getReactions(msg)); currentReactions[reactionInfo.emoji] = diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index f08d88a..67d5a2c 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -5,6 +5,7 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter/foundation.dart'; import '../helpers/gif_helper.dart'; +import '../helpers/pocketmesh_reaction.dart'; import '../helpers/reaction_helper.dart'; import '../l10n/app_localizations.dart'; import '../utils/platform_info.dart'; @@ -155,6 +156,15 @@ class NotificationService { if (reaction != null) { return 'Reacted ${reaction.emoji}'; } + // The PocketMesh / MeshCore One format, or the tray still shows the raw + // emoji-plus-hash payload (#380). The two forms are mutually exclusive by + // shape, so trying both is unambiguous. + final foreign = + PocketMeshReaction.parse(trimmed, isDm: true) ?? + PocketMeshReaction.parse(trimmed, isDm: false); + if (foreign != null) { + return 'Reacted ${foreign.emoji}'; + } // resolveGifUrl, not parseGif: the tray must summarise exactly the set the // chat renders inline, or an allowlisted Tenor GIF shows as a raw URL. (#283) if (GifHelper.resolveGifUrl(trimmed) != null) { diff --git a/test/helpers/crockford_base32_test.dart b/test/helpers/crockford_base32_test.dart new file mode 100644 index 0000000..17d1608 --- /dev/null +++ b/test/helpers/crockford_base32_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/helpers/crockford_base32.dart'; + +void main() { + group('CrockfordBase32', () { + group('encode5', () { + test('encodes 40 zero bits', () { + expect(CrockfordBase32.encode5([0, 0, 0, 0, 0]), '00000000'); + }); + + test('encodes 40 one bits', () { + expect(CrockfordBase32.encode5([255, 255, 255, 255, 255]), 'zzzzzzzz'); + }); + + test('walks the alphabet in order, most significant group first', () { + // 0x004432 14c7 packs the 5-bit groups 0,1,2,3,4,5,6,7. + expect( + CrockfordBase32.encode5([0x00, 0x44, 0x32, 0x14, 0xc7]), + '01234567', + ); + }); + + test('encodes a value above 32 bits without truncating', () { + // Guards the web target, where a naive 40-bit shift would lose the top + // byte and return '0...' for this input. + expect( + CrockfordBase32.encode5([0x8f, 0x1e, 0x2d, 0x00, 0x01]), + 'hwf2t001', + ); + }); + + test('rejects anything that is not exactly 5 bytes', () { + expect( + () => CrockfordBase32.encode5([1, 2, 3, 4]), + throwsArgumentError, + ); + expect( + () => CrockfordBase32.encode5([1, 2, 3, 4, 5, 6]), + throwsArgumentError, + ); + }); + }); + + group('normalize8', () { + test('passes a canonical hash through unchanged', () { + expect(CrockfordBase32.normalize8('dyps6yf0'), 'dyps6yf0'); + }); + + test('lowercases', () { + expect(CrockfordBase32.normalize8('DYPS6YF0'), 'dyps6yf0'); + }); + + test('resolves the ambiguity aliases in both cases', () { + expect(CrockfordBase32.normalize8('OoIiLl00'), '00111100'); + }); + + test('rejects u, which has no alias', () { + expect(CrockfordBase32.normalize8('dypsuyf0'), isNull); + expect(CrockfordBase32.normalize8('dypsUyf0'), isNull); + }); + + test('rejects the wrong length', () { + expect(CrockfordBase32.normalize8('dyps6yf'), isNull); + expect(CrockfordBase32.normalize8('dyps6yf00'), isNull); + expect(CrockfordBase32.normalize8(''), isNull); + }); + + test('rejects non-alphabet characters', () { + expect(CrockfordBase32.normalize8('dyps6y-0'), isNull); + expect(CrockfordBase32.normalize8('dyps6y 0'), isNull); + }); + + test('the alphabet omits the ambiguous letters', () { + for (final c in ['i', 'l', 'o', 'u']) { + expect( + CrockfordBase32.alphabet.contains(c), + isFalse, + reason: 'alphabet must not contain $c', + ); + } + expect(CrockfordBase32.alphabet.length, 32); + }); + }); + }); +} diff --git a/test/helpers/pending_reactions_test.dart b/test/helpers/pending_reactions_test.dart new file mode 100644 index 0000000..c3da6ef --- /dev/null +++ b/test/helpers/pending_reactions_test.dart @@ -0,0 +1,102 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/helpers/pending_reactions.dart'; +import 'package:meshcore_open/helpers/reaction_helper.dart'; + +ReactionInfo _info(String hash) => + ReactionInfo(targetHash: hash, emoji: '\u{1F44D}'); + +void main() { + group('PendingReactions', () { + final now = DateTime.utc(2026, 7, 24, 1, 43); + + test('holds a reaction whose target has not arrived', () { + final pending = PendingReactions(); + pending.add('channel:0', _info('dyps6yf0'), 'Node', now); + expect(pending.length, 1); + }); + + test('applies a held reaction once the target lands', () { + final pending = PendingReactions(); + pending.add('channel:0', _info('dyps6yf0'), 'Node', now); + + final applied = []; + pending.retry('channel:0', (info) { + applied.add(info.targetHash); + return true; + }, now.add(const Duration(seconds: 5))); + + expect(applied, ['dyps6yf0']); + expect(pending.length, 0); + }); + + test('keeps a reaction that still has no target', () { + final pending = PendingReactions(); + pending.add('channel:0', _info('dyps6yf0'), 'Node', now); + + pending.retry( + 'channel:0', + (_) => false, + now.add(const Duration(seconds: 5)), + ); + + expect(pending.length, 1); + }); + + test('does not leak across scopes', () { + final pending = PendingReactions(); + pending.add('channel:0', _info('dyps6yf0'), 'Node', now); + + var called = false; + pending.retry('channel:1', (_) { + called = true; + return true; + }, now); + + expect(called, isFalse); + expect(pending.length, 1); + }); + + test('drops a reaction that outlives the TTL', () { + final pending = PendingReactions(); + pending.add('channel:0', _info('dyps6yf0'), 'Node', now); + + pending.expire(now.add(PendingReactions.ttl)); + + expect(pending.length, 0); + }); + + test('keeps a reaction that is still inside the TTL', () { + final pending = PendingReactions(); + pending.add('channel:0', _info('dyps6yf0'), 'Node', now); + + pending.expire( + now.add(PendingReactions.ttl - const Duration(seconds: 1)), + ); + + expect(pending.length, 1); + }); + + test('caps the queue and evicts the oldest first', () { + final pending = PendingReactions(); + for (var i = 0; i <= PendingReactions.maxEntries; i++) { + pending.add( + 'channel:0', + _info(i.toString().padLeft(8, '0')), + 'Node', + now.add(Duration(seconds: i)), + ); + } + + expect(pending.length, PendingReactions.maxEntries); + + final seen = []; + pending.retry('channel:0', (info) { + seen.add(info.targetHash); + return true; + }, now.add(const Duration(minutes: 1))); + + expect(seen, isNot(contains('00000000')), reason: 'oldest was evicted'); + expect(seen.length, PendingReactions.maxEntries); + }); + }); +} diff --git a/test/helpers/pocketmesh_reaction_test.dart b/test/helpers/pocketmesh_reaction_test.dart new file mode 100644 index 0000000..b3ab7d8 --- /dev/null +++ b/test/helpers/pocketmesh_reaction_test.dart @@ -0,0 +1,177 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/helpers/pocketmesh_reaction.dart'; + +void main() { + group('PocketMeshReaction', () { + group('computeHash', () { + test('reproduces the hash from the live capture (GH #378)', () { + // Captured 2026-07-24: a MeshCore One peer reacted to this message and + // sent the hash below. If this ever fails, our hash input drifted from + // theirs and every incoming reaction will silently stop matching. + expect( + PocketMeshReaction.computeHash( + "It's a hash from MeshCore One for emojis. " + 'Looking at how to render better.', + 1784871761, + ), + 'dyps6yf0', + ); + }); + + test('is stable for the same input', () { + final a = PocketMeshReaction.computeHash('Hello', 1704067200); + final b = PocketMeshReaction.computeHash('Hello', 1704067200); + expect(a, b); + expect(a.length, 8); + }); + + test('the timestamp is part of the hash', () { + expect( + PocketMeshReaction.computeHash('Hello', 1704067200), + isNot(PocketMeshReaction.computeHash('Hello', 1704067201)), + ); + }); + + test('handles a timestamp past the 32-bit signed boundary', () { + // 2^31 + 1. A signed 32-bit shift on the web target would misencode it. + final hash = PocketMeshReaction.computeHash('Hello', 2147483649); + expect(hash.length, 8); + expect(hash, isNot(PocketMeshReaction.computeHash('Hello', 1))); + }); + + test('hashes the body, not the sender-prefixed wire text', () { + const body = 'Test'; + expect( + PocketMeshReaction.computeHash(body, 1784871761), + isNot(PocketMeshReaction.computeHash('Someone: $body', 1784871761)), + ); + }); + }); + + group('parse, channel form', () { + test('parses the exact payload from the live capture', () { + final parsed = PocketMeshReaction.parse( + '\u{1F44D}@[Strycher WM\u{1F6F0}\u{FE0F}]\ndyps6yf0', + isDm: false, + ); + + expect(parsed, isNotNull); + expect(parsed!.emoji, '\u{1F44D}'); + expect(parsed.targetSenderName, 'Strycher WM\u{1F6F0}\u{FE0F}'); + expect(parsed.targetHash, 'dyps6yf0'); + }); + + test('normalises an uppercase hash', () { + final parsed = PocketMeshReaction.parse( + '\u{1F44D}@[Node]\nDYPS6YF0', + isDm: false, + ); + expect(parsed?.targetHash, 'dyps6yf0'); + }); + + test('rejects a missing sender block', () { + expect( + PocketMeshReaction.parse('\u{1F44D}\ndyps6yf0', isDm: false), + isNull, + ); + }); + + test('rejects an unterminated sender block', () { + expect( + PocketMeshReaction.parse('\u{1F44D}@[Node\ndyps6yf0', isDm: false), + isNull, + ); + }); + + test('rejects an empty sender', () { + expect( + PocketMeshReaction.parse('\u{1F44D}@[]\ndyps6yf0', isDm: false), + isNull, + ); + }); + }); + + group('parse, direct form', () { + test('parses emoji and hash', () { + final parsed = PocketMeshReaction.parse( + '\u{1F44D}\ndyps6yf0', + isDm: true, + ); + + expect(parsed, isNotNull); + expect(parsed!.emoji, '\u{1F44D}'); + expect(parsed.targetSenderName, isNull); + expect(parsed.targetHash, 'dyps6yf0'); + }); + + test('rejects the channel form', () { + expect( + PocketMeshReaction.parse('\u{1F44D}@[Node]\ndyps6yf0', isDm: true), + isNull, + ); + }); + }); + + group('parse rejects ordinary messages', () { + test('no newline', () { + expect( + PocketMeshReaction.parse('\u{1F44D}dyps6yf0', isDm: true), + isNull, + ); + }); + + test('tail is not 8 characters', () { + expect( + PocketMeshReaction.parse('\u{1F44D}\ndyps6yf', isDm: true), + isNull, + ); + expect( + PocketMeshReaction.parse('\u{1F44D}\ndyps6yf00', isDm: true), + isNull, + ); + }); + + test('tail holds a character outside the alphabet', () { + expect( + PocketMeshReaction.parse('\u{1F44D}\ndypsuyf0', isDm: true), + isNull, + ); + }); + + test('nothing before the newline', () { + expect(PocketMeshReaction.parse('\ndyps6yf0', isDm: true), isNull); + }); + + test('the leading character is not an emoji', () { + // The realistic false positive: a two-line message whose last line + // happens to be eight Crockford characters. + expect( + PocketMeshReaction.parse('see below\ndyps6yf0', isDm: true), + isNull, + ); + expect(PocketMeshReaction.parse('A\ndyps6yf0', isDm: true), isNull); + }); + + test('our own reaction format is not mistaken for theirs', () { + expect(PocketMeshReaction.parse('r:3f2a:05', isDm: true), isNull); + }); + + test('accepts the emoji the picker offers as quick reactions', () { + for (final emoji in [ + '\u{1F44D}', + '\u{2764}\u{FE0F}', + '\u{1F602}', + '\u{1F389}', + '\u{1F44F}', + '\u{1F525}', + ]) { + expect( + PocketMeshReaction.parse('$emoji\ndyps6yf0', isDm: true), + isNotNull, + reason: 'should accept $emoji', + ); + } + }); + }); + }); +} diff --git a/test/helpers/reaction_dialect_test.dart b/test/helpers/reaction_dialect_test.dart new file mode 100644 index 0000000..06aa5e1 --- /dev/null +++ b/test/helpers/reaction_dialect_test.dart @@ -0,0 +1,142 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/helpers/pocketmesh_reaction.dart'; +import 'package:meshcore_open/helpers/reaction_helper.dart'; + +class _Msg { + final int timestampSecs; + final String senderName; + final String text; + Map reactions = {}; + + _Msg(this.timestampSecs, this.senderName, this.text); +} + +/// Runs applyReaction over [messages] and reports the match plus the resulting +/// reaction map of whichever message was updated. +({bool matched, Map? reactions}) _apply( + List<_Msg> messages, + ReactionInfo info, +) { + Map? updated; + final matched = ReactionHelper.applyReaction<_Msg>( + messages: messages, + reactionInfo: info, + getTimestampSecs: (m) => m.timestampSecs, + getSenderName: (m) => m.senderName, + getMessageText: (m) => m.text, + getReactions: (m) => m.reactions, + shouldSkip: (_) => false, + updateMessage: (i, reactions) { + messages[i].reactions = reactions; + updated = reactions; + }, + ); + return (matched: matched, reactions: updated); +} + +void main() { + // The live capture from GH #378. + const capturedText = + "It's a hash from MeshCore One for emojis. " + 'Looking at how to render better.'; + const capturedTs = 1784871761; + const capturedSender = 'Strycher WM\u{1F6F0}\u{FE0F}'; + const capturedHash = 'dyps6yf0'; + + group('applyReaction dialect dispatch', () { + test('a PocketMesh reaction matches by the PocketMesh hash', () { + final messages = [_Msg(capturedTs, capturedSender, capturedText)]; + final info = ReactionInfo.pocketMesh( + PocketMeshReaction.parse( + '\u{1F44D}@[$capturedSender]\n$capturedHash', + isDm: false, + )!, + ); + + final result = _apply(messages, info); + + expect(result.matched, isTrue); + expect(result.reactions, {'\u{1F44D}': 1}); + }); + + test('the sender must match exactly, emoji and all', () { + final messages = [ + _Msg(capturedTs, 'Strycher WM', capturedText), // no satellite emoji + ]; + final info = ReactionInfo.pocketMesh( + PocketMeshReaction.parse( + '\u{1F44D}@[$capturedSender]\n$capturedHash', + isDm: false, + )!, + ); + + expect(_apply(messages, info).matched, isFalse); + }); + + test('the direct form matches without a sender', () { + final messages = [_Msg(capturedTs, 'whoever', capturedText)]; + final info = ReactionInfo.pocketMesh( + PocketMeshReaction.parse('\u{1F44D}\n$capturedHash', isDm: true)!, + ); + + expect(_apply(messages, info).matched, isTrue); + }); + + test('reports no match when the target is absent', () { + final messages = [ + _Msg(capturedTs, capturedSender, 'a different message'), + ]; + final info = ReactionInfo.pocketMesh( + PocketMeshReaction.parse( + '\u{1F44D}@[$capturedSender]\n$capturedHash', + isDm: false, + )!, + ); + + expect(_apply(messages, info).matched, isFalse); + }); + + test('the Offband dialect still uses the Offband hash, unchanged', () { + final messages = [_Msg(1234567890, 'Alice', 'Hello world!')]; + final hash = ReactionHelper.computeReactionHash( + 1234567890, + 'Alice', + 'Hello world!', + ); + final info = ReactionHelper.parseReaction( + 'r:$hash:${ReactionHelper.emojiToIndex('\u{1F389}')}', + ); + + expect(info, isNotNull); + expect(info!.dialect, ReactionDialect.offband); + + final result = _apply(messages, info); + expect(result.matched, isTrue); + expect(result.reactions, {'\u{1F389}': 1}); + }); + + test('the two dialects do not match each other', () { + final messages = [_Msg(capturedTs, capturedSender, capturedText)]; + // The PocketMesh hash fed in as if it were ours must not match. + final info = ReactionInfo(targetHash: capturedHash, emoji: '\u{1F44D}'); + + expect(info.dialect, ReactionDialect.offband); + expect(_apply(messages, info).matched, isFalse); + }); + + test('counts accumulate for the same emoji', () { + final messages = [_Msg(capturedTs, capturedSender, capturedText)]; + final info = ReactionInfo.pocketMesh( + PocketMeshReaction.parse( + '\u{1F44D}@[$capturedSender]\n$capturedHash', + isDm: false, + )!, + ); + + _apply(messages, info); + final second = _apply(messages, info); + + expect(second.reactions, {'\u{1F44D}': 2}); + }); + }); +} From d63838a94b7b40561165486621970eaa686d0c5b Mon Sep 17 00:00:00 2001 From: Strycher Date: Fri, 24 Jul 2026 03:25:21 -0400 Subject: [PATCH 05/62] fix(#380): tighten reaction parsing after adversarial review Two of three Gemini findings held up against the code; the third did not. Accepted, message-swallow risk: the emoji check looked only at the first rune against a range list that included arrows, so a multi-line message beginning with an arrow and ending in eight Crockford characters would have been consumed and displayed as a reaction whose "emoji" was the whole sentence. Ben's own capture has U+2192 mid-sentence in ordinary channel traffic, so this was reachable, not theoretical. - drop U+2190-U+21FF and U+2934-U+2935; arrows carry the Unicode Emoji property but read as punctuation in prose - cap the emoji segment at 8 runes, which is clear of the longest ZWJ sequence and nowhere near a sentence. This is the guard that holds regardless of how the range list evolves. Accepted, dedup: the contact path keyed on target hash plus emoji only. A room server is many-party over the 1:1 transport, so two members sending the same emoji collapsed into one and the count stuck at 1, the same defect already fixed on the channel path. The reacting author prefix is now part of the key; in a true 1:1 it is empty and the key is unchanged. Rejected, room-server sender matching: the review claimed getSenderName resolves to the room itself and that room text carries a "Sender: " prefix. Neither is true. _resolveContactSenderName resolves the author via fourByteRoomContactKey to the real per-sender contact name, and room message text is stored bare with the author in its own field. One real sub-case survives: an author who is not in our contacts resolves to null and will not match, which degrades to queue-then-expire with a warn log. Also rejected: switching @[ lookup from first to last occurrence. The reference implementation uses the first, and diverging risks mismatching payloads it accepts. --- lib/connector/meshcore_connector.dart | 10 ++++- lib/helpers/pocketmesh_reaction.dart | 22 ++++++++--- test/helpers/pocketmesh_reaction_test.dart | 44 ++++++++++++++++++++++ 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 4289d8b..f8f0018 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -6546,10 +6546,16 @@ class MeshCoreConnector extends ChangeNotifier { _parsePocketMeshReaction(message.text, isDm: true) ?? _parsePocketMeshReaction(message.text, isDm: false); if (reactionInfo != null) { - // Check if we've already processed this exact reaction + // Check if we've already processed this exact reaction. A room server is + // many-party over the 1:1 transport, so the reacting author belongs in + // the key or two members sending the same emoji collapse into one; in a + // true 1:1 the author prefix is empty and the key is unchanged. _processedContactReactions.putIfAbsent(pubKeyHex, () => {}); + final reactingAuthor = message.fourByteRoomContactKey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); final reactionIdentifier = - '${reactionInfo.targetHash}_${reactionInfo.emoji}'; + '${reactionInfo.targetHash}_${reactionInfo.emoji}_$reactingAuthor'; final isDuplicate = _processedContactReactions[pubKeyHex]!.contains( reactionIdentifier, diff --git a/lib/helpers/pocketmesh_reaction.dart b/lib/helpers/pocketmesh_reaction.dart index 234dcae..d8b3da1 100644 --- a/lib/helpers/pocketmesh_reaction.dart +++ b/lib/helpers/pocketmesh_reaction.dart @@ -87,7 +87,7 @@ class PocketMeshReaction { required String? sender, required String hash, }) { - if (emoji.isEmpty || !_startsWithEmoji(emoji)) return null; + if (!_isReactionEmoji(emoji)) return null; return PocketMeshReaction( emoji: emoji, targetSenderName: sender, @@ -95,23 +95,35 @@ class PocketMeshReaction { ); } + /// A reaction is one emoji, possibly with a variation selector, a skin-tone + /// modifier or ZWJ joins. Eight runes is well clear of the longest such + /// sequence and nowhere near a sentence. + /// + /// This cap is the guard that matters: without it, any multi-line message + /// starting with a symbol and ending in eight Crockford characters would be + /// swallowed whole and shown as the reaction "emoji". + static const int _maxEmojiRunes = 8; + /// Deliberately conservative: a missed emoji only means the reaction renders /// as text, which is the behaviour we have today, while a false positive /// would swallow a real message. + /// + /// Arrows (U+2190-U+21FF, U+2934-U+2935) are excluded on purpose even though + /// they carry the Unicode Emoji property. They are ordinary punctuation in + /// prose, and a live capture from this mesh contained U+2192 mid-sentence in + /// a normal channel message. static const List> _emojiRanges = [ [0x1F000, 0x1FAFF], [0x2600, 0x27BF], [0x2B00, 0x2BFF], - [0x2190, 0x21FF], - [0x2934, 0x2935], [0x3030, 0x3030], [0x303D, 0x303D], [0x3297, 0x3299], ]; - static bool _startsWithEmoji(String text) { + static bool _isReactionEmoji(String text) { final runes = text.runes; - if (runes.isEmpty) return false; + if (runes.isEmpty || runes.length > _maxEmojiRunes) return false; final first = runes.first; for (final range in _emojiRanges) { if (first >= range[0] && first <= range[1]) return true; diff --git a/test/helpers/pocketmesh_reaction_test.dart b/test/helpers/pocketmesh_reaction_test.dart index b3ab7d8..5d6efd3 100644 --- a/test/helpers/pocketmesh_reaction_test.dart +++ b/test/helpers/pocketmesh_reaction_test.dart @@ -142,6 +142,50 @@ void main() { expect(PocketMeshReaction.parse('\ndyps6yf0', isDm: true), isNull); }); + test('a sentence starting with an arrow is not a reaction', () { + // Arrows carry the Unicode Emoji property but are ordinary punctuation. + // A live capture from this mesh had U+2192 mid-sentence in a normal + // channel message, so the range is excluded outright. + expect( + PocketMeshReaction.parse( + '\u{2190} Turn left at the fork\ndyps6yf0', + isDm: true, + ), + isNull, + ); + expect( + PocketMeshReaction.parse('\u{2192}\ndyps6yf0', isDm: true), + isNull, + ); + }); + + test('a long run of text after a real emoji is not a reaction', () { + // The swallow risk the rune cap exists for: without it the whole body + // would become the reaction "emoji". + expect( + PocketMeshReaction.parse( + '\u{1F44D} thanks, that fixed it for me\ndyps6yf0', + isDm: true, + ), + isNull, + ); + }); + + test('accepts a multi-rune emoji sequence', () { + // Skin tone modifier, then a ZWJ family sequence. + expect( + PocketMeshReaction.parse('\u{1F44D}\u{1F3FD}\ndyps6yf0', isDm: true), + isNotNull, + ); + expect( + PocketMeshReaction.parse( + '\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\ndyps6yf0', + isDm: true, + ), + isNotNull, + ); + }); + test('the leading character is not an emoji', () { // The realistic false positive: a two-line message whose last line // happens to be eight Crockford characters. From 646dc79d8689271e8d2a80dd3dac253d72a1a120 Mon Sep 17 00:00:00 2001 From: Strycher Date: Mon, 27 Jul 2026 02:22:28 -0400 Subject: [PATCH 06/62] fix(#396): use the platform-standard share icon on the BLE log button The button used Icons.ios_share for all mobile, so Android showed the iOS share glyph. Split by platform: Android -> Icons.share, iOS -> Icons.ios_share, desktop -> Icons.folder_open (reveal the saved logs). Co-Authored-By: Claude Opus 4.8 --- lib/screens/ble_debug_log_screen.dart | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/screens/ble_debug_log_screen.dart b/lib/screens/ble_debug_log_screen.dart index 81c4602..68c97cc 100644 --- a/lib/screens/ble_debug_log_screen.dart +++ b/lib/screens/ble_debug_log_screen.dart @@ -42,7 +42,16 @@ class _BleDebugLogScreenState extends State { actions: [ IconButton( tooltip: isMobile ? 'Share logs' : 'Open logs folder', - icon: Icon(isMobile ? Icons.ios_share : Icons.folder_open), + // Platform-standard share glyph: Android uses Icons.share + // (connected nodes), iOS uses Icons.ios_share (square+arrow), + // desktop reveals the saved file (folder). #396 + icon: Icon( + defaultTargetPlatform == TargetPlatform.android + ? Icons.share + : isMobile + ? Icons.ios_share + : Icons.folder_open, + ), onPressed: () => _exportLogs(context), ), IconButton( From e452ab48058313308982fc6e9c23d472902fd4a7 Mon Sep 17 00:00:00 2001 From: Strycher Date: Mon, 27 Jul 2026 02:00:53 -0400 Subject: [PATCH 07/62] feat(#383): track who reacted, not just the count Reactions stored only a Map of emoji to count, so "who reacted" was unanswerable and MeshCore One's tap-to-see-who had nothing to show against. This captures the reactor for every reaction, both our own r: format and the PocketMesh / MeshCore One format. Data layer only. The tap-a-badge-to-see-who UI is a separate follow-on; this makes the data available and does not change any screen. Additive by design. A new reactionSenders field (Map>, emoji -> reactor names) sits alongside the existing count map, serialised under a new JSON key. Both maps are always written. An older build reading a newer store ignores the unknown key and still gets correct counts from reactions; it never hits the hard `value as int` cast that would fail the whole message-list load (the #355 data-loss shape). Records written before this field load with an empty sender map, so their counts survive with names simply absent. - ChannelMessage + Message: new reactionSenders field, constructor, copyWith - both stores: serialise the new key; deserialise via a shared ReactionHelper.reactionSendersFromJson that returns empty for a missing key and skips malformed entries rather than throwing - applyReaction: records the reactor and dedups per reactor per emoji. This is persistent dedup (survives restart), unlike the connector's in-memory processed-set. A pre-existing count with no sender list is incremented from its stored value, not recomputed from the partial list, so old counts are preserved. - connector: threads the reactor name into all reaction paths. Channel uses the frame sender; a room resolves the author via fourByteRoomContactKey; an outgoing reaction is attributed to self. - pending queue: retry now hands the stored reactor to its callback Tests: reactor capture, two-reactor count, same-reactor no-double-count, pre-#383 count preservation, and JSON round-trip + backward-compat + malformed-entry handling for the new field. Full suite 604 passing. Epic #376. Co-Authored-By: Claude Opus 4.8 --- lib/connector/meshcore_connector.dart | 68 +++++++++++++---- lib/helpers/pending_reactions.dart | 13 +++- lib/helpers/reaction_helper.dart | 54 ++++++++++++-- lib/models/channel_message.dart | 12 +++ lib/models/message.dart | 12 +++ lib/storage/channel_message_store.dart | 5 ++ lib/storage/message_store.dart | 5 ++ test/helpers/pending_reactions_test.dart | 13 +++- test/helpers/reaction_dialect_test.dart | 78 +++++++++++++++++--- test/helpers/reaction_senders_json_test.dart | 44 +++++++++++ 10 files changed, 264 insertions(+), 40 deletions(-) create mode 100644 test/helpers/reaction_senders_json_test.dart diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index f8f0018..63f33e2 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -3610,8 +3610,9 @@ class MeshCoreConnector extends ChangeNotifier { _channelMessages.putIfAbsent(channel.index, () => []); final messages = _channelMessages[channel.index]!; - // Process reaction locally to update the UI immediately - _processReaction(messages, reactionInfo); + // Process reaction locally to update the UI immediately. This is our own + // outgoing reaction, so the reactor is us. + _processReaction(messages, reactionInfo, _selfName ?? 'Me'); await _channelMessageStore.saveChannelMessages(channel.index, messages); // Mark this reaction as processed @@ -6551,11 +6552,23 @@ class MeshCoreConnector extends ChangeNotifier { // the key or two members sending the same emoji collapse into one; in a // true 1:1 the author prefix is empty and the key is unchanged. _processedContactReactions.putIfAbsent(pubKeyHex, () => {}); - final reactingAuthor = message.fourByteRoomContactKey + // The reactor's display name: in a room the frame's own author field + // identifies them; in a true 1:1 it is simply the contact. Falls back to + // the hex author prefix, then the pubkey, so it is never empty (#383). + final reactionContact = _contacts.cast().firstWhere( + (c) => c?.publicKeyHex == pubKeyHex, + orElse: () => null, + ); + final isRoomReaction = reactionContact?.type == advTypeRoom; + final reactingAuthorHex = message.fourByteRoomContactKey .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(); + final reactorName = + _resolveContactSenderName(message, reactionContact, isRoomReaction) ?? + reactionContact?.name ?? + (reactingAuthorHex.isNotEmpty ? reactingAuthorHex : pubKeyHex); final reactionIdentifier = - '${reactionInfo.targetHash}_${reactionInfo.emoji}_$reactingAuthor'; + '${reactionInfo.targetHash}_${reactionInfo.emoji}_$reactingAuthorHex'; final isDuplicate = _processedContactReactions[pubKeyHex]!.contains( reactionIdentifier, @@ -6567,13 +6580,14 @@ class MeshCoreConnector extends ChangeNotifier { messages, reactionInfo, pubKeyHex, + reactorName, ); if (!matched) { // Early arrival, not junk. Hold it for the target (#382). _pendingReactions.add( _contactScopeKey(pubKeyHex), reactionInfo, - pubKeyHex, + reactorName, DateTime.now(), ); } @@ -6592,7 +6606,8 @@ class MeshCoreConnector extends ChangeNotifier { // its target (#382). _pendingReactions.retry( _contactScopeKey(pubKeyHex), - (info) => _processContactReaction(messages, info, pubKeyHex), + (info, reactingSender) => + _processContactReaction(messages, info, pubKeyHex, reactingSender), DateTime.now(), ); _messageStore.saveMessages(pubKeyHex, messages); @@ -6605,6 +6620,7 @@ class MeshCoreConnector extends ChangeNotifier { List messages, ReactionInfo reactionInfo, String contactPubKeyHex, + String reactingSender, ) { final contact = _contacts.cast().firstWhere( (c) => c?.publicKeyHex == contactPubKeyHex, @@ -6615,6 +6631,7 @@ class MeshCoreConnector extends ChangeNotifier { return ReactionHelper.applyReaction( messages: messages, reactionInfo: reactionInfo, + reactingSender: reactingSender, // Incoming reactions in 1:1: match against outgoing messages only shouldSkip: (msg) => isRoomServer != true && !msg.isOutgoing, getTimestampSecs: (msg) => msg.timestamp.millisecondsSinceEpoch ~/ 1000, @@ -6622,8 +6639,12 @@ class MeshCoreConnector extends ChangeNotifier { _resolveContactSenderName(msg, contact, isRoomServer == true), getMessageText: (msg) => msg.text, getReactions: (msg) => msg.reactions, - updateMessage: (i, reactions) { - messages[i] = messages[i].copyWith(reactions: reactions); + getReactionSenders: (msg) => msg.reactionSenders, + updateMessage: (i, reactions, senders) { + messages[i] = messages[i].copyWith( + reactions: reactions, + reactionSenders: senders, + ); }, ); } @@ -6638,6 +6659,8 @@ class MeshCoreConnector extends ChangeNotifier { ReactionHelper.applyReaction( messages: messages, reactionInfo: reactionInfo, + // Our own outgoing reaction, so the reactor is us. + reactingSender: _selfName ?? 'Me', // Outgoing reactions in 1:1: match against incoming messages shouldSkip: (msg) => !isRoomServer && msg.isOutgoing, getTimestampSecs: (msg) => msg.timestamp.millisecondsSinceEpoch ~/ 1000, @@ -6645,8 +6668,12 @@ class MeshCoreConnector extends ChangeNotifier { _resolveContactSenderName(msg, contact, isRoomServer), getMessageText: (msg) => msg.text, getReactions: (msg) => msg.reactions, - updateMessage: (i, reactions) { - messages[i] = messages[i].copyWith(reactions: reactions); + getReactionSenders: (msg) => msg.reactionSenders, + updateMessage: (i, reactions, senders) { + messages[i] = messages[i].copyWith( + reactions: reactions, + reactionSenders: senders, + ); }, ); } @@ -6836,8 +6863,12 @@ class MeshCoreConnector extends ChangeNotifier { ); if (!isDuplicate) { - // New reaction - process it - final matched = _processReaction(messages, reactionInfo); + // New reaction - process it. The reactor is the frame's sender. + final matched = _processReaction( + messages, + reactionInfo, + message.senderName, + ); if (!matched) { // Early arrival, not junk. Hold it for the target (#382). _pendingReactions.add( @@ -6905,7 +6936,8 @@ class MeshCoreConnector extends ChangeNotifier { // its target (#382). _pendingReactions.retry( _channelScopeKey(channelIndex), - (info) => _processReaction(messages, info), + (info, reactingSender) => + _processReaction(messages, info, reactingSender), DateTime.now(), ); @@ -6926,17 +6958,23 @@ class MeshCoreConnector extends ChangeNotifier { bool _processReaction( List messages, ReactionInfo reactionInfo, + String reactingSender, ) { return ReactionHelper.applyReaction( messages: messages, reactionInfo: reactionInfo, + reactingSender: reactingSender, shouldSkip: (_) => false, getTimestampSecs: (msg) => msg.timestamp.millisecondsSinceEpoch ~/ 1000, getSenderName: (msg) => msg.senderName, getMessageText: (msg) => msg.text, getReactions: (msg) => msg.reactions, - updateMessage: (i, reactions) { - messages[i] = messages[i].copyWith(reactions: reactions); + getReactionSenders: (msg) => msg.reactionSenders, + updateMessage: (i, reactions, senders) { + messages[i] = messages[i].copyWith( + reactions: reactions, + reactionSenders: senders, + ); notifyListeners(); }, ); diff --git a/lib/helpers/pending_reactions.dart b/lib/helpers/pending_reactions.dart index 43d3870..098a841 100644 --- a/lib/helpers/pending_reactions.dart +++ b/lib/helpers/pending_reactions.dart @@ -47,13 +47,18 @@ class PendingReactions { ); } - /// Re-attempt every live entry for [scopeKey]. [apply] reports whether the - /// reaction found its target; matched entries are removed, the rest stay. - void retry(String scopeKey, bool Function(ReactionInfo) apply, DateTime now) { + /// Re-attempt every live entry for [scopeKey]. [apply] receives the queued + /// reaction and the name of whoever sent it, and reports whether it found its + /// target; matched entries are removed, the rest stay. + void retry( + String scopeKey, + bool Function(ReactionInfo info, String reactingSender) apply, + DateTime now, + ) { expire(now); _entries.removeWhere((entry) { if (entry.scopeKey != scopeKey) return false; - if (!apply(entry.info)) return false; + if (!apply(entry.info, entry.reactingSender)) return false; appLogger.info( 'Late-matched reaction ${entry.info.emoji} from ${entry.reactingSender} ' 'to ${entry.info.targetHash} in $scopeKey after ' diff --git a/lib/helpers/reaction_helper.dart b/lib/helpers/reaction_helper.dart index 15ecb45..d236397 100644 --- a/lib/helpers/reaction_helper.dart +++ b/lib/helpers/reaction_helper.dart @@ -35,6 +35,23 @@ class ReactionInfo { } class ReactionHelper { + /// Deserialize the additive `reactionSenders` map from stored JSON (#383). + /// + /// Returns an empty map for records written before the field existed, so an + /// old store loads cleanly (its counts still come from the `reactions` key). + /// Malformed or wrongly-typed entries are skipped rather than thrown, because + /// a single bad entry must never fail the whole message-list load. + static Map> reactionSendersFromJson(Object? raw) { + if (raw is! Map) return {}; + final result = >{}; + raw.forEach((key, value) { + if (key is String && value is List) { + result[key] = value.whereType().toList(); + } + }); + return result; + } + /// Apply a reaction to a list of messages by matching the reaction hash. /// /// [messages] - the message list to search @@ -43,23 +60,40 @@ class ReactionHelper { /// [getSenderName] - extract sender name for hash (null for 1:1 implicit) /// [getMessageText] - extract message text /// [getReactions] - extract current reactions map + /// [getReactionSenders] - extract current emoji->reactor-names map (#383) + /// [reactingSender] - the name of whoever sent this reaction /// [shouldSkip] - filter function to skip messages (e.g., skip outgoing for incoming reactions) - /// [updateMessage] - callback to update the message at index with new reactions + /// [updateMessage] - callback to update the message at index with the new + /// count map and the new sender map /// /// Returns whether a match was found. + /// + /// [reactionSenders] is the persistent per-reactor record. A given reactor is + /// counted once per emoji: if their name is already in the list, the reaction + /// is a no-op on both maps (this survives restart, unlike the connector's + /// in-memory dedup set). Counts recorded before #383 have no sender list, so + /// the count is still incremented from its stored value rather than being + /// recomputed from the (partial) sender list, which would lose those. static bool applyReaction({ required List messages, required ReactionInfo reactionInfo, + required String reactingSender, required int Function(T) getTimestampSecs, required String? Function(T) getSenderName, required String Function(T) getMessageText, required Map Function(T) getReactions, + required Map> Function(T) getReactionSenders, required bool Function(T) shouldSkip, - required void Function(int index, Map newReactions) + required void Function( + int index, + Map newReactions, + Map> newSenders, + ) updateMessage, }) { final targetHash = reactionInfo.targetHash; final targetSender = reactionInfo.targetSenderName; + final emoji = reactionInfo.emoji; for (int i = messages.length - 1; i >= 0; i--) { final msg = messages[i]; if (shouldSkip(msg)) continue; @@ -81,10 +115,20 @@ class ReactionHelper { ), }; if (msgHash == targetHash) { + final senders = >{ + for (final e in getReactionSenders(msg).entries) + e.key: List.from(e.value), + }; + final list = senders.putIfAbsent(emoji, () => []); + if (list.contains(reactingSender)) { + // Already recorded this reactor for this emoji; matched, no change. + return true; + } + list.add(reactingSender); + final currentReactions = Map.from(getReactions(msg)); - currentReactions[reactionInfo.emoji] = - (currentReactions[reactionInfo.emoji] ?? 0) + 1; - updateMessage(i, currentReactions); + currentReactions[emoji] = (currentReactions[emoji] ?? 0) + 1; + updateMessage(i, currentReactions, senders); return true; } } diff --git a/lib/models/channel_message.dart b/lib/models/channel_message.dart index 886bab3..7ec8cf0 100644 --- a/lib/models/channel_message.dart +++ b/lib/models/channel_message.dart @@ -51,6 +51,14 @@ class ChannelMessage { final String? replyToText; final Map reactions; + /// Emoji to the list of sender names who reacted with it (#383). + /// + /// Additive over [reactions]: the count map is still written and read so an + /// older build degrades to "counts work, names missing" rather than failing + /// to load. Reactions recorded before this field existed appear in + /// [reactions] with no matching entry here. + final Map> reactionSenders; + /// Local wall-clock time this message's frame arrived, set at ingest. /// Null for outgoing messages and records stored before #285 — never /// fabricated. [timestamp] is the SENDER's claimed time; this is ours. @@ -80,11 +88,13 @@ class ChannelMessage { this.replyToSenderName, this.replyToText, Map? reactions, + Map>? reactionSenders, this.rxTime, }) : messageId = messageId ?? '${timestamp.millisecondsSinceEpoch}_${senderName.hashCode}_${text.hashCode}', reactions = reactions ?? {}, + reactionSenders = reactionSenders ?? {}, pathBytes = pathBytes ?? Uint8List(0), pathVariants = _mergePathVariants( pathBytes ?? Uint8List(0), @@ -129,6 +139,7 @@ class ChannelMessage { MessageTranslationStatus? translationStatus, Object? translationModelId = _unset, Map? reactions, + Map>? reactionSenders, DateTime? rxTime, }) { return ChannelMessage( @@ -163,6 +174,7 @@ class ChannelMessage { replyToSenderName: replyToSenderName ?? this.replyToSenderName, replyToText: replyToText ?? this.replyToText, reactions: reactions ?? this.reactions, + reactionSenders: reactionSenders ?? this.reactionSenders, rxTime: rxTime ?? this.rxTime, ); } diff --git a/lib/models/message.dart b/lib/models/message.dart index e91fb56..37e0964 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -31,6 +31,14 @@ class Message { final int? pathLength; final Uint8List pathBytes; final Map reactions; + + /// Emoji to the list of sender names who reacted with it (#383). + /// + /// Additive over [reactions]: the count map is still written and read so an + /// older build degrades to "counts work, names missing" rather than failing + /// to load. Reactions recorded before this field existed appear in + /// [reactions] with no matching entry here. + final Map> reactionSenders; final Map reactionStatuses; final Uint8List fourByteRoomContactKey; @@ -62,6 +70,7 @@ class Message { Uint8List? pathBytes, Uint8List? fourByteRoomContactKey, Map? reactions, + Map>? reactionSenders, Map? reactionStatuses, this.rxTime, }) : messageId = @@ -70,6 +79,7 @@ class Message { pathBytes = pathBytes ?? Uint8List(0), fourByteRoomContactKey = fourByteRoomContactKey ?? Uint8List(0), reactions = reactions ?? {}, + reactionSenders = reactionSenders ?? {}, reactionStatuses = reactionStatuses ?? {}; String get senderKeyHex => pubKeyToHex(senderKey); @@ -91,6 +101,7 @@ class Message { MessageTranslationStatus? translationStatus, Object? translationModelId = _unset, Map? reactions, + Map>? reactionSenders, Map? reactionStatuses, Uint8List? fourByteRoomContactKey, DateTime? rxTime, @@ -125,6 +136,7 @@ class Message { pathLength: pathLength ?? this.pathLength, pathBytes: pathBytes ?? this.pathBytes, reactions: reactions ?? this.reactions, + reactionSenders: reactionSenders ?? this.reactionSenders, reactionStatuses: reactionStatuses ?? this.reactionStatuses, fourByteRoomContactKey: fourByteRoomContactKey ?? this.fourByteRoomContactKey, diff --git a/lib/storage/channel_message_store.dart b/lib/storage/channel_message_store.dart index b80016f..8077d23 100644 --- a/lib/storage/channel_message_store.dart +++ b/lib/storage/channel_message_store.dart @@ -4,6 +4,7 @@ import 'package:meshcore_open/utils/app_logger.dart'; import '../models/channel_message.dart'; import '../models/translation_support.dart'; +import '../helpers/reaction_helper.dart'; import '../helpers/smaz.dart'; import 'drift/blob_store.dart'; @@ -267,6 +268,7 @@ class ChannelMessageStore { 'replyToSenderName': msg.replyToSenderName, 'replyToText': msg.replyToText, 'reactions': msg.reactions, + 'reactionSenders': msg.reactionSenders, }; } @@ -317,6 +319,9 @@ class ChannelMessageStore { (key, value) => MapEntry(key, value as int), ) ?? {}, + reactionSenders: ReactionHelper.reactionSendersFromJson( + json['reactionSenders'], + ), ); } diff --git a/lib/storage/message_store.dart b/lib/storage/message_store.dart index 37b0575..4ad4c2b 100644 --- a/lib/storage/message_store.dart +++ b/lib/storage/message_store.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:typed_data'; import '../models/message.dart'; import '../models/translation_support.dart'; +import '../helpers/reaction_helper.dart'; import '../helpers/smaz.dart'; import '../utils/app_logger.dart'; import 'drift/blob_store.dart'; @@ -168,6 +169,7 @@ class MessageStore { ? base64Encode(msg.pathBytes) : null, 'reactions': msg.reactions, + 'reactionSenders': msg.reactionSenders, 'reactionStatuses': msg.reactionStatuses.map( (key, value) => MapEntry(key, value.index), ), @@ -216,6 +218,9 @@ class MessageStore { (key, value) => MapEntry(key, value as int), ) ?? {}, + reactionSenders: ReactionHelper.reactionSendersFromJson( + json['reactionSenders'], + ), reactionStatuses: (json['reactionStatuses'] as Map?)?.map( (key, value) => MapEntry(key, MessageStatus.values[value as int]), diff --git a/test/helpers/pending_reactions_test.dart b/test/helpers/pending_reactions_test.dart index c3da6ef..4f57340 100644 --- a/test/helpers/pending_reactions_test.dart +++ b/test/helpers/pending_reactions_test.dart @@ -20,12 +20,17 @@ void main() { pending.add('channel:0', _info('dyps6yf0'), 'Node', now); final applied = []; - pending.retry('channel:0', (info) { + final reactors = []; + pending.retry('channel:0', (info, reactingSender) { applied.add(info.targetHash); + reactors.add(reactingSender); return true; }, now.add(const Duration(seconds: 5))); expect(applied, ['dyps6yf0']); + expect(reactors, [ + 'Node', + ], reason: 'the queued reactor is passed through'); expect(pending.length, 0); }); @@ -35,7 +40,7 @@ void main() { pending.retry( 'channel:0', - (_) => false, + (_, _) => false, now.add(const Duration(seconds: 5)), ); @@ -47,7 +52,7 @@ void main() { pending.add('channel:0', _info('dyps6yf0'), 'Node', now); var called = false; - pending.retry('channel:1', (_) { + pending.retry('channel:1', (_, _) { called = true; return true; }, now); @@ -90,7 +95,7 @@ void main() { expect(pending.length, PendingReactions.maxEntries); final seen = []; - pending.retry('channel:0', (info) { + pending.retry('channel:0', (info, _) { seen.add(info.targetHash); return true; }, now.add(const Duration(minutes: 1))); diff --git a/test/helpers/reaction_dialect_test.dart b/test/helpers/reaction_dialect_test.dart index 06aa5e1..5e53a5c 100644 --- a/test/helpers/reaction_dialect_test.dart +++ b/test/helpers/reaction_dialect_test.dart @@ -7,31 +7,43 @@ class _Msg { final String senderName; final String text; Map reactions = {}; + Map> reactionSenders = {}; _Msg(this.timestampSecs, this.senderName, this.text); } /// Runs applyReaction over [messages] and reports the match plus the resulting -/// reaction map of whichever message was updated. -({bool matched, Map? reactions}) _apply( - List<_Msg> messages, - ReactionInfo info, -) { - Map? updated; +/// count and sender maps of whichever message was updated. +({ + bool matched, + Map? reactions, + Map>? senders, +}) +_apply(List<_Msg> messages, ReactionInfo info, {String reactingSender = 'R'}) { + Map? updatedReactions; + Map>? updatedSenders; final matched = ReactionHelper.applyReaction<_Msg>( messages: messages, reactionInfo: info, + reactingSender: reactingSender, getTimestampSecs: (m) => m.timestampSecs, getSenderName: (m) => m.senderName, getMessageText: (m) => m.text, getReactions: (m) => m.reactions, + getReactionSenders: (m) => m.reactionSenders, shouldSkip: (_) => false, - updateMessage: (i, reactions) { + updateMessage: (i, reactions, senders) { messages[i].reactions = reactions; - updated = reactions; + messages[i].reactionSenders = senders; + updatedReactions = reactions; + updatedSenders = senders; }, ); - return (matched: matched, reactions: updated); + return ( + matched: matched, + reactions: updatedReactions, + senders: updatedSenders, + ); } void main() { @@ -124,7 +136,7 @@ void main() { expect(_apply(messages, info).matched, isFalse); }); - test('counts accumulate for the same emoji', () { + test('two different reactors count as 2 and both names are kept', () { final messages = [_Msg(capturedTs, capturedSender, capturedText)]; final info = ReactionInfo.pocketMesh( PocketMeshReaction.parse( @@ -133,10 +145,52 @@ void main() { )!, ); - _apply(messages, info); - final second = _apply(messages, info); + _apply(messages, info, reactingSender: 'Alice'); + final second = _apply(messages, info, reactingSender: 'Bob'); expect(second.reactions, {'\u{1F44D}': 2}); + expect(second.senders, { + '\u{1F44D}': ['Alice', 'Bob'], + }); + }); + + test('the same reactor with the same emoji does not double-count', () { + final messages = [_Msg(capturedTs, capturedSender, capturedText)]; + final info = ReactionInfo.pocketMesh( + PocketMeshReaction.parse( + '\u{1F44D}@[$capturedSender]\n$capturedHash', + isDm: false, + )!, + ); + + final first = _apply(messages, info, reactingSender: 'Alice'); + expect(first.reactions, {'\u{1F44D}': 1}); + + // Same reactor again: still matched, but no change to count or senders. + final again = _apply(messages, info, reactingSender: 'Alice'); + expect(again.matched, isTrue); + expect(messages.single.reactions, {'\u{1F44D}': 1}); + expect(messages.single.reactionSenders, { + '\u{1F44D}': ['Alice'], + }); + }); + + test('a pre-#383 count with no sender list is preserved, not reset', () { + final messages = [_Msg(capturedTs, capturedSender, capturedText)]; + // Simulate a message loaded from an old store: a count, no sender names. + messages.single.reactions = {'\u{1F44D}': 3}; + + final result = _apply(messages, _pm(capturedSender, capturedHash)); + + // The old three are kept and the new reactor adds one. + expect(result.reactions, {'\u{1F44D}': 4}); + expect(result.senders, { + '\u{1F44D}': ['R'], + }); }); }); } + +ReactionInfo _pm(String sender, String hash) => ReactionInfo.pocketMesh( + PocketMeshReaction.parse('\u{1F44D}@[$sender]\n$hash', isDm: false)!, +); diff --git a/test/helpers/reaction_senders_json_test.dart b/test/helpers/reaction_senders_json_test.dart new file mode 100644 index 0000000..78ef03e --- /dev/null +++ b/test/helpers/reaction_senders_json_test.dart @@ -0,0 +1,44 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/helpers/reaction_helper.dart'; + +void main() { + group('ReactionHelper.reactionSendersFromJson (#383)', () { + test('round-trips a real sender map through JSON', () { + final original = >{ + '\u{1F44D}': ['Alice', 'Bob'], + '\u{2764}\u{FE0F}': ['Carol'], + }; + final decoded = ReactionHelper.reactionSendersFromJson( + jsonDecode(jsonEncode(original)), + ); + expect(decoded, original); + }); + + test('an old store with no reactionSenders key degrades to empty', () { + // This is the backward-compat guarantee: a record written before #383 + // has no such key, so the field is absent from the decoded JSON. + expect(ReactionHelper.reactionSendersFromJson(null), isEmpty); + }); + + test('a non-map value is ignored rather than thrown', () { + // A single bad entry must never fail the whole message-list load (#355). + expect(ReactionHelper.reactionSendersFromJson('garbage'), isEmpty); + expect(ReactionHelper.reactionSendersFromJson(42), isEmpty); + expect(ReactionHelper.reactionSendersFromJson(['a', 'b']), isEmpty); + }); + + test('malformed entries within a map are skipped, valid ones kept', () { + final decoded = ReactionHelper.reactionSendersFromJson({ + '\u{1F44D}': ['Alice', 42, null, 'Bob'], // non-strings dropped + '\u{1F525}': 'not a list', // whole entry dropped + '\u{1F389}': ['Dave'], + }); + expect(decoded, { + '\u{1F44D}': ['Alice', 'Bob'], + '\u{1F389}': ['Dave'], + }); + }); + }); +} From d9afaf45fb781f697e20db954cd7d28722cfbdd3 Mon Sep 17 00:00:00 2001 From: Strycher Date: Mon, 27 Jul 2026 02:05:24 -0400 Subject: [PATCH 08/62] fix(#383): don't attribute room reactions to the room's own name Adversarial review caught a data-loss defect in the reactor-name fallback. For a reaction in a room from an author not in the local contacts, _resolveContactSenderName returns null and the chain fell through to reactionContact?.name. But reactionContact is the room server, not the reactor, so every unknown-author reaction was attributed to the room's display name. Two distinct unknown authors then both resolved to that one name, so applyReaction's per-reactor dedup treated the second as a duplicate and dropped it, count and all. Fall back to the per-author hex (from the frame's own fourByteRoomContact Key) instead, which is unique per reactor. In a true 1:1 that hex is empty and the contact genuinely is the reactor, so its name stays the correct fallback. Co-Authored-By: Claude Opus 4.8 --- lib/connector/meshcore_connector.dart | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 63f33e2..e87e6af 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -6563,10 +6563,18 @@ class MeshCoreConnector extends ChangeNotifier { final reactingAuthorHex = message.fourByteRoomContactKey .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(); + // In a room, reactionContact is the room server, NOT the reactor, so its + // name must never be the fallback: it would attribute every unknown-author + // reaction to the room and then dedup distinct authors into one, dropping + // reactions (#383). Fall back to the per-author hex, which is unique per + // reactor. In a true 1:1 the author hex is empty and the contact IS the + // reactor, so its name is correct. + final fallbackName = (isRoomReaction && reactingAuthorHex.isNotEmpty) + ? reactingAuthorHex + : (reactionContact?.name ?? pubKeyHex); final reactorName = _resolveContactSenderName(message, reactionContact, isRoomReaction) ?? - reactionContact?.name ?? - (reactingAuthorHex.isNotEmpty ? reactingAuthorHex : pubKeyHex); + fallbackName; final reactionIdentifier = '${reactionInfo.targetHash}_${reactionInfo.emoji}_$reactingAuthorHex'; From 21f38b288871dc20f3a46a8852f675a4a5252d78 Mon Sep 17 00:00:00 2001 From: Strycher Date: Sat, 18 Jul 2026 02:06:01 -0400 Subject: [PATCH 09/62] fix(#276): drop stale "Notifications" from App Settings subtitle Notification settings moved to the Messages category, but the App Settings tile subtitle still advertised them. Retitled across all 18 locales to match what the pane actually renders (appearance, translation, battery, map, Cyr2Lat). Co-Authored-By: Claude Opus 4.8 --- lib/l10n/app_bg.arb | 2 +- lib/l10n/app_de.arb | 2 +- lib/l10n/app_en.arb | 2 +- lib/l10n/app_es.arb | 2 +- lib/l10n/app_fr.arb | 2 +- lib/l10n/app_hu.arb | 2 +- lib/l10n/app_it.arb | 2 +- lib/l10n/app_ja.arb | 2 +- lib/l10n/app_ko.arb | 2 +- lib/l10n/app_localizations.dart | 2 +- lib/l10n/app_localizations_bg.dart | 2 +- lib/l10n/app_localizations_de.dart | 2 +- lib/l10n/app_localizations_en.dart | 2 +- lib/l10n/app_localizations_es.dart | 2 +- lib/l10n/app_localizations_fr.dart | 2 +- lib/l10n/app_localizations_hu.dart | 2 +- lib/l10n/app_localizations_it.dart | 2 +- lib/l10n/app_localizations_ja.dart | 2 +- lib/l10n/app_localizations_ko.dart | 2 +- lib/l10n/app_localizations_nl.dart | 2 +- lib/l10n/app_localizations_pl.dart | 2 +- lib/l10n/app_localizations_pt.dart | 2 +- lib/l10n/app_localizations_ru.dart | 2 +- lib/l10n/app_localizations_sk.dart | 2 +- lib/l10n/app_localizations_sl.dart | 2 +- lib/l10n/app_localizations_sv.dart | 2 +- lib/l10n/app_localizations_uk.dart | 2 +- lib/l10n/app_localizations_zh.dart | 2 +- lib/l10n/app_nl.arb | 2 +- lib/l10n/app_pl.arb | 2 +- lib/l10n/app_pt.arb | 2 +- lib/l10n/app_ru.arb | 2 +- lib/l10n/app_sk.arb | 2 +- lib/l10n/app_sl.arb | 2 +- lib/l10n/app_sv.arb | 2 +- lib/l10n/app_uk.arb | 2 +- lib/l10n/app_zh.arb | 2 +- 37 files changed, 37 insertions(+), 37 deletions(-) diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index d8042f5..69f5e5f 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -84,7 +84,7 @@ "settings_title": "Настройки", "settings_deviceInfo": "Информация за устройството", "settings_appSettings": "Настройки на приложението", - "settings_appSettingsSubtitle": "Уведомления, съобщения и предпочитания за карта", + "settings_appSettingsSubtitle": "Външен вид, превод и настройки на картата", "settings_nodeSettings": "Настройки на възела", "settings_nodeName": "Име на възела", "settings_nodeNameNotSet": "Не е зададено", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index aa65bef..ded3337 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -84,7 +84,7 @@ "settings_title": "Einstellungen", "settings_deviceInfo": "Geräteinformationen", "settings_appSettings": "App-Einstellungen", - "settings_appSettingsSubtitle": "Benachrichtigungen, Messaging und Kartenwahrnehmung", + "settings_appSettingsSubtitle": "Erscheinungsbild, Übersetzung und Karteneinstellungen", "settings_nodeSettings": "Knoten-Einstellungen", "settings_nodeName": "Knotenname", "settings_nodeNameNotSet": "Nicht festgelegt", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 6b30e10..287539e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -142,7 +142,7 @@ "settings_title": "Settings", "settings_deviceInfo": "Device Info", "settings_appSettings": "App Settings", - "settings_appSettingsSubtitle": "Notifications, messaging, and map preferences", + "settings_appSettingsSubtitle": "Appearance, translation, and map preferences", "settings_messageSettings": "Message Settings", "@settings_messageSettings": { "description": "Settings category title for notification and message-handling preferences" diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index bf16f93..85e6bf1 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -84,7 +84,7 @@ "settings_title": "Configuración", "settings_deviceInfo": "Información del dispositivo", "settings_appSettings": "Configuración de la App", - "settings_appSettingsSubtitle": "Notificaciones, mensajes y preferencias de mapa", + "settings_appSettingsSubtitle": "Apariencia, traducción y preferencias de mapa", "settings_nodeSettings": "Configuración del Nodo", "settings_nodeName": "Nombre del nodo", "settings_nodeNameNotSet": "No está configurado", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 5fc1ef9..72c5506 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -84,7 +84,7 @@ "settings_title": "Paramètres", "settings_deviceInfo": "Informations du périphérique", "settings_appSettings": "Paramètres de l'application", - "settings_appSettingsSubtitle": "Notifications, messagerie et préférences de carte", + "settings_appSettingsSubtitle": "Apparence, traduction et préférences de carte", "settings_nodeSettings": "Paramètres du nœud", "settings_nodeName": "Nom du nœud", "settings_nodeNameNotSet": "Non défini", diff --git a/lib/l10n/app_hu.arb b/lib/l10n/app_hu.arb index 4ba092c..397ec99 100644 --- a/lib/l10n/app_hu.arb +++ b/lib/l10n/app_hu.arb @@ -141,7 +141,7 @@ "settings_title": "Beállítások", "settings_deviceInfo": "A készülék információi", "settings_appSettings": "Alkalmazási beállítások", - "settings_appSettingsSubtitle": "Értesítések, üzenetküldés és térképi beállítások", + "settings_appSettingsSubtitle": "Megjelenés, fordítás és térképbeállítások", "settings_nodeSettings": "Műközép beállítások", "settings_nodeName": "Vonal neve", "settings_nodeNameNotSet": "Nem megállapított", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index c0f5d6b..7b2d226 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -84,7 +84,7 @@ "settings_title": "Impostazioni", "settings_deviceInfo": "Informazioni Dispositivo", "settings_appSettings": "Impostazioni App", - "settings_appSettingsSubtitle": "Notifiche, messaggi e preferenze della mappa", + "settings_appSettingsSubtitle": "Aspetto, traduzione e preferenze mappa", "settings_nodeSettings": "Impostazioni Nodo", "settings_nodeName": "Nome Nodo", "settings_nodeNameNotSet": "Non impostato", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 70d54f1..f4650d9 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -141,7 +141,7 @@ "settings_title": "設定", "settings_deviceInfo": "デバイス情報", "settings_appSettings": "アプリ設定", - "settings_appSettingsSubtitle": "通知、メッセージング、および地図の表示設定", + "settings_appSettingsSubtitle": "外観、翻訳、地図の設定", "settings_nodeSettings": "ノード設定", "settings_nodeName": "ノード名", "settings_nodeNameNotSet": "設定されていない", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 1eb840e..34984c9 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -141,7 +141,7 @@ "settings_title": "설정", "settings_deviceInfo": "장치 정보", "settings_appSettings": "앱 설정", - "settings_appSettingsSubtitle": "알림, 메시징, 지도 설정", + "settings_appSettingsSubtitle": "모양, 번역 및 지도 환경설정", "settings_nodeSettings": "노드 설정", "settings_nodeName": "노드 이름", "settings_nodeNameNotSet": "설정되지 않음", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 491bb49..f39a9d4 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -691,7 +691,7 @@ abstract class AppLocalizations { /// No description provided for @settings_appSettingsSubtitle. /// /// In en, this message translates to: - /// **'Notifications, messaging, and map preferences'** + /// **'Appearance, translation, and map preferences'** String get settings_appSettingsSubtitle; /// Settings category title for notification and message-handling preferences diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 7c85ff9..82ee646 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -317,7 +317,7 @@ class AppLocalizationsBg extends AppLocalizations { @override String get settings_appSettingsSubtitle => - 'Уведомления, съобщения и предпочитания за карта'; + 'Външен вид, превод и настройки на картата'; @override String get settings_messageSettings => 'Message Settings'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index ce96966..86d780e 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -320,7 +320,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settings_appSettingsSubtitle => - 'Benachrichtigungen, Messaging und Kartenwahrnehmung'; + 'Erscheinungsbild, Übersetzung und Karteneinstellungen'; @override String get settings_messageSettings => 'Message Settings'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 666c336..5344a2a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -314,7 +314,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settings_appSettingsSubtitle => - 'Notifications, messaging, and map preferences'; + 'Appearance, translation, and map preferences'; @override String get settings_messageSettings => 'Message Settings'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index b6d719b..e3c2efd 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -318,7 +318,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settings_appSettingsSubtitle => - 'Notificaciones, mensajes y preferencias de mapa'; + 'Apariencia, traducción y preferencias de mapa'; @override String get settings_messageSettings => 'Message Settings'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index c204f9e..6d46e56 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -320,7 +320,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get settings_appSettingsSubtitle => - 'Notifications, messagerie et préférences de carte'; + 'Apparence, traduction et préférences de carte'; @override String get settings_messageSettings => 'Message Settings'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index 9102572..179a37d 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -317,7 +317,7 @@ class AppLocalizationsHu extends AppLocalizations { @override String get settings_appSettingsSubtitle => - 'Értesítések, üzenetküldés és térképi beállítások'; + 'Megjelenés, fordítás és térképbeállítások'; @override String get settings_messageSettings => 'Message Settings'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 0629ef0..ce6b07c 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -320,7 +320,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String get settings_appSettingsSubtitle => - 'Notifiche, messaggi e preferenze della mappa'; + 'Aspetto, traduzione e preferenze mappa'; @override String get settings_messageSettings => 'Message Settings'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index ecc9140..8bfd63c 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -306,7 +306,7 @@ class AppLocalizationsJa extends AppLocalizations { String get settings_appSettings => 'アプリ設定'; @override - String get settings_appSettingsSubtitle => '通知、メッセージング、および地図の表示設定'; + String get settings_appSettingsSubtitle => '外観、翻訳、地図の設定'; @override String get settings_messageSettings => 'Message Settings'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 455161b..3b9adfc 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -305,7 +305,7 @@ class AppLocalizationsKo extends AppLocalizations { String get settings_appSettings => '앱 설정'; @override - String get settings_appSettingsSubtitle => '알림, 메시징, 지도 설정'; + String get settings_appSettingsSubtitle => '모양, 번역 및 지도 환경설정'; @override String get settings_messageSettings => 'Message Settings'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 6d10c20..dc1c51a 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -316,7 +316,7 @@ class AppLocalizationsNl extends AppLocalizations { @override String get settings_appSettingsSubtitle => - 'Notificaties, berichten en kaartinstellingen'; + 'Weergave, vertaling en kaartvoorkeuren'; @override String get settings_messageSettings => 'Message Settings'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 095dfe9..d6f55f8 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -321,7 +321,7 @@ class AppLocalizationsPl extends AppLocalizations { @override String get settings_appSettingsSubtitle => - 'Powiadomienia, wiadomości i preferencje mapy'; + 'Wygląd, tłumaczenie i ustawienia mapy'; @override String get settings_messageSettings => 'Message Settings'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 46f00b4..da82f6c 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -319,7 +319,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get settings_appSettingsSubtitle => - 'Notificações, mensagens e preferências de mapa'; + 'Aparência, tradução e preferências de mapa'; @override String get settings_messageSettings => 'Message Settings'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 9f6f794..67b868d 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -319,7 +319,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get settings_appSettingsSubtitle => - 'Уведомления, сообщения и настройки карты'; + 'Внешний вид, перевод и настройки карты'; @override String get settings_messageSettings => 'Message Settings'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index f2a340e..cb58b24 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -318,7 +318,7 @@ class AppLocalizationsSk extends AppLocalizations { @override String get settings_appSettingsSubtitle => - 'Upozornenia, správy a nastavenia mapy'; + 'Vzhľad, preklad a nastavenia mapy'; @override String get settings_messageSettings => 'Message Settings'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 24fcf5b..abde10d 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -316,7 +316,7 @@ class AppLocalizationsSl extends AppLocalizations { @override String get settings_appSettingsSubtitle => - 'Obveščanja, sporoščanje in zemljevidi.'; + 'Videz, prevajanje in nastavitve zemljevida'; @override String get settings_messageSettings => 'Message Settings'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index b7bbe33..78c3bb4 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -315,7 +315,7 @@ class AppLocalizationsSv extends AppLocalizations { @override String get settings_appSettingsSubtitle => - 'Meddelanden, notiser och kartinställningar'; + 'Utseende, översättning och kartinställningar'; @override String get settings_messageSettings => 'Message Settings'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index f16fd52..dcf9578 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -318,7 +318,7 @@ class AppLocalizationsUk extends AppLocalizations { @override String get settings_appSettingsSubtitle => - 'Сповіщення, повідомлення та налаштування карти'; + 'Зовнішній вигляд, переклад і налаштування карти'; @override String get settings_messageSettings => 'Message Settings'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index b7e8ae8..96de896 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -303,7 +303,7 @@ class AppLocalizationsZh extends AppLocalizations { String get settings_appSettings => '应用设置'; @override - String get settings_appSettingsSubtitle => '通知、消息和地图偏好'; + String get settings_appSettingsSubtitle => '外观、翻译和地图设置'; @override String get settings_messageSettings => 'Message Settings'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 7749e09..5e115be 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -84,7 +84,7 @@ "settings_title": "Instellingen", "settings_deviceInfo": "Apparateninformatie", "settings_appSettings": "App Instellingen", - "settings_appSettingsSubtitle": "Notificaties, berichten en kaartinstellingen", + "settings_appSettingsSubtitle": "Weergave, vertaling en kaartvoorkeuren", "settings_nodeSettings": "Node Instellingen", "settings_nodeName": "Nodenaam", "settings_nodeNameNotSet": "Niet ingesteld", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 69e9bff..4e0dafc 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -84,7 +84,7 @@ "settings_title": "Ustawienia", "settings_deviceInfo": "Informacje o urządzeniu", "settings_appSettings": "Ustawienia aplikacji", - "settings_appSettingsSubtitle": "Powiadomienia, wiadomości i preferencje mapy", + "settings_appSettingsSubtitle": "Wygląd, tłumaczenie i ustawienia mapy", "settings_nodeSettings": "Ustawienia węzła", "settings_nodeName": "Nazwa węzła", "settings_nodeNameNotSet": "Nie ustawione", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index c598a54..32307e7 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -84,7 +84,7 @@ "settings_title": "Configurações", "settings_deviceInfo": "Informações do Dispositivo", "settings_appSettings": "Configurações do App", - "settings_appSettingsSubtitle": "Notificações, mensagens e preferências de mapa", + "settings_appSettingsSubtitle": "Aparência, tradução e preferências de mapa", "settings_nodeSettings": "Configurações do Nó", "settings_nodeName": "Nome do Nó", "settings_nodeNameNotSet": "Não definido", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 82d6565..8b63b47 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -57,7 +57,7 @@ "settings_title": "Настройки", "settings_deviceInfo": "Информация об устройстве", "settings_appSettings": "Настройки приложения", - "settings_appSettingsSubtitle": "Уведомления, сообщения и настройки карты", + "settings_appSettingsSubtitle": "Внешний вид, перевод и настройки карты", "settings_nodeSettings": "Настройки ноды", "settings_nodeName": "Имя ноды", "settings_nodeNameNotSet": "Не установлено", diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index f12b4c4..9e585e6 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -84,7 +84,7 @@ "settings_title": "Nastavenia", "settings_deviceInfo": "Informácie o zariadení", "settings_appSettings": "Nastavenia aplikácie", - "settings_appSettingsSubtitle": "Upozornenia, správy a nastavenia mapy", + "settings_appSettingsSubtitle": "Vzhľad, preklad a nastavenia mapy", "settings_nodeSettings": "Nastavenia uzla", "settings_nodeName": "Názov uzla", "settings_nodeNameNotSet": "Nezriadené", diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 4c47018..533bc9e 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -84,7 +84,7 @@ "settings_title": "Nastavitve", "settings_deviceInfo": "Informacije o napravei", "settings_appSettings": "Nastavitve aplikacije", - "settings_appSettingsSubtitle": "Obveščanja, sporoščanje in zemljevidi.", + "settings_appSettingsSubtitle": "Videz, prevajanje in nastavitve zemljevida", "settings_nodeSettings": "Nastavitev časa", "settings_nodeName": "Ime node-a", "settings_nodeNameNotSet": "Ni nastavljeno", diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index a3a5c21..0495908 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -84,7 +84,7 @@ "settings_title": "Inställningar", "settings_deviceInfo": "Enhetens information", "settings_appSettings": "Appinställningar", - "settings_appSettingsSubtitle": "Meddelanden, notiser och kartinställningar", + "settings_appSettingsSubtitle": "Utseende, översättning och kartinställningar", "settings_nodeSettings": "Nodinställningar", "settings_nodeName": "Nodnamn", "settings_nodeNameNotSet": "Inte angivet", diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 5572b79..c27cb52 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -85,7 +85,7 @@ "settings_title": "Налаштування", "settings_deviceInfo": "Інформація про пристрій", "settings_appSettings": "Налаштування застосунку", - "settings_appSettingsSubtitle": "Сповіщення, повідомлення та налаштування карти", + "settings_appSettingsSubtitle": "Зовнішній вигляд, переклад і налаштування карти", "settings_nodeSettings": "Налаштування вузла", "settings_nodeName": "Ім'я вузла", "settings_nodeNameNotSet": "Не встановлено", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 70b3649..9bec0b2 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -85,7 +85,7 @@ "settings_title": "设置", "settings_deviceInfo": "设备信息", "settings_appSettings": "应用设置", - "settings_appSettingsSubtitle": "通知、消息和地图偏好", + "settings_appSettingsSubtitle": "外观、翻译和地图设置", "settings_nodeSettings": "节点设置", "settings_nodeName": "节点名称", "settings_nodeNameNotSet": "未设置", From ab6d0b13b2e5bbdfd47eb0b5cb3f9c970ad4838d Mon Sep 17 00:00:00 2001 From: Strycher Date: Sat, 18 Jul 2026 02:07:51 -0400 Subject: [PATCH 10/62] refactor(#271,#272,#273): reorder settings categories - #271 Node Settings moves to the top - #272 Blocked moves below Contacts - #273 App Settings moves above Device Info New order: Node Settings, Radio Settings, Radio Stats, Privacy, Contacts, Blocked, Messages, App Settings, Device Info, Observer, Actions, Debug. Co-Authored-By: Claude Opus 4.8 --- lib/screens/settings_screen.dart | 34 ++++++++++++++++---------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index e7a030f..39cbf86 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -87,6 +87,11 @@ class _SettingsScreenState extends State { ), ); return [ + SettingsCategory( + icon: Icons.badge_outlined, + title: l10n.settings_nodeSettings, + builder: _identityPane, + ), SettingsCategory( icon: Icons.settings_input_antenna, title: l10n.settings_radioSettings, @@ -99,35 +104,36 @@ class _SettingsScreenState extends State { subtitle: l10n.radioStats_settingsSubtitle, builder: _radioStatsPane, ), - SettingsCategory( - icon: Icons.badge_outlined, - title: l10n.settings_nodeSettings, - builder: _identityPane, - ), SettingsCategory( icon: Icons.shield_outlined, title: l10n.settings_privacy, subtitle: l10n.settings_privacySubtitle, builder: _privacyPane, ), - SettingsCategory( - icon: Icons.block, - title: l10n.block_settingsTitle, - subtitle: l10n.block_settingsSubtitle, - builder: _blockedPane, - ), SettingsCategory( icon: Icons.contacts_outlined, title: l10n.contacts_title, subtitle: l10n.settings_contactSettingsSubtitle, builder: _contactsPane, ), + SettingsCategory( + icon: Icons.block, + title: l10n.block_settingsTitle, + subtitle: l10n.block_settingsSubtitle, + builder: _blockedPane, + ), SettingsCategory( icon: Icons.sms_outlined, title: l10n.settings_messageSettings, subtitle: l10n.settings_messageSettingsSubtitle, builder: _messageSettingsPane, ), + SettingsCategory( + icon: Icons.tune, + title: l10n.settings_appSettings, + subtitle: l10n.settings_appSettingsSubtitle, + builder: _appPane, + ), SettingsCategory( icon: Icons.info_outline, title: l10n.settings_deviceInfo, @@ -140,12 +146,6 @@ class _SettingsScreenState extends State { subtitle: 'WiFi · MQTT brokers · display', builder: _observerPane, ), - SettingsCategory( - icon: Icons.tune, - title: l10n.settings_appSettings, - subtitle: l10n.settings_appSettingsSubtitle, - builder: _appPane, - ), SettingsCategory( icon: Icons.bolt_outlined, title: l10n.settings_actions, From 480d6a8e270dd6b39eb6e77663425c9e4e5fe1fe Mon Sep 17 00:00:00 2001 From: Strycher Date: Sat, 18 Jul 2026 02:09:30 -0400 Subject: [PATCH 11/62] refactor(#274): move app debug logging toggle into the Debug category The App Settings pane carried a Debug card holding only the app-debug-logging switch, while the Debug category already owned the log viewers. Moved the switch next to the App debug log viewer it controls and dropped the now-empty Debug card from App Settings. Co-Authored-By: Claude Opus 4.8 --- lib/screens/settings/app_settings_view.dart | 58 ++------------------- lib/screens/settings_screen.dart | 34 ++++++++++++ 2 files changed, 37 insertions(+), 55 deletions(-) diff --git a/lib/screens/settings/app_settings_view.dart b/lib/screens/settings/app_settings_view.dart index a2a57e4..08e61b7 100644 --- a/lib/screens/settings/app_settings_view.dart +++ b/lib/screens/settings/app_settings_view.dart @@ -15,8 +15,9 @@ import '../map_cache_screen.dart'; /// Embeddable view (no Scaffold) for the App Settings shell pane. /// -/// Renders appearance, translation, battery, map, Cyr2Lat, and debug cards. -/// Notifications and message-handling settings live in [MessageSettingsView]. +/// Renders appearance, translation, battery, map, and Cyr2Lat cards. +/// Notifications and message-handling settings live in [MessageSettingsView]; +/// app debug logging lives in the Debug category on the settings screen. class AppSettingsView extends StatelessWidget { const AppSettingsView({super.key}); @@ -43,8 +44,6 @@ class AppSettingsView extends StatelessWidget { _buildMapSettingsCard(context, settingsService), const SizedBox(height: 16), _buildCyr2LatCard(context, settingsService), - const SizedBox(height: 16), - _buildDebugCard(context, settingsService), ], ); }, @@ -1355,57 +1354,6 @@ class AppSettingsView extends StatelessWidget { ), ); } - - Widget _buildDebugCard( - BuildContext context, - AppSettingsService settingsService, - ) { - return Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text( - context.l10n.appSettings_debugCard, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - ), - SwitchListTile( - secondary: const Icon(Icons.bug_report_outlined), - title: Text(context.l10n.appSettings_appDebugLogging), - subtitle: Text(context.l10n.appSettings_appDebugLoggingSubtitle), - value: settingsService.settings.appDebugLogEnabled, - onChanged: (value) async { - try { - await settingsService.setAppDebugLogEnabled(value); - } catch (_) { - if (context.mounted) { - showDismissibleSnackBar( - context, - content: const Text( - 'Could not change debug logging. Please try again.', - ), - ); - } - return; - } - if (!context.mounted) return; - showDismissibleSnackBar( - context, - content: Text( - value - ? context.l10n.appSettings_appDebugLoggingEnabled - : context.l10n.appSettings_appDebugLoggingDisabled, - ), - duration: const Duration(seconds: 2), - ); - }, - ), - ], - ), - ); - } } /// Owns the [TextEditingController] for the manual model URL field so it diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 39cbf86..890766b 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -12,6 +12,7 @@ import '../l10n/l10n.dart'; import '../models/offband_gps_status.dart'; import '../models/radio_settings.dart'; import '../services/app_debug_log_service.dart'; +import '../services/app_settings_service.dart'; import '../connector/observer_config_client.dart'; import '../helpers/snack_bar_builder.dart'; import 'settings/settings_shell.dart'; @@ -571,6 +572,7 @@ class _SettingsScreenState extends State { Widget _buildDebugCard(BuildContext context) { final l10n = context.l10n; + final settingsService = context.watch(); return Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -597,6 +599,38 @@ class _SettingsScreenState extends State { }, ), const Divider(height: 1), + SwitchListTile( + secondary: const Icon(Icons.bug_report_outlined), + title: Text(l10n.appSettings_appDebugLogging), + subtitle: Text(l10n.appSettings_appDebugLoggingSubtitle), + value: settingsService.settings.appDebugLogEnabled, + onChanged: (value) async { + try { + await settingsService.setAppDebugLogEnabled(value); + } catch (_) { + if (context.mounted) { + showDismissibleSnackBar( + context, + content: const Text( + 'Could not change debug logging. Please try again.', + ), + ); + } + return; + } + if (!context.mounted) return; + showDismissibleSnackBar( + context, + content: Text( + value + ? l10n.appSettings_appDebugLoggingEnabled + : l10n.appSettings_appDebugLoggingDisabled, + ), + duration: const Duration(seconds: 2), + ); + }, + ), + const Divider(height: 1), ListTile( leading: const Icon(Icons.code_outlined), title: Text(l10n.settings_appDebugLog), From 8b7f0a3d2c660f2b80aa9ecbd6d41bc005a7b373 Mon Sep 17 00:00:00 2001 From: Strycher Date: Sat, 18 Jul 2026 02:13:40 -0400 Subject: [PATCH 12/62] feat(#269): keep-screen-awake setting Adds a KeepScreenAwake controller that holds a wakelock_plus lock while the setting is on and the app is foregrounded, releasing on background, on toggle off, and on dispose. Toggle lives in App Settings > Battery (power tradeoff), defaults OFF, persisted as keep_screen_awake. Strings added for all 18 locales. Co-Authored-By: Claude Opus 4.8 --- lib/l10n/app_bg.arb | 2 + lib/l10n/app_de.arb | 2 + lib/l10n/app_en.arb | 2 + lib/l10n/app_es.arb | 2 + lib/l10n/app_fr.arb | 2 + lib/l10n/app_hu.arb | 2 + lib/l10n/app_it.arb | 2 + lib/l10n/app_ja.arb | 2 + lib/l10n/app_ko.arb | 2 + lib/l10n/app_localizations.dart | 12 +++ lib/l10n/app_localizations_bg.dart | 7 ++ lib/l10n/app_localizations_de.dart | 7 ++ lib/l10n/app_localizations_en.dart | 7 ++ lib/l10n/app_localizations_es.dart | 7 ++ lib/l10n/app_localizations_fr.dart | 7 ++ lib/l10n/app_localizations_hu.dart | 7 ++ lib/l10n/app_localizations_it.dart | 7 ++ lib/l10n/app_localizations_ja.dart | 7 ++ lib/l10n/app_localizations_ko.dart | 7 ++ lib/l10n/app_localizations_nl.dart | 7 ++ lib/l10n/app_localizations_pl.dart | 7 ++ lib/l10n/app_localizations_pt.dart | 7 ++ lib/l10n/app_localizations_ru.dart | 7 ++ lib/l10n/app_localizations_sk.dart | 7 ++ lib/l10n/app_localizations_sl.dart | 7 ++ lib/l10n/app_localizations_sv.dart | 7 ++ lib/l10n/app_localizations_uk.dart | 7 ++ lib/l10n/app_localizations_zh.dart | 6 ++ lib/l10n/app_nl.arb | 2 + lib/l10n/app_pl.arb | 2 + lib/l10n/app_pt.arb | 2 + lib/l10n/app_ru.arb | 2 + lib/l10n/app_sk.arb | 2 + lib/l10n/app_sl.arb | 2 + lib/l10n/app_sv.arb | 2 + lib/l10n/app_uk.arb | 2 + lib/l10n/app_zh.arb | 2 + lib/main.dart | 11 ++- lib/models/app_settings.dart | 6 ++ lib/screens/settings/app_settings_view.dart | 14 ++++ lib/services/app_settings_service.dart | 4 + lib/widgets/keep_screen_awake.dart | 80 +++++++++++++++++++ macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.yaml | 1 + 44 files changed, 287 insertions(+), 4 deletions(-) create mode 100644 lib/widgets/keep_screen_awake.dart diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index 69f5e5f..16021f0 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -216,6 +216,8 @@ "appSettings_batteryNmc": "18650 NMC (3.0-4.2V)", "appSettings_batteryLifepo4": "Литиево желязо фосфат (2.6-3.65V)", "appSettings_batteryLipo": "Литиев полимер (3.0-4.2V)", + "appSettings_keepScreenAwake": "Поддържане на екрана включен", + "appSettings_keepScreenAwakeSubtitle": "Екранът няма да изгасва, докато приложението е отворено. Изразходва повече батерия.", "appSettings_mapDisplay": "Карта за показване", "appSettings_showRepeaters": "Показване на повторители", "appSettings_showRepeatersSubtitle": "Показване на възпроизвеждащи се възли на картата", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index ded3337..2d8fdfe 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -216,6 +216,8 @@ "appSettings_batteryNmc": "18650 NMC (3,0–4,2 V)", "appSettings_batteryLifepo4": "LiFePO4 (2,6–3,65 V)", "appSettings_batteryLipo": "LiPo (3,0–4,2V)", + "appSettings_keepScreenAwake": "Bildschirm anlassen", + "appSettings_keepScreenAwakeSubtitle": "Verhindert, dass sich das Display ausschaltet, während die App geöffnet ist. Verbraucht mehr Akku.", "appSettings_mapDisplay": "Kartendarstellung", "appSettings_showRepeaters": "Zeige Repeater", "appSettings_showRepeatersSubtitle": "Zeige Repeater-Knoten auf der Karte an", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 287539e..078d7c0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -350,6 +350,8 @@ "appSettings_batteryNmc": "18650 NMC (3.0-4.2V)", "appSettings_batteryLifepo4": "LiFePO4 (2.6-3.65V)", "appSettings_batteryLipo": "LiPo (3.0-4.2V)", + "appSettings_keepScreenAwake": "Keep screen awake", + "appSettings_keepScreenAwakeSubtitle": "Prevent the display from sleeping while the app is open. Uses more battery.", "appSettings_mapDisplay": "Map Display", "appSettings_showRepeaters": "Show Repeaters", "appSettings_showRepeatersSubtitle": "Display repeater nodes on the map", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 85e6bf1..06c8c6a 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -216,6 +216,8 @@ "appSettings_batteryNmc": "18650 NMC (3,0-4,2 V)", "appSettings_batteryLifepo4": "LiFePO4 (2.6-3.65 V)", "appSettings_batteryLipo": "LiPo (3.0-4.2V)", + "appSettings_keepScreenAwake": "Mantener la pantalla encendida", + "appSettings_keepScreenAwakeSubtitle": "Evita que la pantalla se apague mientras la aplicación está abierta. Consume más batería.", "appSettings_mapDisplay": "Visualización del Mapa", "appSettings_showRepeaters": "Mostrar Repetidores", "appSettings_showRepeatersSubtitle": "Mostrar nodos de repetidor en el mapa", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 72c5506..50d5a23 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -216,6 +216,8 @@ "appSettings_batteryNmc": "18650 NMC (3,0-4,2V)", "appSettings_batteryLifepo4": "LiFePO4 (2,6-3,65V)", "appSettings_batteryLipo": "LiPo (3,0-4,2V)", + "appSettings_keepScreenAwake": "Garder l'écran allumé", + "appSettings_keepScreenAwakeSubtitle": "Empêche l'écran de s'éteindre lorsque l'application est ouverte. Consomme plus de batterie.", "appSettings_mapDisplay": "Affichage de la carte", "appSettings_showRepeaters": "Afficher les répéteurs", "appSettings_showRepeatersSubtitle": "Afficher les nœuds répéteurs sur la carte", diff --git a/lib/l10n/app_hu.arb b/lib/l10n/app_hu.arb index 397ec99..1e09f62 100644 --- a/lib/l10n/app_hu.arb +++ b/lib/l10n/app_hu.arb @@ -308,6 +308,8 @@ "appSettings_batteryNmc": "18650 NMC (3,0-4,2 V)", "appSettings_batteryLifepo4": "LiFePO4 (2,6–3,65 V)", "appSettings_batteryLipo": "LiPo (3,0-4,2 V)", + "appSettings_keepScreenAwake": "Képernyő ébren tartása", + "appSettings_keepScreenAwakeSubtitle": "Megakadályozza a képernyő elalvását, amíg az alkalmazás nyitva van. Több akkumulátort fogyaszt.", "appSettings_mapDisplay": "Térkép megjelenítése", "appSettings_showRepeaters": "Megismétlés", "appSettings_showRepeatersSubtitle": "A térképen megjelenítsük a repeater-eket.", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 7b2d226..ebe1e3f 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -216,6 +216,8 @@ "appSettings_batteryNmc": "18650 NMC (3,0-4,2V)", "appSettings_batteryLifepo4": "LiFePO4 (2,6-3,65V)", "appSettings_batteryLipo": "LiPo (3,0-4,2V)", + "appSettings_keepScreenAwake": "Mantieni lo schermo acceso", + "appSettings_keepScreenAwakeSubtitle": "Impedisce allo schermo di spegnersi mentre l'app è aperta. Consuma più batteria.", "appSettings_mapDisplay": "Visualizzazione Mappa", "appSettings_showRepeaters": "Mostra Ripetitori", "appSettings_showRepeatersSubtitle": "Mostra i nodi ripetitori sulla mappa", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index f4650d9..53644cf 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -308,6 +308,8 @@ "appSettings_batteryNmc": "18650型 NMC (3.0-4.2V)", "appSettings_batteryLifepo4": "LiFePO4 (2.6-3.65V)", "appSettings_batteryLipo": "LiPo (3.0-4.2V)", + "appSettings_keepScreenAwake": "画面をスリープさせない", + "appSettings_keepScreenAwakeSubtitle": "アプリを開いている間、画面が消灯しないようにします。バッテリーの消費が増えます。", "appSettings_mapDisplay": "地図の表示", "appSettings_showRepeaters": "繰り返し再生機能", "appSettings_showRepeatersSubtitle": "地図上にリピーターノードを表示する", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 34984c9..8faa855 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -308,6 +308,8 @@ "appSettings_batteryNmc": "18650 NMC (3.0-4.2V)", "appSettings_batteryLifepo4": "LiFePO4 (2.6-3.65V)", "appSettings_batteryLipo": "리튬 폴리머 (3.0-4.2V)", + "appSettings_keepScreenAwake": "화면 켜짐 유지", + "appSettings_keepScreenAwakeSubtitle": "앱이 열려 있는 동안 화면이 꺼지지 않도록 합니다. 배터리를 더 소모합니다.", "appSettings_mapDisplay": "지도 표시", "appSettings_showRepeaters": "반복 기능 표시", "appSettings_showRepeatersSubtitle": "지도에 반복자 노드를 표시", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index f39a9d4..083f220 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1714,6 +1714,18 @@ abstract class AppLocalizations { /// **'LiPo (3.0-4.2V)'** String get appSettings_batteryLipo; + /// No description provided for @appSettings_keepScreenAwake. + /// + /// In en, this message translates to: + /// **'Keep screen awake'** + String get appSettings_keepScreenAwake; + + /// No description provided for @appSettings_keepScreenAwakeSubtitle. + /// + /// In en, this message translates to: + /// **'Prevent the display from sleeping while the app is open. Uses more battery.'** + String get appSettings_keepScreenAwakeSubtitle; + /// No description provided for @appSettings_mapDisplay. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 82ee646..f9352a0 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -895,6 +895,13 @@ class AppLocalizationsBg extends AppLocalizations { @override String get appSettings_batteryLipo => 'Литиев полимер (3.0-4.2V)'; + @override + String get appSettings_keepScreenAwake => 'Поддържане на екрана включен'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'Екранът няма да изгасва, докато приложението е отворено. Изразходва повече батерия.'; + @override String get appSettings_mapDisplay => 'Карта за показване'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 86d780e..54a4f13 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -891,6 +891,13 @@ class AppLocalizationsDe extends AppLocalizations { @override String get appSettings_batteryLipo => 'LiPo (3,0–4,2V)'; + @override + String get appSettings_keepScreenAwake => 'Bildschirm anlassen'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'Verhindert, dass sich das Display ausschaltet, während die App geöffnet ist. Verbraucht mehr Akku.'; + @override String get appSettings_mapDisplay => 'Kartendarstellung'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 5344a2a..abbea4a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -876,6 +876,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get appSettings_batteryLipo => 'LiPo (3.0-4.2V)'; + @override + String get appSettings_keepScreenAwake => 'Keep screen awake'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'Prevent the display from sleeping while the app is open. Uses more battery.'; + @override String get appSettings_mapDisplay => 'Map Display'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index e3c2efd..c8e9878 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -890,6 +890,13 @@ class AppLocalizationsEs extends AppLocalizations { @override String get appSettings_batteryLipo => 'LiPo (3.0-4.2V)'; + @override + String get appSettings_keepScreenAwake => 'Mantener la pantalla encendida'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'Evita que la pantalla se apague mientras la aplicación está abierta. Consume más batería.'; + @override String get appSettings_mapDisplay => 'Visualización del Mapa'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 6d46e56..7c5f46c 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -896,6 +896,13 @@ class AppLocalizationsFr extends AppLocalizations { @override String get appSettings_batteryLipo => 'LiPo (3,0-4,2V)'; + @override + String get appSettings_keepScreenAwake => 'Garder l\'écran allumé'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'Empêche l\'écran de s\'éteindre lorsque l\'application est ouverte. Consomme plus de batterie.'; + @override String get appSettings_mapDisplay => 'Affichage de la carte'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index 179a37d..d63ca80 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -894,6 +894,13 @@ class AppLocalizationsHu extends AppLocalizations { @override String get appSettings_batteryLipo => 'LiPo (3,0-4,2 V)'; + @override + String get appSettings_keepScreenAwake => 'Képernyő ébren tartása'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'Megakadályozza a képernyő elalvását, amíg az alkalmazás nyitva van. Több akkumulátort fogyaszt.'; + @override String get appSettings_mapDisplay => 'Térkép megjelenítése'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index ce6b07c..5ef61e7 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -893,6 +893,13 @@ class AppLocalizationsIt extends AppLocalizations { @override String get appSettings_batteryLipo => 'LiPo (3,0-4,2V)'; + @override + String get appSettings_keepScreenAwake => 'Mantieni lo schermo acceso'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'Impedisce allo schermo di spegnersi mentre l\'app è aperta. Consuma più batteria.'; + @override String get appSettings_mapDisplay => 'Visualizzazione Mappa'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 8bfd63c..bfd0ada 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -849,6 +849,13 @@ class AppLocalizationsJa extends AppLocalizations { @override String get appSettings_batteryLipo => 'LiPo (3.0-4.2V)'; + @override + String get appSettings_keepScreenAwake => '画面をスリープさせない'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'アプリを開いている間、画面が消灯しないようにします。バッテリーの消費が増えます。'; + @override String get appSettings_mapDisplay => '地図の表示'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 3b9adfc..7a6fea9 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -847,6 +847,13 @@ class AppLocalizationsKo extends AppLocalizations { @override String get appSettings_batteryLipo => '리튬 폴리머 (3.0-4.2V)'; + @override + String get appSettings_keepScreenAwake => '화면 켜짐 유지'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + '앱이 열려 있는 동안 화면이 꺼지지 않도록 합니다. 배터리를 더 소모합니다.'; + @override String get appSettings_mapDisplay => '지도 표시'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index dc1c51a..e981d89 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -885,6 +885,13 @@ class AppLocalizationsNl extends AppLocalizations { @override String get appSettings_batteryLipo => 'LiPo (3,0-4,2V)'; + @override + String get appSettings_keepScreenAwake => 'Scherm aan houden'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'Voorkomt dat het scherm in slaapstand gaat terwijl de app open is. Verbruikt meer batterij.'; + @override String get appSettings_mapDisplay => 'Kaartweergave'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index d6f55f8..3a990e4 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -895,6 +895,13 @@ class AppLocalizationsPl extends AppLocalizations { @override String get appSettings_batteryLipo => 'LiPo (3,0-4,2V)'; + @override + String get appSettings_keepScreenAwake => 'Nie wygaszaj ekranu'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'Zapobiega wygaszaniu ekranu, gdy aplikacja jest otwarta. Zużywa więcej baterii.'; + @override String get appSettings_mapDisplay => 'Wyświetlanie mapy'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index da82f6c..146694d 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -892,6 +892,13 @@ class AppLocalizationsPt extends AppLocalizations { @override String get appSettings_batteryLipo => 'LiPo (3,0-4,2V)'; + @override + String get appSettings_keepScreenAwake => 'Manter a tela ligada'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'Impede que a tela desligue enquanto o app está aberto. Consome mais bateria.'; + @override String get appSettings_mapDisplay => 'Exibição do Mapa'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 67b868d..3a1ce48 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -894,6 +894,13 @@ class AppLocalizationsRu extends AppLocalizations { @override String get appSettings_batteryLipo => 'LiPo (3.0–4.2 В)'; + @override + String get appSettings_keepScreenAwake => 'Не выключать экран'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'Экран не будет гаснуть, пока приложение открыто. Расходует больше заряда.'; + @override String get appSettings_mapDisplay => 'Отображение карты'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index cb58b24..e0c16b7 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -881,6 +881,13 @@ class AppLocalizationsSk extends AppLocalizations { @override String get appSettings_batteryLipo => 'LiPo (3,0-4,2V)'; + @override + String get appSettings_keepScreenAwake => 'Nechať obrazovku zapnutú'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'Zabráni vypnutiu obrazovky, kým je aplikácia otvorená. Spotrebuje viac batérie.'; + @override String get appSettings_mapDisplay => 'Zobrazenie mapy'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index abde10d..4c258e5 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -882,6 +882,13 @@ class AppLocalizationsSl extends AppLocalizations { @override String get appSettings_batteryLipo => 'LiPo (3,0-4,2V)'; + @override + String get appSettings_keepScreenAwake => 'Ohrani zaslon prižgan'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'Prepreči, da bi zaslon ugasnil, medtem ko je aplikacija odprta. Porabi več baterije.'; + @override String get appSettings_mapDisplay => 'Prikaz zemljevida'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 78c3bb4..c7b1bdb 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -876,6 +876,13 @@ class AppLocalizationsSv extends AppLocalizations { @override String get appSettings_batteryLipo => 'LiPo (3,0-4,2V)'; + @override + String get appSettings_keepScreenAwake => 'Håll skärmen tänd'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'Hindrar skärmen från att slockna medan appen är öppen. Drar mer batteri.'; + @override String get appSettings_mapDisplay => 'Kartvisning'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index dcf9578..5ec0e36 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -888,6 +888,13 @@ class AppLocalizationsUk extends AppLocalizations { @override String get appSettings_batteryLipo => 'LiPo (3.0-4.2В)'; + @override + String get appSettings_keepScreenAwake => 'Не вимикати екран'; + + @override + String get appSettings_keepScreenAwakeSubtitle => + 'Екран не гаснутиме, доки застосунок відкрито. Витрачає більше заряду.'; + @override String get appSettings_mapDisplay => 'Відображення карти'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 96de896..206f4a6 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -833,6 +833,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get appSettings_batteryLipo => '锂聚合物电池 (3.0-4.2V)'; + @override + String get appSettings_keepScreenAwake => '保持屏幕常亮'; + + @override + String get appSettings_keepScreenAwakeSubtitle => '应用打开时防止屏幕休眠。会更耗电。'; + @override String get appSettings_mapDisplay => '地图显示'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 5e115be..8a11ea1 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -216,6 +216,8 @@ "appSettings_batteryNmc": "18650 NMC (3,0-4,2V)", "appSettings_batteryLifepo4": "LiFePO4 (2,6-3,65V)", "appSettings_batteryLipo": "LiPo (3,0-4,2V)", + "appSettings_keepScreenAwake": "Scherm aan houden", + "appSettings_keepScreenAwakeSubtitle": "Voorkomt dat het scherm in slaapstand gaat terwijl de app open is. Verbruikt meer batterij.", "appSettings_mapDisplay": "Kaartweergave", "appSettings_showRepeaters": "Toon Repeaters", "appSettings_showRepeatersSubtitle": "Toon repeaternodes op de kaart", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 4e0dafc..81ffcb0 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -216,6 +216,8 @@ "appSettings_batteryNmc": "18650 NMC (3,0-4,2V)", "appSettings_batteryLifepo4": "LiFePO4 (2,6-3,65 V)", "appSettings_batteryLipo": "LiPo (3,0-4,2V)", + "appSettings_keepScreenAwake": "Nie wygaszaj ekranu", + "appSettings_keepScreenAwakeSubtitle": "Zapobiega wygaszaniu ekranu, gdy aplikacja jest otwarta. Zużywa więcej baterii.", "appSettings_mapDisplay": "Wyświetlanie mapy", "appSettings_showRepeaters": "Pokaż przekaźniki", "appSettings_showRepeatersSubtitle": "Wyświetl węzły przekaźników na mapie", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 32307e7..05fe50a 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -216,6 +216,8 @@ "appSettings_batteryNmc": "18650 NMC (3,0-4,2V)", "appSettings_batteryLifepo4": "LiFePO4 (2,6-3,65V)", "appSettings_batteryLipo": "LiPo (3,0-4,2V)", + "appSettings_keepScreenAwake": "Manter a tela ligada", + "appSettings_keepScreenAwakeSubtitle": "Impede que a tela desligue enquanto o app está aberto. Consome mais bateria.", "appSettings_mapDisplay": "Exibição do Mapa", "appSettings_showRepeaters": "Mostrar Repetidores", "appSettings_showRepeatersSubtitle": "Exibir nós de repetidor no mapa", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 8b63b47..8fea350 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -173,6 +173,8 @@ "appSettings_batteryNmc": "18650 NMC (3.0–4.2 В)", "appSettings_batteryLifepo4": "LiFePO4 (2.6–3.65 В)", "appSettings_batteryLipo": "LiPo (3.0–4.2 В)", + "appSettings_keepScreenAwake": "Не выключать экран", + "appSettings_keepScreenAwakeSubtitle": "Экран не будет гаснуть, пока приложение открыто. Расходует больше заряда.", "appSettings_mapDisplay": "Отображение карты", "appSettings_showRepeaters": "Показывать репитеры", "appSettings_showRepeatersSubtitle": "Отображать репитеры на карте", diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index 9e585e6..5892bc6 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -216,6 +216,8 @@ "appSettings_batteryNmc": "18650 NMC (3,0-4,2V)", "appSettings_batteryLifepo4": "LiFePO4 (2,6–3,65V)", "appSettings_batteryLipo": "LiPo (3,0-4,2V)", + "appSettings_keepScreenAwake": "Nechať obrazovku zapnutú", + "appSettings_keepScreenAwakeSubtitle": "Zabráni vypnutiu obrazovky, kým je aplikácia otvorená. Spotrebuje viac batérie.", "appSettings_mapDisplay": "Zobrazenie mapy", "appSettings_showRepeaters": "Zobraziť opakovače", "appSettings_showRepeatersSubtitle": "Zobraziť opakujúce sa uzly na mape", diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 533bc9e..15d6493 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -216,6 +216,8 @@ "appSettings_batteryNmc": "18650 NMC (3,0-4,2V)", "appSettings_batteryLifepo4": "LiFePO4 (2,6–3,65 V)", "appSettings_batteryLipo": "LiPo (3,0-4,2V)", + "appSettings_keepScreenAwake": "Ohrani zaslon prižgan", + "appSettings_keepScreenAwakeSubtitle": "Prepreči, da bi zaslon ugasnil, medtem ko je aplikacija odprta. Porabi več baterije.", "appSettings_mapDisplay": "Prikaz zemljevida", "appSettings_showRepeaters": "Prikaži repetitorje", "appSettings_showRepeatersSubtitle": "Prikaži repetitorje na mapi", diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index 0495908..ed22b2d 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -216,6 +216,8 @@ "appSettings_batteryNmc": "18650 NMC (3,0-4,2V)", "appSettings_batteryLifepo4": "LiFePO4 (2,6–3,65V)", "appSettings_batteryLipo": "LiPo (3,0-4,2V)", + "appSettings_keepScreenAwake": "Håll skärmen tänd", + "appSettings_keepScreenAwakeSubtitle": "Hindrar skärmen från att slockna medan appen är öppen. Drar mer batteri.", "appSettings_mapDisplay": "Kartvisning", "appSettings_showRepeaters": "Visa återuppslag", "appSettings_showRepeatersSubtitle": "Visa återspelsnoder på kartan", diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index c27cb52..d3c5ec6 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -218,6 +218,8 @@ "appSettings_batteryNmc": "18650 NMC (3.0-4.2В)", "appSettings_batteryLifepo4": "LiFePO4 (2.6-3.65В)", "appSettings_batteryLipo": "LiPo (3.0-4.2В)", + "appSettings_keepScreenAwake": "Не вимикати екран", + "appSettings_keepScreenAwakeSubtitle": "Екран не гаснутиме, доки застосунок відкрито. Витрачає більше заряду.", "appSettings_mapDisplay": "Відображення карти", "appSettings_showRepeaters": "Показувати ретранслятори", "appSettings_showRepeatersSubtitle": "Відображати вузли-ретранслятори на карті", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 9bec0b2..4061b0a 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -230,6 +230,8 @@ "appSettings_batteryNmc": "18650 NMC 电池 (3.0-4.2V)", "appSettings_batteryLifepo4": "磷酸铁锂 (2.6-3.65V)", "appSettings_batteryLipo": "锂聚合物电池 (3.0-4.2V)", + "appSettings_keepScreenAwake": "保持屏幕常亮", + "appSettings_keepScreenAwakeSubtitle": "应用打开时防止屏幕休眠。会更耗电。", "appSettings_mapDisplay": "地图显示", "appSettings_showRepeaters": "显示转发节点", "appSettings_showRepeatersSubtitle": "在地图上显示转发节点", diff --git a/lib/main.dart b/lib/main.dart index 655beaf..34a3a8e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -34,6 +34,7 @@ import 'storage/drift/blob_store.dart'; import 'widgets/storage_unavailable_banner.dart'; import 'storage/prefs_manager.dart'; import 'utils/app_logger.dart'; +import 'widgets/keep_screen_awake.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -279,10 +280,12 @@ class MeshCoreApp extends StatelessWidget { NotificationService().setLocale(locale); return AnnotatedRegion( value: _systemUiOverlayStyle(context), - child: Consumer( - builder: (context, health, _) => StorageUnavailableBanner( - show: !health.available, - child: child ?? const SizedBox.shrink(), + child: KeepScreenAwake( + child: Consumer( + builder: (context, health, _) => StorageUnavailableBanner( + show: !health.available, + child: child ?? const SizedBox.shrink(), + ), ), ), ); diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index 782e78a..70d4222 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -121,6 +121,7 @@ class AppSettings { final bool mapShowMarkers; final bool mapShowGuessedLocations; final bool enableMessageTracing; + final bool keepScreenAwake; final ClockFormat clockFormat; final Map? mapCacheBounds; final int mapCacheMinZoom; @@ -198,6 +199,7 @@ class AppSettings { this.mapShowMarkers = true, this.mapShowGuessedLocations = true, this.enableMessageTracing = false, + this.keepScreenAwake = false, this.clockFormat = ClockFormat.system, this.mapCacheBounds, this.mapCacheMinZoom = 10, @@ -263,6 +265,7 @@ class AppSettings { 'map_show_markers': mapShowMarkers, 'map_show_guessed_locations': mapShowGuessedLocations, 'enable_message_tracing': enableMessageTracing, + 'keep_screen_awake': keepScreenAwake, 'clock_format': clockFormat.value, 'map_cache_bounds': mapCacheBounds, 'map_cache_min_zoom': mapCacheMinZoom, @@ -352,6 +355,7 @@ class AppSettings { mapShowGuessedLocations: json['map_show_guessed_locations'] as bool? ?? true, enableMessageTracing: json['enable_message_tracing'] as bool? ?? false, + keepScreenAwake: json['keep_screen_awake'] as bool? ?? false, clockFormat: parseClockFormat(json['clock_format']), mapCacheBounds: (json['map_cache_bounds'] as Map?)?.map( (key, value) => MapEntry(key.toString(), (value as num).toDouble()), @@ -475,6 +479,7 @@ class AppSettings { bool? mapShowMarkers, bool? mapShowGuessedLocations, bool? enableMessageTracing, + bool? keepScreenAwake, ClockFormat? clockFormat, Object? mapCacheBounds = _unset, int? mapCacheMinZoom, @@ -525,6 +530,7 @@ class AppSettings { mapShowGuessedLocations: mapShowGuessedLocations ?? this.mapShowGuessedLocations, enableMessageTracing: enableMessageTracing ?? this.enableMessageTracing, + keepScreenAwake: keepScreenAwake ?? this.keepScreenAwake, clockFormat: clockFormat ?? this.clockFormat, mapCacheBounds: mapCacheBounds == _unset ? this.mapCacheBounds diff --git a/lib/screens/settings/app_settings_view.dart b/lib/screens/settings/app_settings_view.dart index 08e61b7..5486422 100644 --- a/lib/screens/settings/app_settings_view.dart +++ b/lib/screens/settings/app_settings_view.dart @@ -557,6 +557,20 @@ class AppSettingsView extends StatelessWidget { ], ), ), + + const Divider(height: 1), + SwitchListTile( + secondary: const Icon(Icons.screen_lock_portrait_outlined), + title: Text(context.l10n.appSettings_keepScreenAwake), + subtitle: Text(context.l10n.appSettings_keepScreenAwakeSubtitle), + value: settingsService.settings.keepScreenAwake, + onChanged: (value) { + persistSetting( + context, + () => settingsService.setKeepScreenAwake(value), + ); + }, + ), ], ), ); diff --git a/lib/services/app_settings_service.dart b/lib/services/app_settings_service.dart index d0ea235..4352f41 100644 --- a/lib/services/app_settings_service.dart +++ b/lib/services/app_settings_service.dart @@ -104,6 +104,10 @@ class AppSettingsService extends ChangeNotifier { await updateSettings(_settings.copyWith(enableMessageTracing: value)); } + Future setKeepScreenAwake(bool value) async { + await updateSettings(_settings.copyWith(keepScreenAwake: value)); + } + Future setClockFormat(ClockFormat value) async { await updateSettings(_settings.copyWith(clockFormat: value)); } diff --git a/lib/widgets/keep_screen_awake.dart b/lib/widgets/keep_screen_awake.dart new file mode 100644 index 0000000..4a98167 --- /dev/null +++ b/lib/widgets/keep_screen_awake.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; + +import '../services/app_settings_service.dart'; + +/// Holds a screen wakelock while [AppSettings.keepScreenAwake] is enabled and +/// the app is foregrounded (#269). +/// +/// The lock is released when the setting is turned off, when the app leaves the +/// foreground, and on dispose — so normal display sleep resumes and the +/// background foreground-service behaviour is untouched. +class KeepScreenAwake extends StatefulWidget { + const KeepScreenAwake({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _KeepScreenAwakeState(); +} + +class _KeepScreenAwakeState extends State + with WidgetsBindingObserver { + AppSettingsService? _service; + bool _foreground = true; + bool _held = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final service = context.read(); + if (!identical(service, _service)) { + _service?.removeListener(_sync); + _service = service..addListener(_sync); + } + _sync(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + _foreground = state == AppLifecycleState.resumed; + _sync(); + } + + /// Drives the platform wakelock toward the desired state. Failures are + /// logged and leave the tracked state released, never swallowed. + Future _sync() async { + final want = (_service?.settings.keepScreenAwake ?? false) && _foreground; + if (want == _held) return; + try { + await WakelockPlus.toggle(enable: want); + _held = want; + } catch (e) { + _held = false; + debugPrint('[KeepScreenAwake] failed to set wakelock to $want: $e'); + } + } + + @override + void dispose() { + _service?.removeListener(_sync); + WidgetsBinding.instance.removeObserver(this); + if (_held) { + WakelockPlus.disable().catchError( + (Object e) => + debugPrint('[KeepScreenAwake] failed to release on dispose: $e'), + ); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index ffc8c59..d2ea57e 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -13,6 +13,7 @@ import share_plus import shared_preferences_foundation import sqflite_darwin import url_launcher_macos +import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin")) @@ -23,4 +24,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) } diff --git a/pubspec.yaml b/pubspec.yaml index 2b67d78..e4a23e8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -55,6 +55,7 @@ dependencies: cached_network_image: ^3.4.1 flutter_cache_manager: ^3.4.1 flutter_foreground_task: ^9.2.0 + wakelock_plus: ^1.2.8 # Keep-screen-awake setting (#269) characters: ^1.4.0 package_info_plus: ^9.0.0 mobile_scanner: ^7.1.4 # QR/barcode scanning From f63f796940e157ae4e304366663836bb93853b9c Mon Sep 17 00:00:00 2001 From: Strycher Date: Sat, 18 Jul 2026 02:15:43 -0400 Subject: [PATCH 13/62] feat(#275): per-channel notify mode from the channel ellipsis menu Extracts the #262 notify-mode selector out of channels_screen into a shared widgets/channel_notify_mode.dart (key, icon, label, dialog) and points both entry points at it, so the Channels list and the in-channel menu cannot drift. Same PSK-keyed storage; no new settings. Co-Authored-By: Claude Opus 4.8 --- lib/screens/channel_chat_screen.dart | 23 ++++++++- lib/screens/channels_screen.dart | 75 ++------------------------- lib/widgets/channel_notify_mode.dart | 76 ++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 71 deletions(-) create mode 100644 lib/widgets/channel_notify_mode.dart diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index dfa8959..1cac581 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -24,6 +24,7 @@ import '../models/channel_message.dart'; import '../models/translation_support.dart'; import '../models/app_settings.dart'; import '../services/app_settings_service.dart'; +import '../widgets/channel_notify_mode.dart'; import '../services/block_service.dart'; import '../services/chat_text_scale_service.dart'; import '../services/translation_service.dart'; @@ -424,13 +425,33 @@ class _ChannelChatScreenState extends State { PopupMenuButton( icon: const Icon(Icons.more_vert), onSelected: (value) { - if (value == 'clearChat') { + if (value == 'notifications') { + showChannelNotifyModeDialog(context, widget.channel); + } else if (value == 'clearChat') { context.read().clearMessagesForChannel( _currentChannel.index, ); } }, itemBuilder: (context) => [ + PopupMenuItem( + value: 'notifications', + child: Row( + children: [ + Icon( + channelNotifyModeIcon( + context.read().channelNotifyMode( + identityKey: channelNotifyKeyFor(widget.channel), + channelName: widget.channel.name, + ), + ), + size: 20, + ), + const SizedBox(width: 12), + Text(context.l10n.channels_notifications), + ], + ), + ), PopupMenuItem( value: 'clearChat', child: Row( diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index eedfd71..5d91ffd 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -13,7 +13,6 @@ import '../connector/meshcore_connector.dart'; import '../l10n/l10n.dart'; import '../services/app_settings_service.dart'; import '../services/ui_view_state_service.dart'; -import '../models/app_settings.dart'; import '../models/channel.dart'; import '../models/community.dart'; import '../storage/community_store.dart'; @@ -27,6 +26,7 @@ import '../widgets/app_shell.dart'; import '../widgets/channel_drawer_list.dart'; import '../widgets/sync_progress_overlay.dart'; import '../widgets/unread_badge.dart'; +import '../widgets/channel_notify_mode.dart'; import '../helpers/snack_bar_builder.dart'; import 'channel_chat_screen.dart'; import 'community_qr_scanner_screen.dart'; @@ -468,71 +468,6 @@ class _ChannelsScreenState extends State ); } - /// Notify-mode storage key for [channel]: its PSK identity, so the setting - /// survives a rename and does not follow a reused slot (#259). - String _notifyKeyFor(Channel channel) => AppSettings.channelNotifyKey( - channelIndex: channel.index, - pskHex: channel.pskHex, - ); - - IconData _notifyModeIcon(ChannelNotifyMode mode) { - switch (mode) { - case ChannelNotifyMode.all: - return Icons.notifications_outlined; - case ChannelNotifyMode.mentionsOnly: - return Icons.alternate_email; - case ChannelNotifyMode.off: - return Icons.notifications_off_outlined; - } - } - - String _notifyModeLabel(BuildContext context, ChannelNotifyMode mode) { - switch (mode) { - case ChannelNotifyMode.all: - return context.l10n.channels_notifyAll; - case ChannelNotifyMode.mentionsOnly: - return context.l10n.channels_notifyMentionsOnly; - case ChannelNotifyMode.off: - return context.l10n.channels_notifyOff; - } - } - - void _showNotifyModeDialog(BuildContext context, Channel channel) { - final settingsService = context.read(); - final identityKey = _notifyKeyFor(channel); - final current = settingsService.channelNotifyMode( - identityKey: identityKey, - channelName: channel.name, - ); - - showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: Text(dialogContext.l10n.channels_notifications), - contentPadding: const EdgeInsets.symmetric(vertical: 8), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final mode in ChannelNotifyMode.values) - ListTile( - leading: Icon(_notifyModeIcon(mode)), - title: Text(_notifyModeLabel(dialogContext, mode)), - trailing: mode == current ? const Icon(Icons.check) : null, - onTap: () async { - Navigator.pop(dialogContext); - await settingsService.setChannelNotifyMode( - identityKey: identityKey, - channelName: channel.name, - mode: mode, - ); - }, - ), - ], - ), - ), - ); - } - void _showChannelActions( BuildContext context, MeshCoreConnector connector, @@ -542,7 +477,7 @@ class _ChannelsScreenState extends State final parentContext = context; final settingsService = context.read(); final notifyMode = settingsService.channelNotifyMode( - identityKey: _notifyKeyFor(channel), + identityKey: channelNotifyKeyFor(channel), channelName: channel.name, ); @@ -575,14 +510,14 @@ class _ChannelsScreenState extends State }, ), ListTile( - leading: Icon(_notifyModeIcon(notifyMode)), + leading: Icon(channelNotifyModeIcon(notifyMode)), title: Text(context.l10n.channels_notifications), - subtitle: Text(_notifyModeLabel(context, notifyMode)), + subtitle: Text(channelNotifyModeLabel(context, notifyMode)), onTap: () async { Navigator.pop(sheetContext); await Future.delayed(const Duration(milliseconds: 100)); if (parentContext.mounted) { - _showNotifyModeDialog(parentContext, channel); + showChannelNotifyModeDialog(parentContext, channel); } }, ), diff --git a/lib/widgets/channel_notify_mode.dart b/lib/widgets/channel_notify_mode.dart new file mode 100644 index 0000000..c87486b --- /dev/null +++ b/lib/widgets/channel_notify_mode.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../l10n/l10n.dart'; +import '../models/app_settings.dart'; +import '../models/channel.dart'; +import '../services/app_settings_service.dart'; + +/// Shared per-channel notify-mode affordance (#262), reachable both from the +/// Channels list and from inside a channel's chat (#275). Single implementation +/// so the two entry points cannot drift. + +/// Notify-mode storage key for [channel]: its PSK identity, so the setting +/// survives a rename and does not follow a reused slot (#259). +String channelNotifyKeyFor(Channel channel) => AppSettings.channelNotifyKey( + channelIndex: channel.index, + pskHex: channel.pskHex, +); + +IconData channelNotifyModeIcon(ChannelNotifyMode mode) { + switch (mode) { + case ChannelNotifyMode.all: + return Icons.notifications_outlined; + case ChannelNotifyMode.mentionsOnly: + return Icons.alternate_email; + case ChannelNotifyMode.off: + return Icons.notifications_off_outlined; + } +} + +String channelNotifyModeLabel(BuildContext context, ChannelNotifyMode mode) { + switch (mode) { + case ChannelNotifyMode.all: + return context.l10n.channels_notifyAll; + case ChannelNotifyMode.mentionsOnly: + return context.l10n.channels_notifyMentionsOnly; + case ChannelNotifyMode.off: + return context.l10n.channels_notifyOff; + } +} + +void showChannelNotifyModeDialog(BuildContext context, Channel channel) { + final settingsService = context.read(); + final identityKey = channelNotifyKeyFor(channel); + final current = settingsService.channelNotifyMode( + identityKey: identityKey, + channelName: channel.name, + ); + + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(dialogContext.l10n.channels_notifications), + contentPadding: const EdgeInsets.symmetric(vertical: 8), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final mode in ChannelNotifyMode.values) + ListTile( + leading: Icon(channelNotifyModeIcon(mode)), + title: Text(channelNotifyModeLabel(dialogContext, mode)), + trailing: mode == current ? const Icon(Icons.check) : null, + onTap: () async { + Navigator.pop(dialogContext); + await settingsService.setChannelNotifyMode( + identityKey: identityKey, + channelName: channel.name, + mode: mode, + ); + }, + ), + ], + ), + ), + ); +} From 6c2a856e2d5eac71bbef9ad5bf7575a34295fd3a Mon Sep 17 00:00:00 2001 From: Strycher Date: Sat, 18 Jul 2026 02:17:45 -0400 Subject: [PATCH 14/62] fix(#270): rename advert notification setting to plain language "Advertisement Notifications" reads as ads. Retitled to "New node discovered" / "Notify when new repeaters or contacts are heard" across all 18 locales so the overnight-ping setting is findable without mesh jargon. Coverage verified, no gap found: showAdvertNotification is the only discovery notification entry point and all three of its callers (meshcore_connector.dart:4961, 5047, 7226) are guarded by notificationsEnabled && notifyOnNewAdvert && !isBlocked. The batch summary is fed solely from enqueued adverts, so it cannot fire with the toggle off. Co-Authored-By: Claude Opus 4.8 --- lib/l10n/app_bg.arb | 4 ++-- lib/l10n/app_de.arb | 4 ++-- lib/l10n/app_en.arb | 4 ++-- lib/l10n/app_es.arb | 4 ++-- lib/l10n/app_fr.arb | 4 ++-- lib/l10n/app_hu.arb | 4 ++-- lib/l10n/app_it.arb | 4 ++-- lib/l10n/app_ja.arb | 4 ++-- lib/l10n/app_ko.arb | 4 ++-- lib/l10n/app_localizations.dart | 4 ++-- lib/l10n/app_localizations_bg.dart | 4 ++-- lib/l10n/app_localizations_de.dart | 5 ++--- lib/l10n/app_localizations_en.dart | 5 ++--- lib/l10n/app_localizations_es.dart | 5 ++--- lib/l10n/app_localizations_fr.dart | 5 ++--- lib/l10n/app_localizations_hu.dart | 4 ++-- lib/l10n/app_localizations_it.dart | 5 ++--- lib/l10n/app_localizations_ja.dart | 4 ++-- lib/l10n/app_localizations_ko.dart | 4 ++-- lib/l10n/app_localizations_nl.dart | 4 ++-- lib/l10n/app_localizations_pl.dart | 5 ++--- lib/l10n/app_localizations_pt.dart | 5 ++--- lib/l10n/app_localizations_ru.dart | 5 ++--- lib/l10n/app_localizations_sk.dart | 4 ++-- lib/l10n/app_localizations_sl.dart | 4 ++-- lib/l10n/app_localizations_sv.dart | 4 ++-- lib/l10n/app_localizations_uk.dart | 5 ++--- lib/l10n/app_localizations_zh.dart | 5 +++-- lib/l10n/app_nl.arb | 4 ++-- lib/l10n/app_pl.arb | 4 ++-- lib/l10n/app_pt.arb | 4 ++-- lib/l10n/app_ru.arb | 4 ++-- lib/l10n/app_sk.arb | 4 ++-- lib/l10n/app_sl.arb | 4 ++-- lib/l10n/app_sv.arb | 4 ++-- lib/l10n/app_uk.arb | 4 ++-- lib/l10n/app_zh.arb | 4 ++-- 37 files changed, 75 insertions(+), 83 deletions(-) diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index 16021f0..75450f0 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -191,8 +191,8 @@ "appSettings_messageNotificationsSubtitle": "Покажи известие при получаване на нови съобщения", "appSettings_channelMessageNotifications": "Уведомления за съобщения от канал", "appSettings_channelMessageNotificationsSubtitle": "Покажи известие при получаване на съобщения от канали", - "appSettings_advertisementNotifications": "Уведомления за реклами", - "appSettings_advertisementNotificationsSubtitle": "Покажи известие, когато бъдат открити нови възли.", + "appSettings_advertisementNotifications": "Открит нов възел", + "appSettings_advertisementNotificationsSubtitle": "Известяване при откриване на нови репитери или контакти", "appSettings_messaging": "Съобщения", "appSettings_clearPathOnMaxRetry": "Изчисти Път на Макс Опит", "appSettings_clearPathOnMaxRetrySubtitle": "Възстанови контактния път след 5 неуспешни опита за изпращане", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 2d8fdfe..e03c340 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -191,8 +191,8 @@ "appSettings_messageNotificationsSubtitle": "Zeige Benachrichtigung beim Empfang neuer Direktnachrichten", "appSettings_channelMessageNotifications": "Kanalnachrichten Benachrichtigungen", "appSettings_channelMessageNotificationsSubtitle": "Zeige Benachrichtigung beim Empfangen von Kanalnachrichten", - "appSettings_advertisementNotifications": "Ankündigungsbenachrichtigungen", - "appSettings_advertisementNotificationsSubtitle": "Zeige Benachrichtigung, wenn neue Knoten entdeckt werden.", + "appSettings_advertisementNotifications": "Neuer Knoten entdeckt", + "appSettings_advertisementNotificationsSubtitle": "Benachrichtigen, wenn neue Repeater oder Kontakte empfangen werden", "appSettings_messaging": "Nachrichten", "appSettings_clearPathOnMaxRetry": "Lösche Pfade bei Max Wiederholungsversuchen", "appSettings_clearPathOnMaxRetrySubtitle": "Zurücksetzen der Kontaktpfade nach 5 fehlgeschlagenen Sendeabbrüchen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 078d7c0..2c3ba6a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -304,8 +304,8 @@ "appSettings_messageNotificationsSubtitle": "Show notification when receiving new messages", "appSettings_channelMessageNotifications": "Channel Message Notifications", "appSettings_channelMessageNotificationsSubtitle": "Show notification when receiving channel messages", - "appSettings_advertisementNotifications": "Advertisement Notifications", - "appSettings_advertisementNotificationsSubtitle": "Show notification when new nodes are discovered", + "appSettings_advertisementNotifications": "New node discovered", + "appSettings_advertisementNotificationsSubtitle": "Notify when new repeaters or contacts are heard", "appSettings_messaging": "Messaging", "appSettings_clearPathOnMaxRetry": "Clear Path on Max Retry", "appSettings_clearPathOnMaxRetrySubtitle": "Reset contact path after 5 failed send attempts", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 06c8c6a..58417d8 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -191,8 +191,8 @@ "appSettings_messageNotificationsSubtitle": "Mostrar notificación al recibir nuevos mensajes", "appSettings_channelMessageNotifications": "Notificaciones de Mensajes del Canal", "appSettings_channelMessageNotificationsSubtitle": "Mostrar notificación al recibir mensajes del canal", - "appSettings_advertisementNotifications": "Notificaciones de Anuncios", - "appSettings_advertisementNotificationsSubtitle": "Mostrar notificación cuando se descubren nuevos nodos", + "appSettings_advertisementNotifications": "Nuevo nodo descubierto", + "appSettings_advertisementNotificationsSubtitle": "Notificar cuando se detecten nuevos repetidores o contactos", "appSettings_messaging": "Mensajería", "appSettings_clearPathOnMaxRetry": "Borrar Camino en Max Reintentos", "appSettings_clearPathOnMaxRetrySubtitle": "Restablecer la ruta de contacto después de 5 intentos de envío fallidos", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 50d5a23..d32f029 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -191,8 +191,8 @@ "appSettings_messageNotificationsSubtitle": "Afficher une notification lors de la réception de nouveaux messages", "appSettings_channelMessageNotifications": "Notifications des Messages de Canal", "appSettings_channelMessageNotificationsSubtitle": "Afficher une notification lors de la réception des messages de canal", - "appSettings_advertisementNotifications": "Notifications d'annonces", - "appSettings_advertisementNotificationsSubtitle": "Afficher une notification lors de la découverte de nouveaux nœuds", + "appSettings_advertisementNotifications": "Nouveau nœud découvert", + "appSettings_advertisementNotificationsSubtitle": "Notifier lorsque de nouveaux répéteurs ou contacts sont détectés", "appSettings_messaging": "Messagerie", "appSettings_clearPathOnMaxRetry": "Effacer le chemin sur Max Retry", "appSettings_clearPathOnMaxRetrySubtitle": "Réinitialiser le chemin de contact après 5 tentatives d'envoi infructueuses", diff --git a/lib/l10n/app_hu.arb b/lib/l10n/app_hu.arb index 1e09f62..51cc7c6 100644 --- a/lib/l10n/app_hu.arb +++ b/lib/l10n/app_hu.arb @@ -262,8 +262,8 @@ "appSettings_messageNotificationsSubtitle": "A figyelmeztetést megjelenítve, amikor új üzenet érkezik", "appSettings_channelMessageNotifications": "Csatorna-üzenetek értesítése", "appSettings_channelMessageNotificationsSubtitle": "A figyelmeztetést megjelenítve, amikor új üzenet érkezik a csatornáról", - "appSettings_advertisementNotifications": "Reklám értesítések", - "appSettings_advertisementNotificationsSubtitle": "A figyelmeztetést megjelenítve, amikor új csomópontok kerülnek felfedezésre.", + "appSettings_advertisementNotifications": "Új csomópont észlelve", + "appSettings_advertisementNotificationsSubtitle": "Értesítés, ha új átjátszók vagy névjegyek érkeznek", "appSettings_messaging": "Üzenetek küldése", "appSettings_clearPathOnMaxRetry": "Egyértelmű út a Max Retry funkció használatával", "appSettings_clearPathOnMaxRetrySubtitle": "A kapcsolat visszaállítás 5 sikertelen továbbítás után", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index ebe1e3f..6fd027a 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -191,8 +191,8 @@ "appSettings_messageNotificationsSubtitle": "Mostra notifica all'arrivo di nuovi messaggi", "appSettings_channelMessageNotifications": "Notifiche Messaggi Canale", "appSettings_channelMessageNotificationsSubtitle": "Mostra notifica all'arrivo di messaggi nel canale", - "appSettings_advertisementNotifications": "Notifiche Pubblicitarie", - "appSettings_advertisementNotificationsSubtitle": "Mostra notifica quando vengono scoperti nuovi nodi", + "appSettings_advertisementNotifications": "Nuovo nodo rilevato", + "appSettings_advertisementNotificationsSubtitle": "Notifica quando vengono rilevati nuovi ripetitori o contatti", "appSettings_messaging": "Messaggi", "appSettings_clearPathOnMaxRetry": "Cancella Percorso su Massimo Riprovo", "appSettings_clearPathOnMaxRetrySubtitle": "Reimposta il percorso di contatto dopo 5 tentativi di invio falliti", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 53644cf..88ac881 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -262,8 +262,8 @@ "appSettings_messageNotificationsSubtitle": "新しいメッセージを受信した際に、通知を表示する", "appSettings_channelMessageNotifications": "チャネルメッセージの通知", "appSettings_channelMessageNotificationsSubtitle": "チャンネルからのメッセージを受信した際に、通知を表示する", - "appSettings_advertisementNotifications": "広告通知", - "appSettings_advertisementNotificationsSubtitle": "新しいノードが発見された場合に通知を表示する", + "appSettings_advertisementNotifications": "新しいノードを検出", + "appSettings_advertisementNotificationsSubtitle": "新しいリピーターや連絡先を受信したときに通知します", "appSettings_messaging": "メッセージング", "appSettings_clearPathOnMaxRetry": "マックスリトライでの明確な手順", "appSettings_clearPathOnMaxRetrySubtitle": "5回送信に失敗した場合、連絡経路をリセットする", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 8faa855..d9ba986 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -262,8 +262,8 @@ "appSettings_messageNotificationsSubtitle": "새로운 메시지를 받을 때 알림 표시", "appSettings_channelMessageNotifications": "채널 메시지 알림", "appSettings_channelMessageNotificationsSubtitle": "채널 메시지를 수신할 때 알림 표시", - "appSettings_advertisementNotifications": "광고 알림", - "appSettings_advertisementNotificationsSubtitle": "새 노드가 발견되었을 때 알림 표시", + "appSettings_advertisementNotifications": "새 노드 발견", + "appSettings_advertisementNotificationsSubtitle": "새 리피터나 연락처가 감지되면 알림", "appSettings_messaging": "메시징", "appSettings_clearPathOnMaxRetry": "Max 재시도 시 경로 명확하게 설정", "appSettings_clearPathOnMaxRetrySubtitle": "5번의 전송 시도가 실패하면 연락 경로를 재설정", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 083f220..dc5b2f5 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1543,13 +1543,13 @@ abstract class AppLocalizations { /// No description provided for @appSettings_advertisementNotifications. /// /// In en, this message translates to: - /// **'Advertisement Notifications'** + /// **'New node discovered'** String get appSettings_advertisementNotifications; /// No description provided for @appSettings_advertisementNotificationsSubtitle. /// /// In en, this message translates to: - /// **'Show notification when new nodes are discovered'** + /// **'Notify when new repeaters or contacts are heard'** String get appSettings_advertisementNotificationsSubtitle; /// No description provided for @appSettings_messaging. diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index f9352a0..1947f9a 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -786,11 +786,11 @@ class AppLocalizationsBg extends AppLocalizations { 'Покажи известие при получаване на съобщения от канали'; @override - String get appSettings_advertisementNotifications => 'Уведомления за реклами'; + String get appSettings_advertisementNotifications => 'Открит нов възел'; @override String get appSettings_advertisementNotificationsSubtitle => - 'Покажи известие, когато бъдат открити нови възли.'; + 'Известяване при откриване на нови репитери или контакти'; @override String get appSettings_messaging => 'Съобщения'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 54a4f13..78edf42 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -783,12 +783,11 @@ class AppLocalizationsDe extends AppLocalizations { 'Zeige Benachrichtigung beim Empfangen von Kanalnachrichten'; @override - String get appSettings_advertisementNotifications => - 'Ankündigungsbenachrichtigungen'; + String get appSettings_advertisementNotifications => 'Neuer Knoten entdeckt'; @override String get appSettings_advertisementNotificationsSubtitle => - 'Zeige Benachrichtigung, wenn neue Knoten entdeckt werden.'; + 'Benachrichtigen, wenn neue Repeater oder Kontakte empfangen werden'; @override String get appSettings_messaging => 'Nachrichten'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index abbea4a..de46a90 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -770,12 +770,11 @@ class AppLocalizationsEn extends AppLocalizations { 'Show notification when receiving channel messages'; @override - String get appSettings_advertisementNotifications => - 'Advertisement Notifications'; + String get appSettings_advertisementNotifications => 'New node discovered'; @override String get appSettings_advertisementNotificationsSubtitle => - 'Show notification when new nodes are discovered'; + 'Notify when new repeaters or contacts are heard'; @override String get appSettings_messaging => 'Messaging'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index c8e9878..aecb9c4 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -782,12 +782,11 @@ class AppLocalizationsEs extends AppLocalizations { 'Mostrar notificación al recibir mensajes del canal'; @override - String get appSettings_advertisementNotifications => - 'Notificaciones de Anuncios'; + String get appSettings_advertisementNotifications => 'Nuevo nodo descubierto'; @override String get appSettings_advertisementNotificationsSubtitle => - 'Mostrar notificación cuando se descubren nuevos nodos'; + 'Notificar cuando se detecten nuevos repetidores o contactos'; @override String get appSettings_messaging => 'Mensajería'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 7c5f46c..5c0dc85 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -786,12 +786,11 @@ class AppLocalizationsFr extends AppLocalizations { 'Afficher une notification lors de la réception des messages de canal'; @override - String get appSettings_advertisementNotifications => - 'Notifications d\'annonces'; + String get appSettings_advertisementNotifications => 'Nouveau nœud découvert'; @override String get appSettings_advertisementNotificationsSubtitle => - 'Afficher une notification lors de la découverte de nouveaux nœuds'; + 'Notifier lorsque de nouveaux répéteurs ou contacts sont détectés'; @override String get appSettings_messaging => 'Messagerie'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index d63ca80..548c17e 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -788,11 +788,11 @@ class AppLocalizationsHu extends AppLocalizations { 'A figyelmeztetést megjelenítve, amikor új üzenet érkezik a csatornáról'; @override - String get appSettings_advertisementNotifications => 'Reklám értesítések'; + String get appSettings_advertisementNotifications => 'Új csomópont észlelve'; @override String get appSettings_advertisementNotificationsSubtitle => - 'A figyelmeztetést megjelenítve, amikor új csomópontok kerülnek felfedezésre.'; + 'Értesítés, ha új átjátszók vagy névjegyek érkeznek'; @override String get appSettings_messaging => 'Üzenetek küldése'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 5ef61e7..b095a57 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -784,12 +784,11 @@ class AppLocalizationsIt extends AppLocalizations { 'Mostra notifica all\'arrivo di messaggi nel canale'; @override - String get appSettings_advertisementNotifications => - 'Notifiche Pubblicitarie'; + String get appSettings_advertisementNotifications => 'Nuovo nodo rilevato'; @override String get appSettings_advertisementNotificationsSubtitle => - 'Mostra notifica quando vengono scoperti nuovi nodi'; + 'Notifica quando vengono rilevati nuovi ripetitori o contatti'; @override String get appSettings_messaging => 'Messaggi'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index bfd0ada..cd68df0 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -753,11 +753,11 @@ class AppLocalizationsJa extends AppLocalizations { 'チャンネルからのメッセージを受信した際に、通知を表示する'; @override - String get appSettings_advertisementNotifications => '広告通知'; + String get appSettings_advertisementNotifications => '新しいノードを検出'; @override String get appSettings_advertisementNotificationsSubtitle => - '新しいノードが発見された場合に通知を表示する'; + '新しいリピーターや連絡先を受信したときに通知します'; @override String get appSettings_messaging => 'メッセージング'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 7a6fea9..d1d8762 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -752,11 +752,11 @@ class AppLocalizationsKo extends AppLocalizations { '채널 메시지를 수신할 때 알림 표시'; @override - String get appSettings_advertisementNotifications => '광고 알림'; + String get appSettings_advertisementNotifications => '새 노드 발견'; @override String get appSettings_advertisementNotificationsSubtitle => - '새 노드가 발견되었을 때 알림 표시'; + '새 리피터나 연락처가 감지되면 알림'; @override String get appSettings_messaging => '메시징'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index e981d89..99ffb6e 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -779,11 +779,11 @@ class AppLocalizationsNl extends AppLocalizations { 'Toon notificatie bij het ontvangen van kanaalberichten'; @override - String get appSettings_advertisementNotifications => 'Advertentie-meldingen'; + String get appSettings_advertisementNotifications => 'Nieuwe node ontdekt'; @override String get appSettings_advertisementNotificationsSubtitle => - 'Toon notificatie wanneer nieuwe nodes worden ontdekt'; + 'Melden wanneer nieuwe repeaters of contacten worden gehoord'; @override String get appSettings_messaging => 'Berichten'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 3a990e4..125f62c 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -787,12 +787,11 @@ class AppLocalizationsPl extends AppLocalizations { 'Pokaż powiadomienie przy odbieraniu wiadomości z kanału'; @override - String get appSettings_advertisementNotifications => - 'Powiadomienia o rozgłoszeniach'; + String get appSettings_advertisementNotifications => 'Wykryto nowy węzeł'; @override String get appSettings_advertisementNotificationsSubtitle => - 'Wyświetl powiadomienie, gdy zostaną wykryte nowe węzły.'; + 'Powiadamiaj o wykryciu nowych przemienników lub kontaktów'; @override String get appSettings_messaging => 'Wiadomości'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 146694d..3d2a5cc 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -784,12 +784,11 @@ class AppLocalizationsPt extends AppLocalizations { 'Mostrar notificação ao receber mensagens do canal'; @override - String get appSettings_advertisementNotifications => - 'Notificações de Anúncios'; + String get appSettings_advertisementNotifications => 'Novo nó descoberto'; @override String get appSettings_advertisementNotificationsSubtitle => - 'Mostrar notificação quando novos nós forem descobertos'; + 'Notificar quando novos repetidores ou contatos forem detectados'; @override String get appSettings_messaging => 'Mensagens'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 3a1ce48..262fec1 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -784,12 +784,11 @@ class AppLocalizationsRu extends AppLocalizations { 'Показывать уведомление при получении сообщений в каналах'; @override - String get appSettings_advertisementNotifications => - 'Уведомления об анонсированиях'; + String get appSettings_advertisementNotifications => 'Обнаружен новый узел'; @override String get appSettings_advertisementNotificationsSubtitle => - 'Показывать уведомление при обнаружении новых нод'; + 'Уведомлять при обнаружении новых репитеров или контактов'; @override String get appSettings_messaging => 'Обмен сообщениями'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index e0c16b7..c31de4c 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -776,11 +776,11 @@ class AppLocalizationsSk extends AppLocalizations { 'Zobraziť upozornenie pri prijímaní správ z kanálu'; @override - String get appSettings_advertisementNotifications => 'Upozornenia na reklamy'; + String get appSettings_advertisementNotifications => 'Zistený nový uzol'; @override String get appSettings_advertisementNotificationsSubtitle => - 'Zobraziť upozornenie, keď sa objavia nové uzly.'; + 'Upozorniť pri zachytení nových prevádzačov alebo kontaktov'; @override String get appSettings_messaging => 'Správy'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 4c258e5..41fd764 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -774,11 +774,11 @@ class AppLocalizationsSl extends AppLocalizations { 'Pokaži obvestilo ob prejemanju sporočil kanala'; @override - String get appSettings_advertisementNotifications => 'Opozorila o oglasih'; + String get appSettings_advertisementNotifications => 'Zaznano novo vozlišče'; @override String get appSettings_advertisementNotificationsSubtitle => - 'Pokaži obvestilo, ko so najdene nove naprave.'; + 'Obvesti ob zaznavi novih repetitorjev ali stikov'; @override String get appSettings_messaging => 'Komuniciranje'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index c7b1bdb..af3fc36 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -771,11 +771,11 @@ class AppLocalizationsSv extends AppLocalizations { 'Visa notis när meddelanden i kanal mottas'; @override - String get appSettings_advertisementNotifications => 'Annonsmeddelanden'; + String get appSettings_advertisementNotifications => 'Ny nod upptäckt'; @override String get appSettings_advertisementNotificationsSubtitle => - 'Visa notis när nya noder upptäcks'; + 'Avisera när nya repeatrar eller kontakter hörs'; @override String get appSettings_messaging => 'Meddelanden'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 5ec0e36..fc9845a 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -780,12 +780,11 @@ class AppLocalizationsUk extends AppLocalizations { 'Показувати сповіщення при отриманні повідомлень каналу'; @override - String get appSettings_advertisementNotifications => - 'Сповіщення про оголошення'; + String get appSettings_advertisementNotifications => 'Виявлено новий вузол'; @override String get appSettings_advertisementNotificationsSubtitle => - 'Показувати сповіщення при виявленні нових вузлів'; + 'Сповіщати про виявлення нових ретрансляторів або контактів'; @override String get appSettings_messaging => 'Обмін повідомленнями'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 206f4a6..c1b4d10 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -741,10 +741,11 @@ class AppLocalizationsZh extends AppLocalizations { String get appSettings_channelMessageNotificationsSubtitle => '收到频道消息时显示通知'; @override - String get appSettings_advertisementNotifications => '广播通知'; + String get appSettings_advertisementNotifications => '发现新节点'; @override - String get appSettings_advertisementNotificationsSubtitle => '发现新节点时显示通知'; + String get appSettings_advertisementNotificationsSubtitle => + '当检测到新的中继器或联系人时通知'; @override String get appSettings_messaging => '消息'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 8a11ea1..fba0902 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -191,8 +191,8 @@ "appSettings_messageNotificationsSubtitle": "Toon notificatie bij het ontvangen van nieuwe berichten", "appSettings_channelMessageNotifications": "Kanaal Bericht Meldingen", "appSettings_channelMessageNotificationsSubtitle": "Toon notificatie bij het ontvangen van kanaalberichten", - "appSettings_advertisementNotifications": "Advertentie-meldingen", - "appSettings_advertisementNotificationsSubtitle": "Toon notificatie wanneer nieuwe nodes worden ontdekt", + "appSettings_advertisementNotifications": "Nieuwe node ontdekt", + "appSettings_advertisementNotificationsSubtitle": "Melden wanneer nieuwe repeaters of contacten worden gehoord", "appSettings_messaging": "Berichten", "appSettings_clearPathOnMaxRetry": "Wis Pad op Max Retry", "appSettings_clearPathOnMaxRetrySubtitle": "Reset contactpad na 5 mislukte verzendpogingen", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 81ffcb0..128ed5e 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -191,8 +191,8 @@ "appSettings_messageNotificationsSubtitle": "Pokaż powiadomienie przy otrzymywaniu nowych wiadomości", "appSettings_channelMessageNotifications": "Powiadomienia o Wiadomościach na Kanałach", "appSettings_channelMessageNotificationsSubtitle": "Pokaż powiadomienie przy odbieraniu wiadomości z kanału", - "appSettings_advertisementNotifications": "Powiadomienia o rozgłoszeniach", - "appSettings_advertisementNotificationsSubtitle": "Wyświetl powiadomienie, gdy zostaną wykryte nowe węzły.", + "appSettings_advertisementNotifications": "Wykryto nowy węzeł", + "appSettings_advertisementNotificationsSubtitle": "Powiadamiaj o wykryciu nowych przemienników lub kontaktów", "appSettings_messaging": "Wiadomości", "appSettings_clearPathOnMaxRetry": "Wyczyść ścieżkę po maks. liczbie prób", "appSettings_clearPathOnMaxRetrySubtitle": "Resetuj ścieżkę kontaktu po 5 nieudanych próbach wysłania", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 05fe50a..4e7418b 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -191,8 +191,8 @@ "appSettings_messageNotificationsSubtitle": "Mostrar notificação ao receber novas mensagens", "appSettings_channelMessageNotifications": "Notificações de Mensagens do Canal", "appSettings_channelMessageNotificationsSubtitle": "Mostrar notificação ao receber mensagens do canal", - "appSettings_advertisementNotifications": "Notificações de Anúncios", - "appSettings_advertisementNotificationsSubtitle": "Mostrar notificação quando novos nós forem descobertos", + "appSettings_advertisementNotifications": "Novo nó descoberto", + "appSettings_advertisementNotificationsSubtitle": "Notificar quando novos repetidores ou contatos forem detectados", "appSettings_messaging": "Mensagens", "appSettings_clearPathOnMaxRetry": "Limpar Caminho em Tentativas Máximas", "appSettings_clearPathOnMaxRetrySubtitle": "Redefinir o caminho de contato após 5 tentativas de envio falhas", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 8fea350..ee0bb29 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -155,8 +155,8 @@ "appSettings_messageNotificationsSubtitle": "Показывать уведомление при получении новых сообщений", "appSettings_channelMessageNotifications": "Уведомления о сообщениях в каналах", "appSettings_channelMessageNotificationsSubtitle": "Показывать уведомление при получении сообщений в каналах", - "appSettings_advertisementNotifications": "Уведомления об анонсированиях", - "appSettings_advertisementNotificationsSubtitle": "Показывать уведомление при обнаружении новых нод", + "appSettings_advertisementNotifications": "Обнаружен новый узел", + "appSettings_advertisementNotificationsSubtitle": "Уведомлять при обнаружении новых репитеров или контактов", "appSettings_messaging": "Обмен сообщениями", "appSettings_clearPathOnMaxRetry": "Сбросить маршрут после максимального числа попыток", "appSettings_clearPathOnMaxRetrySubtitle": "Сбросить маршрут контакта после 5 неудачных попыток отправки", diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index 5892bc6..3a6e386 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -191,8 +191,8 @@ "appSettings_messageNotificationsSubtitle": "Zobraziť upozornenie pri prijímaní nových správ", "appSettings_channelMessageNotifications": "Notifikácie z kanálov", "appSettings_channelMessageNotificationsSubtitle": "Zobraziť upozornenie pri prijímaní správ z kanálu", - "appSettings_advertisementNotifications": "Upozornenia na reklamy", - "appSettings_advertisementNotificationsSubtitle": "Zobraziť upozornenie, keď sa objavia nové uzly.", + "appSettings_advertisementNotifications": "Zistený nový uzol", + "appSettings_advertisementNotificationsSubtitle": "Upozorniť pri zachytení nových prevádzačov alebo kontaktov", "appSettings_messaging": "Správy", "appSettings_clearPathOnMaxRetry": "Vyčisti cestu na Max Retry", "appSettings_clearPathOnMaxRetrySubtitle": "Resetovať kontaktný priebeh po 5 neúspešných pokusoch o doručenie", diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 15d6493..fc943d6 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -191,8 +191,8 @@ "appSettings_messageNotificationsSubtitle": "Pokaži obvestilo ob prejemu novih sporočil.", "appSettings_channelMessageNotifications": "Obvestila o sporočilih kanala", "appSettings_channelMessageNotificationsSubtitle": "Pokaži obvestilo ob prejemanju sporočil kanala", - "appSettings_advertisementNotifications": "Opozorila o oglasih", - "appSettings_advertisementNotificationsSubtitle": "Pokaži obvestilo, ko so najdene nove naprave.", + "appSettings_advertisementNotifications": "Zaznano novo vozlišče", + "appSettings_advertisementNotificationsSubtitle": "Obvesti ob zaznavi novih repetitorjev ali stikov", "appSettings_messaging": "Komuniciranje", "appSettings_clearPathOnMaxRetry": "Ponovite pot do cilja na največjem štetju", "appSettings_clearPathOnMaxRetrySubtitle": "Ponovi pot zimske obveščevalne poti po 5 neuspešnih poskusih pošiljanja", diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index ed22b2d..44f87ce 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -191,8 +191,8 @@ "appSettings_messageNotificationsSubtitle": "Visa notis när nya meddelanden tas emot", "appSettings_channelMessageNotifications": "Kanalmeddelandena", "appSettings_channelMessageNotificationsSubtitle": "Visa notis när meddelanden i kanal mottas", - "appSettings_advertisementNotifications": "Annonsmeddelanden", - "appSettings_advertisementNotificationsSubtitle": "Visa notis när nya noder upptäcks", + "appSettings_advertisementNotifications": "Ny nod upptäckt", + "appSettings_advertisementNotificationsSubtitle": "Avisera när nya repeatrar eller kontakter hörs", "appSettings_messaging": "Meddelanden", "appSettings_clearPathOnMaxRetry": "Rensa Vägen på Max Försök", "appSettings_clearPathOnMaxRetrySubtitle": "Återställ kontaktväg efter 5 misslyckade försök att skicka", diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index d3c5ec6..94e41e4 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -193,8 +193,8 @@ "appSettings_messageNotificationsSubtitle": "Показувати сповіщення при отриманні нових повідомлень", "appSettings_channelMessageNotifications": "Сповіщення каналів", "appSettings_channelMessageNotificationsSubtitle": "Показувати сповіщення при отриманні повідомлень каналу", - "appSettings_advertisementNotifications": "Сповіщення про оголошення", - "appSettings_advertisementNotificationsSubtitle": "Показувати сповіщення при виявленні нових вузлів", + "appSettings_advertisementNotifications": "Виявлено новий вузол", + "appSettings_advertisementNotificationsSubtitle": "Сповіщати про виявлення нових ретрансляторів або контактів", "appSettings_messaging": "Обмін повідомленнями", "appSettings_clearPathOnMaxRetry": "Очищати шлях після макс. спроб", "appSettings_clearPathOnMaxRetrySubtitle": "Скидати шлях до контакту після 5 невдалих спроб надсилання", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 4061b0a..fada6c3 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -205,8 +205,8 @@ "appSettings_messageNotificationsSubtitle": "收到新消息时显示通知", "appSettings_channelMessageNotifications": "频道消息通知", "appSettings_channelMessageNotificationsSubtitle": "收到频道消息时显示通知", - "appSettings_advertisementNotifications": "广播通知", - "appSettings_advertisementNotificationsSubtitle": "发现新节点时显示通知", + "appSettings_advertisementNotifications": "发现新节点", + "appSettings_advertisementNotificationsSubtitle": "当检测到新的中继器或联系人时通知", "appSettings_messaging": "消息", "appSettings_clearPathOnMaxRetry": "达到最大重试次数时清除路径", "appSettings_clearPathOnMaxRetrySubtitle": "在5次发送失败后重置联系路径。", From f64c27ef6f8ce061fc5cca6974e789d8fa27ab01 Mon Sep 17 00:00:00 2001 From: Strycher Date: Sat, 18 Jul 2026 02:43:36 -0400 Subject: [PATCH 15/62] refactor(#273): move Observer above App Settings Groups the list by kind: configuration (Node, Radio, Radio Stats, Privacy, Contacts, Blocked, Messages, Observer, App Settings), then readout (Device Info), then rarely-used Actions, then Debug. Co-Authored-By: Claude Opus 4.8 --- lib/screens/settings_screen.dart | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 890766b..4eaebc1 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -129,6 +129,13 @@ class _SettingsScreenState extends State { subtitle: l10n.settings_messageSettingsSubtitle, builder: _messageSettingsPane, ), + if (showObserver) + SettingsCategory( + icon: Icons.cloud_outlined, + title: 'Observer', + subtitle: 'WiFi · MQTT brokers · display', + builder: _observerPane, + ), SettingsCategory( icon: Icons.tune, title: l10n.settings_appSettings, @@ -140,13 +147,6 @@ class _SettingsScreenState extends State { title: l10n.settings_deviceInfo, builder: _devicePane, ), - if (showObserver) - SettingsCategory( - icon: Icons.cloud_outlined, - title: 'Observer', - subtitle: 'WiFi · MQTT brokers · display', - builder: _observerPane, - ), SettingsCategory( icon: Icons.bolt_outlined, title: l10n.settings_actions, From 4fd5f76a139e467906fc931d1b7583908a697886 Mon Sep 17 00:00:00 2001 From: Strycher Date: Sun, 19 Jul 2026 05:05:07 -0400 Subject: [PATCH 16/62] feat(#297): send a stable per-install client_id in CMD_APP_START Wadamesh keys its history watermark (last_delivered_seq) by client_id. We sent none, so we shared the empty-string slot with every other MeshCore client on the machine: whichever connected first drained the device history ring and the next app got NO_MORE_MESSAGES for frames it never received. cid_len is 6 by necessity, not preference. Stock reads cmd_frame[1..7] as reserved with the app name at a fixed offset 8; Wadamesh reads the name at 2 + cid_len. Only 6 puts the name at 8 on both, so one frame serves both firmwares with no firmware change. Covered by app_start_frame_test. Co-Authored-By: Claude Opus 4.8 --- lib/connector/meshcore_connector.dart | 11 +++-- lib/connector/meshcore_protocol.dart | 25 ++++++++-- lib/storage/client_id_store.dart | 59 ++++++++++++++++++++++++ test/connector/app_start_frame_test.dart | 59 ++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 8 deletions(-) create mode 100644 lib/storage/client_id_store.dart create mode 100644 test/connector/app_start_frame_test.dart diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index e87e6af..61d916d 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -47,6 +47,7 @@ import '../storage/channel_message_store.dart'; import '../storage/channel_order_store.dart'; import '../storage/channel_settings_store.dart'; import '../storage/channel_store.dart'; +import '../storage/client_id_store.dart'; import '../storage/contact_discovery_store.dart'; import '../storage/contact_settings_store.dart'; import '../storage/contact_store.dart'; @@ -3056,7 +3057,7 @@ class MeshCoreConnector extends ChangeNotifier { _webInitialHandshakeRequestSent = true; } await sendFrame(buildDeviceQueryFrame()); - await sendFrame(buildAppStartFrame()); + await sendFrame(buildAppStartFrame(clientId: ClientIdStore.load())); await requestBatteryStatus(force: true); await sendFrame(buildGetCustomVarsFrame()); await sendFrame(buildGetAutoAddFlagsFrame()); @@ -3079,7 +3080,7 @@ class MeshCoreConnector extends ChangeNotifier { _webInitialHandshakeRequestSent = true; } await sendFrame(buildDeviceQueryFrame()); - await sendFrame(buildAppStartFrame()); + await sendFrame(buildAppStartFrame(clientId: ClientIdStore.load())); await sendFrame(buildGetCustomVarsFrame()); await requestBatteryStatus(); await sendFrame(buildGetAutoAddFlagsFrame()); @@ -3122,7 +3123,9 @@ class MeshCoreConnector extends ChangeNotifier { return; } attempts += 1; - unawaited(sendFrame(buildAppStartFrame())); + unawaited( + sendFrame(buildAppStartFrame(clientId: ClientIdStore.load())), + ); if (attempts >= maxAttempts) { timer.cancel(); } @@ -3155,7 +3158,7 @@ class MeshCoreConnector extends ChangeNotifier { awaitingSelfInfo: _awaitingSelfInfo, syncing: syncing, )) { - unawaited(sendFrame(buildAppStartFrame())); + unawaited(sendFrame(buildAppStartFrame(clientId: ClientIdStore.load()))); if (_appStartRetryAttempt < 5) _appStartRetryAttempt++; } _armAppStartRetry(); diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index f486e79..36eba0e 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:math'; import 'dart:typed_data'; import 'package:flutter/widgets.dart'; @@ -757,16 +758,32 @@ Uint8List buildRemoveContactFrame(Uint8List pubKey) { return writer.toBytes(); } +/// Byte length of the client id carried in [buildAppStartFrame] (#297). +/// +/// 6 is load-bearing, not arbitrary: stock firmware treats `cmd_frame[1..7]` as +/// reserved and reads the app name at a FIXED offset 8, while Wadamesh reads +/// byte 1 as the client-id length and the app name at `2 + cid_len`. Only +/// `cid_len == 6` puts the name at 8 for both, so one frame serves both. +const int clientIdLength = 6; + // Build CMD_APP_START frame -// Format: [cmd][app_ver][reserved x6][app_name...] +// Format: [cmd][cid_len=6][client_id x6][app_name...] +// Stock reads bytes 1..7 as reserved + name at 8; Wadamesh reads the client id +// and lands on the same name offset. See [clientIdLength]. Uint8List buildAppStartFrame({ String appName = 'MeshCoreOpen', - int appVersion = 1, + Uint8List? clientId, }) { + final id = Uint8List(clientIdLength); + if (clientId != null) { + // Truncate or zero-pad: the length byte must stay 6 or the app-name offset + // desyncs on one of the two firmwares. + id.setRange(0, min(clientId.length, clientIdLength), clientId); + } final writer = BufferWriter(); writer.writeByte(cmdAppStart); - writer.writeByte(appVersion); - writer.writeBytes(Uint8List(6)); // reserved bytes + writer.writeByte(clientIdLength); + writer.writeBytes(id); writer.writeString(appName); writer.writeByte(0); return writer.toBytes(); diff --git a/lib/storage/client_id_store.dart b/lib/storage/client_id_store.dart new file mode 100644 index 0000000..742c27e --- /dev/null +++ b/lib/storage/client_id_store.dart @@ -0,0 +1,59 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import 'prefs_manager.dart'; + +/// Per-install client identity sent in `CMD_APP_START` (#297). +/// +/// Wadamesh keeps a per-client history watermark (`last_delivered_seq`) keyed by +/// this id. Without one we land in the shared empty-string slot alongside every +/// other MeshCore client on the machine, so whichever app connects first drains +/// the device's history ring and the next app is told "no more messages" for +/// frames it never received. +/// +/// The id is exactly [length] bytes so a single frame satisfies both firmwares: +/// stock treats `cmd_frame[1..7]` as reserved and reads the app name at a fixed +/// offset 8, while Wadamesh reads it at `2 + cid_len`. With `cid_len == 6` both +/// land on 8. +class ClientIdStore { + static const String _key = 'client_install_id'; + + /// Byte length of the id. Do not change without re-checking both firmwares — + /// 6 is what keeps the app-name offset agreeing at 8. + static const int length = 6; + + /// The install's client id, generated and persisted on first use so it is + /// stable across builds (all builds share one SharedPreferences store). + static Uint8List load() { + final prefs = PrefsManager.instance; + final stored = prefs.getString(_key); + if (stored != null && stored.length == length * 2) { + final bytes = Uint8List(length); + for (var i = 0; i < length; i++) { + final byte = int.tryParse( + stored.substring(i * 2, i * 2 + 2), + radix: 16, + ); + if (byte == null) { + return _generate(prefs); + } + bytes[i] = byte; + } + return bytes; + } + return _generate(prefs); + } + + static Uint8List _generate(SharedPreferences prefs) { + final random = Random.secure(); + final bytes = Uint8List(length); + for (var i = 0; i < length; i++) { + bytes[i] = random.nextInt(256); + } + final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + prefs.setString(_key, hex); + return bytes; + } +} diff --git a/test/connector/app_start_frame_test.dart b/test/connector/app_start_frame_test.dart new file mode 100644 index 0000000..c7bf1ff --- /dev/null +++ b/test/connector/app_start_frame_test.dart @@ -0,0 +1,59 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/connector/meshcore_protocol.dart'; + +/// The client-id length in CMD_APP_START is load-bearing (#297). +/// +/// Stock firmware treats `cmd_frame[1..7]` as reserved and reads the app name at +/// a FIXED offset 8. Wadamesh reads byte 1 as the client-id length and the app +/// name at `2 + cid_len`. Only `cid_len == 6` satisfies both. If this test ever +/// fails, one of the two firmwares is now reading the app name out of the +/// client-id bytes. +void main() { + group('buildAppStartFrame', () { + test('declares a 6-byte client id so the app name lands at offset 8', () { + final clientId = Uint8List.fromList([1, 2, 3, 4, 5, 6]); + final frame = buildAppStartFrame(appName: 'Offband', clientId: clientId); + + expect(frame[0], cmdAppStart); + expect(frame[1], 6, reason: 'cid_len must be 6 for both firmwares'); + expect(frame.sublist(2, 8), clientId); + expect( + String.fromCharCodes(frame.sublist(8, 8 + 'Offband'.length)), + 'Offband', + reason: 'app name must start at offset 8', + ); + }); + + test('zero-pads a short client id without moving the app name', () { + final frame = buildAppStartFrame( + appName: 'X', + clientId: Uint8List.fromList([0xAA, 0xBB]), + ); + + expect(frame[1], 6); + expect(frame.sublist(2, 8), [0xAA, 0xBB, 0, 0, 0, 0]); + expect(String.fromCharCodes(frame.sublist(8, 9)), 'X'); + }); + + test('truncates an over-long client id without moving the app name', () { + final frame = buildAppStartFrame( + appName: 'X', + clientId: Uint8List.fromList([1, 2, 3, 4, 5, 6, 7, 8]), + ); + + expect(frame[1], 6); + expect(frame.sublist(2, 8), [1, 2, 3, 4, 5, 6]); + expect(String.fromCharCodes(frame.sublist(8, 9)), 'X'); + }); + + test('omitted client id still keeps the frame shape', () { + final frame = buildAppStartFrame(appName: 'X'); + + expect(frame[1], 6); + expect(frame.sublist(2, 8), [0, 0, 0, 0, 0, 0]); + expect(String.fromCharCodes(frame.sublist(8, 9)), 'X'); + }); + }); +} From f516163614f24c7e6a9c0036299852375ea463a1 Mon Sep 17 00:00:00 2001 From: Strycher Date: Sun, 19 Jul 2026 05:07:06 -0400 Subject: [PATCH 17/62] feat(#295): surface the active radio data scope in Device Info Stores are keyed by the first 10 hex of the connected radio's public key, so connecting a different radio silently swaps which contacts, channels, and history you are viewing with nothing in the UI saying so. Device Info showed the full public key but never tied it to storage. Adds a Data scope row with the key actually in effect, plus a one-line explanation. Co-Authored-By: Claude Opus 4.8 --- lib/l10n/app_en.arb | 2 ++ lib/l10n/app_localizations.dart | 12 +++++++++++ lib/l10n/app_localizations_bg.dart | 7 ++++++ lib/l10n/app_localizations_de.dart | 7 ++++++ lib/l10n/app_localizations_en.dart | 7 ++++++ lib/l10n/app_localizations_es.dart | 7 ++++++ lib/l10n/app_localizations_fr.dart | 7 ++++++ lib/l10n/app_localizations_hu.dart | 7 ++++++ lib/l10n/app_localizations_it.dart | 7 ++++++ lib/l10n/app_localizations_ja.dart | 7 ++++++ lib/l10n/app_localizations_ko.dart | 7 ++++++ lib/l10n/app_localizations_nl.dart | 7 ++++++ lib/l10n/app_localizations_pl.dart | 7 ++++++ lib/l10n/app_localizations_pt.dart | 7 ++++++ lib/l10n/app_localizations_ru.dart | 7 ++++++ lib/l10n/app_localizations_sk.dart | 7 ++++++ lib/l10n/app_localizations_sl.dart | 7 ++++++ lib/l10n/app_localizations_sv.dart | 7 ++++++ lib/l10n/app_localizations_uk.dart | 7 ++++++ lib/l10n/app_localizations_zh.dart | 7 ++++++ lib/screens/settings_screen.dart | 27 +++++++++++++++++++++++- untranslated.json | 34 ++++++++++++++++++++++++++++++ 22 files changed, 200 insertions(+), 1 deletion(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2c3ba6a..e27f1a8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -239,6 +239,8 @@ "settings_infoOffbandCaps": "Offband capabilities", "settings_infoBattery": "Battery", "settings_infoPublicKey": "Public Key", + "settings_infoDataScope": "Data scope", + "settings_infoDataScopeSubtitle": "Messages, contacts, and channels are stored per radio under this key", "settings_publicKeyCopied": "Public key copied", "settings_infoContactsCount": "Contacts Count", "settings_infoChannelCount": "Channel Count", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index dc5b2f5..c488d29 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1192,6 +1192,18 @@ abstract class AppLocalizations { /// **'Public Key'** String get settings_infoPublicKey; + /// No description provided for @settings_infoDataScope. + /// + /// In en, this message translates to: + /// **'Data scope'** + String get settings_infoDataScope; + + /// No description provided for @settings_infoDataScopeSubtitle. + /// + /// In en, this message translates to: + /// **'Messages, contacts, and channels are stored per radio under this key'** + String get settings_infoDataScopeSubtitle; + /// No description provided for @settings_publicKeyCopied. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 1947f9a..074e459 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -599,6 +599,13 @@ class AppLocalizationsBg extends AppLocalizations { @override String get settings_infoPublicKey => 'Общ публичен ключ'; + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 78edf42..5c9c312 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -594,6 +594,13 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settings_infoPublicKey => 'Öffentlicher Schlüssel'; + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index de46a90..308645f 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -584,6 +584,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settings_infoPublicKey => 'Public Key'; + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index aecb9c4..8d5c34b 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -595,6 +595,13 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settings_infoPublicKey => 'Clave Pública'; + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 5c0dc85..89071b8 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -599,6 +599,13 @@ class AppLocalizationsFr extends AppLocalizations { @override String get settings_infoPublicKey => 'Clé Publique'; + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index 548c17e..20569f8 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -597,6 +597,13 @@ class AppLocalizationsHu extends AppLocalizations { @override String get settings_infoPublicKey => 'Nyelvkönyv'; + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index b095a57..09a3bac 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -597,6 +597,13 @@ class AppLocalizationsIt extends AppLocalizations { @override String get settings_infoPublicKey => 'Chiave Pubblica'; + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index cd68df0..8e0cedf 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -570,6 +570,13 @@ class AppLocalizationsJa extends AppLocalizations { @override String get settings_infoPublicKey => '公開鍵'; + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index d1d8762..6600a52 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -570,6 +570,13 @@ class AppLocalizationsKo extends AppLocalizations { @override String get settings_infoPublicKey => '공개 키'; + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 99ffb6e..876564e 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -591,6 +591,13 @@ class AppLocalizationsNl extends AppLocalizations { @override String get settings_infoPublicKey => 'Openbare Sleutel'; + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 125f62c..d2ab176 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -599,6 +599,13 @@ class AppLocalizationsPl extends AppLocalizations { @override String get settings_infoPublicKey => 'Klucz Publiczny'; + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 3d2a5cc..fe03cdc 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -597,6 +597,13 @@ class AppLocalizationsPt extends AppLocalizations { @override String get settings_infoPublicKey => 'Chave Pública'; + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 262fec1..7bb8618 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -596,6 +596,13 @@ class AppLocalizationsRu extends AppLocalizations { @override String get settings_infoPublicKey => 'Публичный ключ'; + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index c31de4c..241d1ff 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -591,6 +591,13 @@ class AppLocalizationsSk extends AppLocalizations { @override String get settings_infoPublicKey => 'Verejný kľúč'; + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 41fd764..663ee17 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -588,6 +588,13 @@ class AppLocalizationsSl extends AppLocalizations { @override String get settings_infoPublicKey => 'Javni ključ'; + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index af3fc36..8c1a8c1 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -586,6 +586,13 @@ class AppLocalizationsSv extends AppLocalizations { @override String get settings_infoPublicKey => 'Allmänt nyckel'; + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index fc9845a..eed8994 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -594,6 +594,13 @@ class AppLocalizationsUk extends AppLocalizations { @override String get settings_infoPublicKey => 'Відкритий ключ'; + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index c1b4d10..2dd550c 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -562,6 +562,13 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settings_infoPublicKey => '公钥'; + @override + String get settings_infoDataScope => 'Data scope'; + + @override + String get settings_infoDataScopeSubtitle => + 'Messages, contacts, and channels are stored per radio under this key'; + @override String get settings_publicKeyCopied => 'Public key copied'; diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 4eaebc1..75764e4 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -418,12 +418,37 @@ class _SettingsScreenState extends State { _buildBatteryInfoRow(context, connector), if (connector.selfName != null) _buildInfoRow(l10n.settings_nodeName, connector.selfName!), - if (connector.selfPublicKey != null) + if (connector.selfPublicKey != null) ...[ _buildInfoRow( l10n.settings_infoPublicKey, pubKeyToHex(connector.selfPublicKey!), copyValue: pubKeyToHex(connector.selfPublicKey!), ), + // #295: stores are keyed by the first 10 hex of the connected + // radio's public key, so switching radios silently swaps which + // history you are looking at. Surface the key that is in effect. + ...() { + final hex = pubKeyToHex(connector.selfPublicKey!); + final scope = hex.length > 10 ? hex.substring(0, 10) : hex; + if (scope.isEmpty) return []; + return [ + _buildInfoRow( + l10n.settings_infoDataScope, + scope, + copyValue: scope, + ), + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + l10n.settings_infoDataScopeSubtitle, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ]; + }(), + ], _buildInfoRow( l10n.settings_infoContactsCount, '${connector.contacts.length}', diff --git a/untranslated.json b/untranslated.json index c6bcee5..eb27db7 100644 --- a/untranslated.json +++ b/untranslated.json @@ -15,6 +15,8 @@ "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", + "settings_infoDataScope", + "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", "settings_femLna", "settings_femLnaSubtitle", @@ -78,6 +80,8 @@ "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", + "settings_infoDataScope", + "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", "settings_femLna", "settings_femLnaSubtitle", @@ -141,6 +145,8 @@ "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", + "settings_infoDataScope", + "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", "settings_femLna", "settings_femLnaSubtitle", @@ -204,6 +210,8 @@ "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", + "settings_infoDataScope", + "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", "settings_femLna", "settings_femLnaSubtitle", @@ -267,6 +275,8 @@ "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", + "settings_infoDataScope", + "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", "settings_femLna", "settings_femLnaSubtitle", @@ -330,6 +340,8 @@ "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", + "settings_infoDataScope", + "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", "settings_femLna", "settings_femLnaSubtitle", @@ -393,6 +405,8 @@ "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", + "settings_infoDataScope", + "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", "settings_femLna", "settings_femLnaSubtitle", @@ -456,6 +470,8 @@ "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", + "settings_infoDataScope", + "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", "settings_femLna", "settings_femLnaSubtitle", @@ -519,6 +535,8 @@ "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", + "settings_infoDataScope", + "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", "settings_femLna", "settings_femLnaSubtitle", @@ -582,6 +600,8 @@ "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", + "settings_infoDataScope", + "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", "settings_femLna", "settings_femLnaSubtitle", @@ -645,6 +665,8 @@ "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", + "settings_infoDataScope", + "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", "settings_femLna", "settings_femLnaSubtitle", @@ -708,6 +730,8 @@ "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", + "settings_infoDataScope", + "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", "settings_femLna", "settings_femLnaSubtitle", @@ -771,6 +795,8 @@ "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", + "settings_infoDataScope", + "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", "settings_femLna", "settings_femLnaSubtitle", @@ -834,6 +860,8 @@ "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", + "settings_infoDataScope", + "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", "settings_femLna", "settings_femLnaSubtitle", @@ -897,6 +925,8 @@ "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", + "settings_infoDataScope", + "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", "settings_femLna", "settings_femLnaSubtitle", @@ -960,6 +990,8 @@ "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", + "settings_infoDataScope", + "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", "settings_femLna", "settings_femLnaSubtitle", @@ -1023,6 +1055,8 @@ "settings_infoFirmware", "settings_infoModel", "settings_infoOffbandCaps", + "settings_infoDataScope", + "settings_infoDataScopeSubtitle", "settings_publicKeyCopied", "settings_femLna", "settings_femLnaSubtitle", From bd34682f36084faa15862b6f0b5ea7f92a7f1383 Mon Sep 17 00:00:00 2001 From: Strycher Date: Mon, 27 Jul 2026 02:21:54 -0400 Subject: [PATCH 18/62] feat(#397): show running build identity (branch @ sha, build time) in Device Info No in-app signal of which binary is running has caused repeated confusion (a debug-signed APK silently not installing over the release app, old layout persisting with nothing to indicate the new build never landed). BuildInfo reads GIT_SHA/GIT_BRANCH/BUILD_TIME from --dart-define with dev fallbacks, so every build carries its own identity independent of the pubspec version. Surfaced as its own copyable Build row in Device Info. Co-Authored-By: Claude Opus 4.8 --- lib/l10n/app_en.arb | 1 + lib/l10n/app_localizations.dart | 6 +++++ lib/l10n/app_localizations_bg.dart | 3 +++ lib/l10n/app_localizations_de.dart | 3 +++ lib/l10n/app_localizations_en.dart | 3 +++ lib/l10n/app_localizations_es.dart | 3 +++ lib/l10n/app_localizations_fr.dart | 3 +++ lib/l10n/app_localizations_hu.dart | 3 +++ lib/l10n/app_localizations_it.dart | 3 +++ lib/l10n/app_localizations_ja.dart | 3 +++ lib/l10n/app_localizations_ko.dart | 3 +++ lib/l10n/app_localizations_nl.dart | 3 +++ lib/l10n/app_localizations_pl.dart | 3 +++ lib/l10n/app_localizations_pt.dart | 3 +++ lib/l10n/app_localizations_ru.dart | 3 +++ lib/l10n/app_localizations_sk.dart | 3 +++ lib/l10n/app_localizations_sl.dart | 3 +++ lib/l10n/app_localizations_sv.dart | 3 +++ lib/l10n/app_localizations_uk.dart | 3 +++ lib/l10n/app_localizations_zh.dart | 3 +++ lib/screens/settings_screen.dart | 9 ++++++++ lib/utils/build_info.dart | 35 ++++++++++++++++++++++++++++++ untranslated.json | 34 +++++++++++++++++++++++++++++ 23 files changed, 139 insertions(+) create mode 100644 lib/utils/build_info.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e27f1a8..d24df1c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -239,6 +239,7 @@ "settings_infoOffbandCaps": "Offband capabilities", "settings_infoBattery": "Battery", "settings_infoPublicKey": "Public Key", + "settings_infoBuild": "Build", "settings_infoDataScope": "Data scope", "settings_infoDataScopeSubtitle": "Messages, contacts, and channels are stored per radio under this key", "settings_publicKeyCopied": "Public key copied", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index c488d29..d315a48 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1192,6 +1192,12 @@ abstract class AppLocalizations { /// **'Public Key'** String get settings_infoPublicKey; + /// No description provided for @settings_infoBuild. + /// + /// In en, this message translates to: + /// **'Build'** + String get settings_infoBuild; + /// No description provided for @settings_infoDataScope. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 074e459..06e8c28 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -599,6 +599,9 @@ class AppLocalizationsBg extends AppLocalizations { @override String get settings_infoPublicKey => 'Общ публичен ключ'; + @override + String get settings_infoBuild => 'Build'; + @override String get settings_infoDataScope => 'Data scope'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 5c9c312..e314e80 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -594,6 +594,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get settings_infoPublicKey => 'Öffentlicher Schlüssel'; + @override + String get settings_infoBuild => 'Build'; + @override String get settings_infoDataScope => 'Data scope'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 308645f..9585df4 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -584,6 +584,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settings_infoPublicKey => 'Public Key'; + @override + String get settings_infoBuild => 'Build'; + @override String get settings_infoDataScope => 'Data scope'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 8d5c34b..f422697 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -595,6 +595,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get settings_infoPublicKey => 'Clave Pública'; + @override + String get settings_infoBuild => 'Build'; + @override String get settings_infoDataScope => 'Data scope'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 89071b8..2bffe9c 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -599,6 +599,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get settings_infoPublicKey => 'Clé Publique'; + @override + String get settings_infoBuild => 'Build'; + @override String get settings_infoDataScope => 'Data scope'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index 20569f8..a5e3ac6 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -597,6 +597,9 @@ class AppLocalizationsHu extends AppLocalizations { @override String get settings_infoPublicKey => 'Nyelvkönyv'; + @override + String get settings_infoBuild => 'Build'; + @override String get settings_infoDataScope => 'Data scope'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 09a3bac..e1d6be8 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -597,6 +597,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get settings_infoPublicKey => 'Chiave Pubblica'; + @override + String get settings_infoBuild => 'Build'; + @override String get settings_infoDataScope => 'Data scope'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 8e0cedf..a5ce421 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -570,6 +570,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get settings_infoPublicKey => '公開鍵'; + @override + String get settings_infoBuild => 'Build'; + @override String get settings_infoDataScope => 'Data scope'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 6600a52..10f147b 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -570,6 +570,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get settings_infoPublicKey => '공개 키'; + @override + String get settings_infoBuild => 'Build'; + @override String get settings_infoDataScope => 'Data scope'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 876564e..5fa5a12 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -591,6 +591,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get settings_infoPublicKey => 'Openbare Sleutel'; + @override + String get settings_infoBuild => 'Build'; + @override String get settings_infoDataScope => 'Data scope'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index d2ab176..dd603df 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -599,6 +599,9 @@ class AppLocalizationsPl extends AppLocalizations { @override String get settings_infoPublicKey => 'Klucz Publiczny'; + @override + String get settings_infoBuild => 'Build'; + @override String get settings_infoDataScope => 'Data scope'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index fe03cdc..76dcdf7 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -597,6 +597,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get settings_infoPublicKey => 'Chave Pública'; + @override + String get settings_infoBuild => 'Build'; + @override String get settings_infoDataScope => 'Data scope'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 7bb8618..6612522 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -596,6 +596,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get settings_infoPublicKey => 'Публичный ключ'; + @override + String get settings_infoBuild => 'Build'; + @override String get settings_infoDataScope => 'Data scope'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 241d1ff..b9a0630 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -591,6 +591,9 @@ class AppLocalizationsSk extends AppLocalizations { @override String get settings_infoPublicKey => 'Verejný kľúč'; + @override + String get settings_infoBuild => 'Build'; + @override String get settings_infoDataScope => 'Data scope'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 663ee17..93690a5 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -588,6 +588,9 @@ class AppLocalizationsSl extends AppLocalizations { @override String get settings_infoPublicKey => 'Javni ključ'; + @override + String get settings_infoBuild => 'Build'; + @override String get settings_infoDataScope => 'Data scope'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 8c1a8c1..0bb2b72 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -586,6 +586,9 @@ class AppLocalizationsSv extends AppLocalizations { @override String get settings_infoPublicKey => 'Allmänt nyckel'; + @override + String get settings_infoBuild => 'Build'; + @override String get settings_infoDataScope => 'Data scope'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index eed8994..1e5c405 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -594,6 +594,9 @@ class AppLocalizationsUk extends AppLocalizations { @override String get settings_infoPublicKey => 'Відкритий ключ'; + @override + String get settings_infoBuild => 'Build'; + @override String get settings_infoDataScope => 'Data scope'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 2dd550c..af2ed6d 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -562,6 +562,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settings_infoPublicKey => '公钥'; + @override + String get settings_infoBuild => 'Build'; + @override String get settings_infoDataScope => 'Data scope'; diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 75764e4..29977bd 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -15,6 +15,7 @@ import '../services/app_debug_log_service.dart'; import '../services/app_settings_service.dart'; import '../connector/observer_config_client.dart'; import '../helpers/snack_bar_builder.dart'; +import '../utils/build_info.dart'; import 'settings/settings_shell.dart'; import 'settings/app_settings_view.dart'; import 'settings/message_settings_view.dart'; @@ -457,6 +458,14 @@ class _SettingsScreenState extends State { l10n.settings_infoChannelCount, '${connector.channels.length}', ), + // #397: identity of the running binary, injected at build time and + // independent of the marketing version. Its own line so "which build + // am I on" is answerable at a glance; copyable for bug reports. + _buildInfoRow( + l10n.settings_infoBuild, + BuildInfo.stamp, + copyValue: BuildInfo.stamp, + ), ], ), ), diff --git a/lib/utils/build_info.dart b/lib/utils/build_info.dart new file mode 100644 index 0000000..00d5b34 --- /dev/null +++ b/lib/utils/build_info.dart @@ -0,0 +1,35 @@ +/// Identity of the running binary, injected at build time (#397). +/// +/// Set via `--dart-define` so every build — debug or release, dev or prod — +/// carries its own identity, independent of the pubspec marketing version: +/// +/// ``` +/// flutter build apk --release \ +/// --dart-define=GIT_SHA=$(git rev-parse --short HEAD) \ +/// --dart-define=GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) \ +/// --dart-define=BUILD_TIME=$(date -u +%Y-%m-%dT%H:%MZ) +/// ``` +/// +/// Falls back to `dev` / `unknown` when the defines are absent, so a plain +/// `flutter run` still works and never crashes on a missing value. +class BuildInfo { + const BuildInfo._(); + + static const String gitSha = String.fromEnvironment( + 'GIT_SHA', + defaultValue: 'dev', + ); + + static const String gitBranch = String.fromEnvironment( + 'GIT_BRANCH', + defaultValue: 'dev', + ); + + static const String buildTime = String.fromEnvironment( + 'BUILD_TIME', + defaultValue: 'unknown', + ); + + /// One-line stamp for the UI: `branch @ sha · built