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,