fix(#385): surface storage-init failure with a persistent warning

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 <noreply@anthropic.com>
feat/385-storage-health
Strycher 2 months ago
parent b6a801166a
commit 4f9db98e53

@ -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)."
}
}

@ -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

@ -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.';
}

@ -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.';
}

@ -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.';
}

@ -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.';
}

@ -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.';
}

@ -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.';
}

@ -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.';
}

@ -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.';
}

@ -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.';
}

@ -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.';
}

@ -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.';
}

@ -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.';
}

@ -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.';
}

@ -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.';
}

@ -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.';
}

@ -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.';
}

@ -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.';
}

@ -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.';
}

@ -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<SystemUiOverlayStyle>(
value: _systemUiOverlayStyle(context),
child: child ?? const SizedBox.shrink(),
child: Consumer<StorageHealthService>(
builder: (context, health, _) => StorageUnavailableBanner(
show: !health.available,
child: child ?? const SizedBox.shrink(),
),
),
);
},
home: (PlatformInfo.isWeb && !PlatformInfo.isChrome)

@ -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();
}
}

@ -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<bool> verifyReadWrite() => _db.verifyReadWrite();
Future<void> write(String key, String value) async {
await _db
.into(_db.storedBlobs)

@ -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),
],
);
}
}

@ -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');
});
}

@ -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);
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.