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); + }); +}