From 824de4ed5edaa0109ae1c9229c83640372cf0ef8 Mon Sep 17 00:00:00 2001 From: Rohit <40559587+Rohit3523@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:56:39 +0530 Subject: [PATCH 1/8] fix(android): migrate legacy experimental databases on upgrade Pre-4.73 experimental builds stored WatermelonDB files as -experimental.db.db; unified builds use .db.db. Without a rename, upgraders silently start with an empty database. Rename legacy files when the unified name is missing; never overwrite; never break startup. --- .../rocket/reactnative/MainApplication.kt | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt index 56c88d46277..a91016b402c 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt @@ -66,6 +66,9 @@ open class MainApplication : Application(), ReactApplication { override fun onCreate() { super.onCreate() + // Migrate pre-4.73 experimental databases before React Native boots (no db open yet). + migrateLegacyExperimentalDatabases() + Bugsnag.start(this) // Initialize MMKV encryption - reads existing key or generates new one @@ -82,4 +85,27 @@ open class MainApplication : Application(), ReactApplication { super.onConfigurationChanged(newConfig) ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig) } + + // Rename pre-4.73 `-experimental.db` files to unified names when missing; never overwrites. + private fun migrateLegacyExperimentalDatabases() { + try { + val dirs = listOfNotNull(filesDir?.parentFile, getDatabasePath("probe").parentFile) + for (dir in dirs) { + val files = dir.listFiles() ?: continue + for (file in files) { + if (!file.isFile || !file.name.contains("-experimental.db")) { + continue + } + val target = java.io.File(dir, file.name.replace("-experimental.db", ".db")) + if (target.exists()) { + continue + } + file.renameTo(target) + } + } + } catch (e: Exception) { + // Migration must never break startup. Worst case the app starts with a fresh + // database and the startup saga falls back to the logged-out flow. + } + } } From c6e7724fca3d8f7e2228c7e7d74272ada89c8923 Mon Sep 17 00:00:00 2001 From: Rohit <40559587+Rohit3523@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:56:39 +0530 Subject: [PATCH 2/8] fix(ios): migrate legacy experimental databases on upgrade iOS builds had IS_OFFICIAL hardcoded false, so every pre-4.73 install stored WatermelonDB files as -experimental.db in the App Group container; unified builds use .db. Rename legacy files when the unified name is missing; never overwrite; never break startup. --- ios/AppDelegate.swift | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/ios/AppDelegate.swift b/ios/AppDelegate.swift index 2699b2c7fba..2239f3fbd0f 100644 --- a/ios/AppDelegate.swift +++ b/ios/AppDelegate.swift @@ -18,6 +18,9 @@ public class AppDelegate: ExpoAppDelegate { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { + // Migrate pre-4.73 experimental databases before React Native boots (no db open yet). + migrateLegacyExperimentalDatabases() + // IMPORTANT: Initialize MMKV encryption FIRST, before any other initialization // This reads existing encryption key or generates a new one for fresh installs // Must run before Firebase, Bugsnag, and React Native start @@ -69,6 +72,23 @@ public class AppDelegate: ExpoAppDelegate { return result } + // Rename pre-4.73 `-experimental.db` files to unified names when missing; never overwrites. + private func migrateLegacyExperimentalDatabases() { + guard let suite = Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as? String, + let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: suite), + let files = try? FileManager.default.contentsOfDirectory(at: containerURL, includingPropertiesForKeys: nil) + else { + return + } + for file in files { + let name = file.lastPathComponent + guard name.contains("-experimental.db") else { continue } + let target = containerURL.appendingPathComponent(name.replacingOccurrences(of: "-experimental.db", with: ".db")) + guard !FileManager.default.fileExists(atPath: target.path) else { continue } + try? FileManager.default.moveItem(at: file, to: target) + } + } + // Linking API public override func application( _ app: UIApplication, From 82756e1eaf214ddfb21554e89056dbe0969d9ec6 Mon Sep 17 00:00:00 2001 From: Rohit <40559587+Rohit3523@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:13:45 +0530 Subject: [PATCH 3/8] fix: migrate experimental databases with their sidecars as a group - Skip a database entirely when its unified target already has data, so a stale -wal/-shm is never moved next to a different database. - Move sidecars before the main file and stop if one fails, keeping the database and its WAL paired; an interrupted run resumes on next launch. - Replace a 0-byte target on iOS, which NotificationService's sqlite3_open leaves behind when a push arrives before the first launch. - Match the legacy suffix only at the end of the name. Claude-Session: https://claude.ai/code/session_01D1xvkowpMQJ1bRgx5xcp3E --- .../rocket/reactnative/MainApplication.kt | 38 +++++++++++-------- ios/AppDelegate.swift | 35 ++++++++++++----- 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt index a91016b402c..b1a2108c69d 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt @@ -66,7 +66,6 @@ open class MainApplication : Application(), ReactApplication { override fun onCreate() { super.onCreate() - // Migrate pre-4.73 experimental databases before React Native boots (no db open yet). migrateLegacyExperimentalDatabases() Bugsnag.start(this) @@ -86,26 +85,35 @@ open class MainApplication : Application(), ReactApplication { ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig) } - // Rename pre-4.73 `-experimental.db` files to unified names when missing; never overwrites. + // Renames <=4.73.0 `-experimental.db.db` files. private fun migrateLegacyExperimentalDatabases() { try { - val dirs = listOfNotNull(filesDir?.parentFile, getDatabasePath("probe").parentFile) - for (dir in dirs) { - val files = dir.listFiles() ?: continue - for (file in files) { - if (!file.isFile || !file.name.contains("-experimental.db")) { - continue - } - val target = java.io.File(dir, file.name.replace("-experimental.db", ".db")) - if (target.exists()) { - continue + val dir = getDatabasePath("probe").parentFile?.parentFile ?: return + val legacySuffix = "-experimental.db.db" + val files = dir.listFiles() ?: return + databases@ for (legacy in files) { + if (!legacy.isFile || !legacy.name.endsWith(legacySuffix)) { + continue + } + val baseName = legacy.name.removeSuffix(legacySuffix) + val target = java.io.File(dir, "$baseName.db.db") + if (target.exists() && target.length() > 0) { + continue + } + for (sidecar in listOf("-wal", "-shm", "-journal")) { + val legacySidecar = java.io.File(dir, legacy.name + sidecar) + if (legacySidecar.exists()) { + val targetSidecar = java.io.File(dir, target.name + sidecar) + targetSidecar.delete() + if (!legacySidecar.renameTo(targetSidecar)) { + continue@databases + } } - file.renameTo(target) } + target.delete() + legacy.renameTo(target) } } catch (e: Exception) { - // Migration must never break startup. Worst case the app starts with a fresh - // database and the startup saga falls back to the logged-out flow. } } } diff --git a/ios/AppDelegate.swift b/ios/AppDelegate.swift index 2239f3fbd0f..d016755dc95 100644 --- a/ios/AppDelegate.swift +++ b/ios/AppDelegate.swift @@ -18,7 +18,6 @@ public class AppDelegate: ExpoAppDelegate { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { - // Migrate pre-4.73 experimental databases before React Native boots (no db open yet). migrateLegacyExperimentalDatabases() // IMPORTANT: Initialize MMKV encryption FIRST, before any other initialization @@ -72,20 +71,36 @@ public class AppDelegate: ExpoAppDelegate { return result } - // Rename pre-4.73 `-experimental.db` files to unified names when missing; never overwrites. + // Renames <=4.73.0 `-experimental.db` files; a 0-byte target is one NotificationService created. private func migrateLegacyExperimentalDatabases() { + let fileManager = FileManager.default guard let suite = Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as? String, - let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: suite), - let files = try? FileManager.default.contentsOfDirectory(at: containerURL, includingPropertiesForKeys: nil) + let containerURL = fileManager.containerURL(forSecurityApplicationGroupIdentifier: suite), + let files = try? fileManager.contentsOfDirectory(at: containerURL, includingPropertiesForKeys: nil) else { return } - for file in files { - let name = file.lastPathComponent - guard name.contains("-experimental.db") else { continue } - let target = containerURL.appendingPathComponent(name.replacingOccurrences(of: "-experimental.db", with: ".db")) - guard !FileManager.default.fileExists(atPath: target.path) else { continue } - try? FileManager.default.moveItem(at: file, to: target) + let legacySuffix = "-experimental.db" + databases: for legacy in files { + let name = legacy.lastPathComponent + guard name.hasSuffix(legacySuffix) else { continue } + let target = containerURL.appendingPathComponent(String(name.dropLast(legacySuffix.count)) + ".db") + if let targetSize = (try? fileManager.attributesOfItem(atPath: target.path))?[.size] as? Int, targetSize > 0 { + continue + } + for sidecar in ["-wal", "-shm", "-journal"] { + let legacySidecar = URL(fileURLWithPath: legacy.path + sidecar) + guard fileManager.fileExists(atPath: legacySidecar.path) else { continue } + let targetSidecar = URL(fileURLWithPath: target.path + sidecar) + try? fileManager.removeItem(at: targetSidecar) + do { + try fileManager.moveItem(at: legacySidecar, to: targetSidecar) + } catch { + continue databases + } + } + try? fileManager.removeItem(at: target) + try? fileManager.moveItem(at: legacy, to: target) } } From 118d7368dd396fb235667c45149d06f6a05a61e4 Mon Sep 17 00:00:00 2001 From: Rohit <40559587+Rohit3523@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:45:56 +0530 Subject: [PATCH 4/8] fix(android): abort startup when legacy database rename fails Ignoring a failed rename let WatermelonDB create an empty DB at the target, after which later launches skipped the migration and stranded the legacy data. --- .../main/java/chat/rocket/reactnative/MainApplication.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt index b1a2108c69d..45be7db564d 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt @@ -111,8 +111,13 @@ open class MainApplication : Application(), ReactApplication { } } target.delete() - legacy.renameTo(target) + if (!legacy.renameTo(target)) { + throw IllegalStateException("Failed to migrate ${legacy.name} to ${target.name}") + } } + } catch (e: IllegalStateException) { + // Starting with a fresh DB at `target` would strand the legacy data, since later launches skip a non-empty target. + throw e } catch (e: Exception) { } } From e258ca4aa71256a807d6288ada2420be51c1459a Mon Sep 17 00:00:00 2001 From: Rohit <40559587+Rohit3523@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:17:26 +0530 Subject: [PATCH 5/8] fix(ios): abort startup when legacy database move fails Ignoring a failed move let WatermelonDB create an empty DB at the target, after which later launches skipped the migration and stranded the legacy data. --- ios/AppDelegate.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ios/AppDelegate.swift b/ios/AppDelegate.swift index d016755dc95..c60aaab7fd8 100644 --- a/ios/AppDelegate.swift +++ b/ios/AppDelegate.swift @@ -100,7 +100,12 @@ public class AppDelegate: ExpoAppDelegate { } } try? fileManager.removeItem(at: target) - try? fileManager.moveItem(at: legacy, to: target) + do { + try fileManager.moveItem(at: legacy, to: target) + } catch { + // Starting with a fresh DB at `target` would strand the legacy data, since later launches skip a non-empty target. + fatalError("Failed to migrate \(name) to \(target.lastPathComponent): \(error)") + } } } From cd5dfb5a44ff9c70cba1ecc1f62d8f5c0d59e156 Mon Sep 17 00:00:00 2001 From: Rohit <40559587+Rohit3523@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:37:42 +0530 Subject: [PATCH 6/8] fix: run legacy database migration after Bugsnag starts A failed migration aborts startup, so run it once Bugsnag is up to have the failure reported; on iOS also notify Bugsnag before the fatal error. --- .../main/java/chat/rocket/reactnative/MainApplication.kt | 3 ++- ios/AppDelegate.swift | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt index 45be7db564d..6c57e12860e 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt @@ -66,10 +66,11 @@ open class MainApplication : Application(), ReactApplication { override fun onCreate() { super.onCreate() - migrateLegacyExperimentalDatabases() Bugsnag.start(this) + migrateLegacyExperimentalDatabases() + // Initialize MMKV encryption - reads existing key or generates new one // Must run before React Native starts to avoid race conditions MMKVKeyManager.initialize(this) diff --git a/ios/AppDelegate.swift b/ios/AppDelegate.swift index c60aaab7fd8..547b4a712c1 100644 --- a/ios/AppDelegate.swift +++ b/ios/AppDelegate.swift @@ -18,15 +18,14 @@ public class AppDelegate: ExpoAppDelegate { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { - migrateLegacyExperimentalDatabases() - // IMPORTANT: Initialize MMKV encryption FIRST, before any other initialization // This reads existing encryption key or generates a new one for fresh installs // Must run before Firebase, Bugsnag, and React Native start MMKVKeyManager.initialize() - + FirebaseApp.configure() Bugsnag.start() + migrateLegacyExperimentalDatabases() ReplyNotification.configure() if !VoipRegion.isChina() { VoipService.voipRegistration() @@ -104,6 +103,7 @@ public class AppDelegate: ExpoAppDelegate { try fileManager.moveItem(at: legacy, to: target) } catch { // Starting with a fresh DB at `target` would strand the legacy data, since later launches skip a non-empty target. + Bugsnag.notifyError(error) fatalError("Failed to migrate \(name) to \(target.lastPathComponent): \(error)") } } From 9da39d9b77f25f68e2ab271c8a93cc75ca3a82dc Mon Sep 17 00:00:00 2001 From: Rohit <40559587+Rohit3523@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:48:04 +0530 Subject: [PATCH 7/8] docs: correct last version using legacy experimental database names The -experimental suffix was removed in 4.73.0, so 4.72.0 is the last release that wrote those files. --- .../src/main/java/chat/rocket/reactnative/MainApplication.kt | 2 +- ios/AppDelegate.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt index 6c57e12860e..5fbd179bdb3 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt @@ -86,7 +86,7 @@ open class MainApplication : Application(), ReactApplication { ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig) } - // Renames <=4.73.0 `-experimental.db.db` files. + // Renames <=4.72.0 `-experimental.db.db` files. private fun migrateLegacyExperimentalDatabases() { try { val dir = getDatabasePath("probe").parentFile?.parentFile ?: return diff --git a/ios/AppDelegate.swift b/ios/AppDelegate.swift index 547b4a712c1..3e645aa4f42 100644 --- a/ios/AppDelegate.swift +++ b/ios/AppDelegate.swift @@ -70,7 +70,7 @@ public class AppDelegate: ExpoAppDelegate { return result } - // Renames <=4.73.0 `-experimental.db` files; a 0-byte target is one NotificationService created. + // Renames <=4.72.0 `-experimental.db` files; a 0-byte target is one NotificationService created. private func migrateLegacyExperimentalDatabases() { let fileManager = FileManager.default guard let suite = Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as? String, From 324fde3322f737580dfbf06dde02644211f73b93 Mon Sep 17 00:00:00 2001 From: Rohit <40559587+Rohit3523@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:30:33 +0530 Subject: [PATCH 8/8] fix: fail legacy database migration on any sidecar failure Move sidecars and the main file in one loop with the main file last, so a failed helper move aborts startup instead of stranding legacy data behind a fresh target DB. Also use applicationInfo.dataDir on Android and drop the no-op try/catch. --- .../rocket/reactnative/MainApplication.kt | 55 ++++++++----------- ios/AppDelegate.swift | 26 ++++----- 2 files changed, 32 insertions(+), 49 deletions(-) diff --git a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt index 5fbd179bdb3..a25bd87acf8 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt @@ -68,16 +68,16 @@ open class MainApplication : Application(), ReactApplication { super.onCreate() Bugsnag.start(this) - + migrateLegacyExperimentalDatabases() - + // Initialize MMKV encryption - reads existing key or generates new one // Must run before React Native starts to avoid race conditions MMKVKeyManager.initialize(this) // Load the native entry point for the New Architecture loadReactNative(this) - + ApplicationLifecycleDispatcher.onApplicationCreate(this) } @@ -88,38 +88,27 @@ open class MainApplication : Application(), ReactApplication { // Renames <=4.72.0 `-experimental.db.db` files. private fun migrateLegacyExperimentalDatabases() { - try { - val dir = getDatabasePath("probe").parentFile?.parentFile ?: return - val legacySuffix = "-experimental.db.db" - val files = dir.listFiles() ?: return - databases@ for (legacy in files) { - if (!legacy.isFile || !legacy.name.endsWith(legacySuffix)) { - continue - } - val baseName = legacy.name.removeSuffix(legacySuffix) - val target = java.io.File(dir, "$baseName.db.db") - if (target.exists() && target.length() > 0) { - continue - } - for (sidecar in listOf("-wal", "-shm", "-journal")) { - val legacySidecar = java.io.File(dir, legacy.name + sidecar) - if (legacySidecar.exists()) { - val targetSidecar = java.io.File(dir, target.name + sidecar) - targetSidecar.delete() - if (!legacySidecar.renameTo(targetSidecar)) { - continue@databases - } - } - } - target.delete() - if (!legacy.renameTo(target)) { - throw IllegalStateException("Failed to migrate ${legacy.name} to ${target.name}") + val dir = java.io.File(applicationInfo.dataDir) + val legacySuffix = "-experimental.db.db" + val files = dir.listFiles() ?: return + for (legacy in files) { + if (!legacy.isFile || !legacy.name.endsWith(legacySuffix)) { + continue + } + val baseName = legacy.name.removeSuffix(legacySuffix) + val target = java.io.File(dir, "$baseName.db.db") + if (target.exists() && target.length() > 0) { + continue + } + for (suffix in listOf("-wal", "-shm", "-journal", "")) { + val from = java.io.File(dir, legacy.name + suffix) + val to = java.io.File(dir, target.name + suffix) + to.delete() + if (from.exists() && !from.renameTo(to)) { + // Starting with a fresh DB at `target` would strand the legacy data, since later launches skip a non-empty target. + throw IllegalStateException("Failed to migrate ${from.name} to ${to.name}") } } - } catch (e: IllegalStateException) { - // Starting with a fresh DB at `target` would strand the legacy data, since later launches skip a non-empty target. - throw e - } catch (e: Exception) { } } } diff --git a/ios/AppDelegate.swift b/ios/AppDelegate.swift index 3e645aa4f42..bdfe963330c 100644 --- a/ios/AppDelegate.swift +++ b/ios/AppDelegate.swift @@ -80,32 +80,26 @@ public class AppDelegate: ExpoAppDelegate { return } let legacySuffix = "-experimental.db" - databases: for legacy in files { + for legacy in files { let name = legacy.lastPathComponent guard name.hasSuffix(legacySuffix) else { continue } let target = containerURL.appendingPathComponent(String(name.dropLast(legacySuffix.count)) + ".db") if let targetSize = (try? fileManager.attributesOfItem(atPath: target.path))?[.size] as? Int, targetSize > 0 { continue } - for sidecar in ["-wal", "-shm", "-journal"] { - let legacySidecar = URL(fileURLWithPath: legacy.path + sidecar) - guard fileManager.fileExists(atPath: legacySidecar.path) else { continue } - let targetSidecar = URL(fileURLWithPath: target.path + sidecar) - try? fileManager.removeItem(at: targetSidecar) + for suffix in ["-wal", "-shm", "-journal", ""] { + let from = URL(fileURLWithPath: legacy.path + suffix) + let to = URL(fileURLWithPath: target.path + suffix) + try? fileManager.removeItem(at: to) + guard fileManager.fileExists(atPath: from.path) else { continue } do { - try fileManager.moveItem(at: legacySidecar, to: targetSidecar) + try fileManager.moveItem(at: from, to: to) } catch { - continue databases + // Starting with a fresh DB at `target` would strand the legacy data, since later launches skip a non-empty target. + Bugsnag.notifyError(error) + fatalError("Failed to migrate \(from.lastPathComponent) to \(to.lastPathComponent): \(error)") } } - try? fileManager.removeItem(at: target) - do { - try fileManager.moveItem(at: legacy, to: target) - } catch { - // Starting with a fresh DB at `target` would strand the legacy data, since later launches skip a non-empty target. - Bugsnag.notifyError(error) - fatalError("Failed to migrate \(name) to \(target.lastPathComponent): \(error)") - } } }