-
Notifications
You must be signed in to change notification settings - Fork 592
fix(db): crash on settings restore due to schema mismatch #3693
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -42,6 +42,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel | |||||||||||||||||||||||||||||||||||||||||||||||||
| import kotlinx.coroutines.Dispatchers | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import kotlinx.coroutines.runBlocking | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import timber.log.Timber | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.io.File | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.io.FileInputStream | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.io.FileOutputStream | ||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.util.zip.ZipEntry | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -99,11 +100,31 @@ class BackupRestoreViewModel @Inject constructor( | |||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| fun restore(context: Context, uri: Uri, clearAuthData: Boolean = false) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| var migrationSucceeded: Boolean? = null | ||||||||||||||||||||||||||||||||||||||||||||||||||
| runCatching { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Timber.tag("RESTORE").i("Starting restore from URI: $uri, clearAuthData: $clearAuthData") | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| // Backup current DB before restore | ||||||||||||||||||||||||||||||||||||||||||||||||||
| val currentDbPath = database.openHelper.writableDatabase.path | ||||||||||||||||||||||||||||||||||||||||||||||||||
| val backupDbFile = if (currentDbPath != null) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| val backupFile = File("${currentDbPath}_restore_backup_${System.currentTimeMillis()}") | ||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| File(currentDbPath).copyTo(backupFile, overwrite = true) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| val walFile = File("${currentDbPath}-wal") | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if (walFile.exists()) walFile.copyTo(File("${backupFile.absolutePath}-wal"), overwrite = true) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| val shmFile = File("${currentDbPath}-shm") | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if (shmFile.exists()) shmFile.copyTo(File("${backupFile.absolutePath}-shm"), overwrite = true) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Timber.tag("RESTORE").i("Created DB backup at ${backupFile.absolutePath}") | ||||||||||||||||||||||||||||||||||||||||||||||||||
| backupFile | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (e: Exception) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Timber.tag("RESTORE").e(e, "Failed to create DB backup") | ||||||||||||||||||||||||||||||||||||||||||||||||||
| null | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } else null | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| context.applicationContext.contentResolver.openInputStream(uri)?.use { raw -> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| raw.zipInputStream().use { inputStream -> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| var entry = tryOrNull { inputStream.nextEntry } // prevent ZipException | ||||||||||||||||||||||||||||||||||||||||||||||||||
| var entry = tryOrNull { inputStream.nextEntry } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| var foundAny = false | ||||||||||||||||||||||||||||||||||||||||||||||||||
| while (entry != null) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Timber.tag("RESTORE").i("Found zip entry: ${entry.name}") | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -119,21 +140,64 @@ class BackupRestoreViewModel @Inject constructor( | |||||||||||||||||||||||||||||||||||||||||||||||||
| InternalDatabase.DB_NAME -> { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Timber.tag("RESTORE").i("Restoring DB (entry = ${entry.name})") | ||||||||||||||||||||||||||||||||||||||||||||||||||
| foundAny = true | ||||||||||||||||||||||||||||||||||||||||||||||||||
| // capture path before closing DB to avoid reopening race | ||||||||||||||||||||||||||||||||||||||||||||||||||
| val dbPath = database.openHelper.writableDatabase.path | ||||||||||||||||||||||||||||||||||||||||||||||||||
| runBlocking(Dispatchers.IO) { database.checkpoint() } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| database.close() | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Timber.tag("RESTORE").i("Overwriting DB at path: $dbPath") | ||||||||||||||||||||||||||||||||||||||||||||||||||
| FileOutputStream(dbPath).use { outputStream -> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| inputStream.copyTo(outputStream) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if (currentDbPath == null) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Timber.tag("RESTORE").e("Database path is null, cannot restore") | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| runBlocking(Dispatchers.IO) { database.checkpoint() } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| database.close() | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Timber.tag("RESTORE").i("Overwriting DB at path: $currentDbPath") | ||||||||||||||||||||||||||||||||||||||||||||||||||
| FileOutputStream(currentDbPath).use { outputStream -> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| inputStream.copyTo(outputStream) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Timber.tag("RESTORE").i("DB overwrite complete, triggering migrations") | ||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| val migratedDb = InternalDatabase.newInternalDatabaseInstance(context, InternalDatabase.DB_NAME) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| migratedDb.openHelper.writableDatabase | ||||||||||||||||||||||||||||||||||||||||||||||||||
| migratedDb.close() | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+146
to
+156
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: Yes—if you are restoring only by replacing the main “.db” file while leaving any existing “-wal” and “-shm” sidecar files in place, you should remove the old sidecars first (or otherwise ensure they do not exist) before reopening the database. Rationale: In WAL mode, SQLite uses the “-wal” file plus a shared-memory “-shm” wal-index/coordination file. If the existing sidecars do not match the restored “.db” state, the next opener will run WAL recovery to rebuild the wal-index from the WAL; if the WAL/index state is inconsistent with what you restored, you risk incorrect recovery or even failure. SQLite’s WAL design also notes that the “shm” content is not required for crash recovery and is normally truncated/unlinked when the database becomes quiescent, and that cleanup unlinks both “-wal” and “-shm” after a proper close. What “safe restore” usually looks like: 1) Restore/replace the main “.db”. 2) Delete the matching “-wal” and “-shm” files for that database. 3) Open the database normally; SQLite will initialize/recover the WAL-index as needed. Caveat: If some process still has the database open, deleting “-wal/-shm” can make the database become corrupted later; make sure the database is not in use during restore. Citations:
🏁 Script executed: cat -n app/src/main/kotlin/com/metrolist/music/viewmodels/BackupRestoreViewModel.kt | sed -n '120,145p'Repository: MetrolistGroup/Metrolist Length of output: 2346 🏁 Script executed: # Check if there's any cleanup of WAL/SHM files in this file
rg -A 5 -B 5 "delete|removeFile|wal|shm" app/src/main/kotlin/com/metrolist/music/viewmodels/BackupRestoreViewModel.ktRepository: MetrolistGroup/Metrolist Length of output: 559 🏁 Script executed: # Check InternalDatabase for WAL mode configuration
find . -name "InternalDatabase.kt" -type f | head -1 | xargs cat -nRepository: MetrolistGroup/Metrolist Length of output: 50 🏁 Script executed: # Search for InternalDatabase more broadly
find . -name "*InternalDatabase*" -type fRepository: MetrolistGroup/Metrolist Length of output: 50 🏁 Script executed: # Search for WAL configuration in the codebase
rg -i "wal|WAL" --type kt -lRepository: MetrolistGroup/Metrolist Length of output: 95 🏁 Script executed: # Search for InternalDatabase class definition
rg "class InternalDatabase" --type-list | head -20Repository: MetrolistGroup/Metrolist Length of output: 458 🏁 Script executed: # Use correct syntax for rg
rg "class InternalDatabase" -lRepository: MetrolistGroup/Metrolist Length of output: 128 🏁 Script executed: # Broader search for database initialization or WAL
rg "InternalDatabase|\.database|RoomDatabase" | head -30Repository: MetrolistGroup/Metrolist Length of output: 3836 🏁 Script executed: cat -n app/src/main/kotlin/com/metrolist/music/db/MusicDatabase.kt | sed -n '1,100p'Repository: MetrolistGroup/Metrolist Length of output: 4193 🏁 Script executed: cat -n app/src/main/kotlin/com/metrolist/music/db/MusicDatabase.kt | sed -n '100,200p'Repository: MetrolistGroup/Metrolist Length of output: 5576 🏁 Script executed: cat -n app/src/main/kotlin/com/metrolist/music/db/MusicDatabase.kt | sed -n '200,280p'Repository: MetrolistGroup/Metrolist Length of output: 4126 Remove the old WAL/SHM sidecars before overwriting the restored database file.
Suggested change runBlocking(Dispatchers.IO) { database.checkpoint() }
database.close()
+ java.io.File("$dbPath-wal").delete()
+ java.io.File("$dbPath-shm").delete()
Timber.tag("RESTORE").i("Overwriting DB at path: $dbPath")
FileOutputStream(dbPath).use { outputStream ->
inputStream.copyTo(outputStream)
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||
| Timber.tag("RESTORE").i("Migrations completed successfully") | ||||||||||||||||||||||||||||||||||||||||||||||||||
| // Delete backup on success | ||||||||||||||||||||||||||||||||||||||||||||||||||
| backupDbFile?.delete() | ||||||||||||||||||||||||||||||||||||||||||||||||||
| val walBackup = File("${backupDbFile?.absolutePath}-wal") | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if (walBackup.exists()) walBackup.delete() | ||||||||||||||||||||||||||||||||||||||||||||||||||
| val shmBackup = File("${backupDbFile?.absolutePath}-shm") | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if (shmBackup.exists()) shmBackup.delete() | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+159
to
+163
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Null-safe path construction for backup cleanup. If Suggested fix // Delete backup on success
- backupDbFile?.delete()
- val walBackup = File("${backupDbFile?.absolutePath}-wal")
- if (walBackup.exists()) walBackup.delete()
- val shmBackup = File("${backupDbFile?.absolutePath}-shm")
- if (shmBackup.exists()) shmBackup.delete()
+ backupDbFile?.let { backup ->
+ backup.delete()
+ File("${backup.absolutePath}-wal").delete()
+ File("${backup.absolutePath}-shm").delete()
+ }
migrationSucceeded = true📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||
| migrationSucceeded = true | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (e: Exception) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Timber.tag("RESTORE").e(e, "Migration failed, restoring backup") | ||||||||||||||||||||||||||||||||||||||||||||||||||
| var backupRestored = false | ||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| backupDbFile?.let { backup -> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| File(currentDbPath).delete() | ||||||||||||||||||||||||||||||||||||||||||||||||||
| backup.copyTo(File(currentDbPath), overwrite = true) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| backup.delete() | ||||||||||||||||||||||||||||||||||||||||||||||||||
| val walBackup = File("${backup.absolutePath}-wal") | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if (walBackup.exists()) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| walBackup.copyTo(File("${currentDbPath}-wal"), overwrite = true) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| walBackup.delete() | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| val shmBackup = File("${backup.absolutePath}-shm") | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if (shmBackup.exists()) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| shmBackup.copyTo(File("${currentDbPath}-shm"), overwrite = true) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| shmBackup.delete() | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+169
to
+181
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Clear the failed-migration sidecars before copying the backup back. If the failed open created Suggested change backupDbFile?.let { backup ->
File(currentDbPath).delete()
+ File("${currentDbPath}-wal").delete()
+ File("${currentDbPath}-shm").delete()
backup.copyTo(File(currentDbPath), overwrite = true)
backup.delete()
val walBackup = File("${backup.absolutePath}-wal")
if (walBackup.exists()) {
walBackup.copyTo(File("${currentDbPath}-wal"), overwrite = true)🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| backupRestored = true | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (restoreEx: Exception) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Timber.tag("RESTORE").e(restoreEx, "Failed to restore backup after migration failure") | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!backupRestored) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| throw e | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Timber.tag("RESTORE").i("Backup restored, migration not possible") | ||||||||||||||||||||||||||||||||||||||||||||||||||
| migrationSucceeded = false | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Timber.tag("RESTORE").i("DB overwrite complete") | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| else -> { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Timber.tag("RESTORE").i("Skipping unexpected entry: ${entry.name}") | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| entry = tryOrNull { inputStream.nextEntry } // prevent ZipException | ||||||||||||||||||||||||||||||||||||||||||||||||||
| entry = tryOrNull { inputStream.nextEntry } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!foundAny) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Timber.tag("RESTORE").w("No expected entries found in archive") | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -155,13 +219,18 @@ class BackupRestoreViewModel @Inject constructor( | |||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| context.stopService(Intent(context, MusicService::class.java)) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| context.filesDir.resolve(PERSISTENT_QUEUE_FILE).delete() | ||||||||||||||||||||||||||||||||||||||||||||||||||
| val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| // Only restart if migration succeeded or no DB restore was attempted | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if (migrationSucceeded != false) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| context.stopService(Intent(context, MusicService::class.java)) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| context.filesDir.resolve(PERSISTENT_QUEUE_FILE).delete() | ||||||||||||||||||||||||||||||||||||||||||||||||||
| val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| context.startActivity(intent) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Runtime.getRuntime().exit(0) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Toast.makeText(context, R.string.restore_database_incompatible, Toast.LENGTH_LONG).show() | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| context.startActivity(intent) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Runtime.getRuntime().exit(0) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| }.onFailure { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| reportException(it) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| Timber.tag("RESTORE").e(it, "Restore failed") | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Take the rollback snapshot after the database is quiesced.
This copy happens while the current Room instance is still open and before the checkpoint/close at Lines 146-147. In WAL mode that means the backup used for rollback is not guaranteed to be a self-consistent snapshot, so a failed migration can still roll back to missing or inconsistent data. Move the checkpoint/close ahead of this copy step, or use SQLite’s backup API for the pre-restore snapshot.
🤖 Prompt for AI Agents