From aa2b5f8e821d57a2e9a58ea00996962c07775d0a Mon Sep 17 00:00:00 2001 From: Suman Biswas Date: Sat, 7 Nov 2026 19:13:43 +0530 Subject: [PATCH] fix(mob): stop the local database from becoming unreadable Concurrent first calls to DatabaseHelper.database each ran _initDB, so two random encryption keys could be generated and the stored one no longer matched the file. Share one initialisation future, and if an existing database still can't be opened, recreate it (Signal keys are regenerated and republished on sign-in) instead of failing forever. The inbox now shows a load error with a retry action instead of an empty "No conversations yet" state. Closes #159 --- .../controllers/chat/inbox_controller.dart | 10 ++++++ mobile/lib/pages/home/home_page.dart | 8 +++++ mobile/lib/services/db_services.dart | 31 ++++++++++++++++--- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/mobile/lib/controllers/chat/inbox_controller.dart b/mobile/lib/controllers/chat/inbox_controller.dart index 90e167d..4a2ae20 100644 --- a/mobile/lib/controllers/chat/inbox_controller.dart +++ b/mobile/lib/controllers/chat/inbox_controller.dart @@ -14,12 +14,16 @@ class InboxController extends ChangeNotifier { bool isLoading = false; bool hasLoadedOnce = false; + /// Why the last load failed, shown instead of an empty inbox. + String? loadError; + List get groups => inbox.where((item) => item.isGroup).toList(); int get unreadChats => inbox.where((item) => item.unreadCount > 0).length; void clearInbox() { inbox = []; hasLoadedOnce = false; + loadError = null; notifyListeners(); } @@ -36,6 +40,7 @@ class InboxController extends ChangeNotifier { } } catch (e) { debugPrint("Failed to load local inbox cache: $e"); + loadError = "Couldn't open your saved conversations."; } if (isOffline) return; @@ -88,6 +93,7 @@ class InboxController extends ChangeNotifier { inbox = combined; hasLoadedOnce = true; + loadError = null; await _replaceInboxInDb(inbox); for (final chat in chatsToCatchUp) { @@ -97,6 +103,10 @@ class InboxController extends ChangeNotifier { } } catch (e) { debugPrint("Inbox fetch failed: $e"); + loadError = ApiService.errorMessage( + e, + fallback: "Couldn't load your conversations.", + ); } finally { isLoading = false; notifyListeners(); diff --git a/mobile/lib/pages/home/home_page.dart b/mobile/lib/pages/home/home_page.dart index 9ed8725..a39ec34 100644 --- a/mobile/lib/pages/home/home_page.dart +++ b/mobile/lib/pages/home/home_page.dart @@ -308,6 +308,14 @@ class _ChatsTabState extends State<_ChatsTab> { title: 'No matches', message: 'No chat matches "$_query".', ) + : inbox.loadError != null + ? EmptyState( + icon: Icons.cloud_off_rounded, + title: 'Couldn\'t load chats', + message: inbox.loadError!, + actionLabel: 'Try again', + onAction: () => inbox.loadInbox(), + ) : EmptyState( icon: Icons.forum_rounded, title: 'No conversations yet', diff --git a/mobile/lib/services/db_services.dart b/mobile/lib/services/db_services.dart index 500e98a..c19f55e 100644 --- a/mobile/lib/services/db_services.dart +++ b/mobile/lib/services/db_services.dart @@ -10,16 +10,24 @@ import '../core/message_envelope.dart'; class DatabaseHelper { static final DatabaseHelper instance = DatabaseHelper._init(); static Database? _database; + static Future? _opening; final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); static const String _dbName = 'secure_chat.db'; DatabaseHelper._init(); + /// Opens the database once. Concurrent first callers share the same + /// future, so the encryption key is only ever generated once. Future get database async { - if (_database != null) return _database!; - _database = await _initDB(_dbName); - return _database!; + final db = _database; + if (db != null) return db; + final opening = _opening ??= _initDB(_dbName); + try { + return _database = await opening; + } finally { + _opening = null; + } } Future _getEncryptionKey() async { @@ -63,7 +71,22 @@ class DatabaseHelper { final path = join(dbPath, filePath); final password = await _getEncryptionKey(); - return await openDatabase( + try { + return await _open(path, password); + } on DatabaseException catch (e) { + if (!await databaseExists(path)) rethrow; + // The file was encrypted with a key we no longer have. It only holds a + // cache and this device's Signal keys, which are regenerated and + // republished on the next sign-in, so start over instead of failing on + // every launch. + debugPrint('Local database unreadable, recreating it: $e'); + await deleteDatabase(path); + return await _open(path, password); + } + } + + Future _open(String path, String password) { + return openDatabase( path, version: 2, password: password,