diff --git a/app/src/main/kotlin/com/metrolist/music/db/MusicDatabase.kt b/app/src/main/kotlin/com/metrolist/music/db/MusicDatabase.kt index d73d34e227..919d6ed112 100644 --- a/app/src/main/kotlin/com/metrolist/music/db/MusicDatabase.kt +++ b/app/src/main/kotlin/com/metrolist/music/db/MusicDatabase.kt @@ -206,6 +206,24 @@ abstract class InternalDatabase : RoomDatabase() { }, ).build(), ) + + fun newInternalDatabaseInstance(context: Context, dbName: String = DB_NAME): InternalDatabase = + Room + .databaseBuilder(context, InternalDatabase::class.java, dbName) + .openHelperFactory(BackupBeforeMigrationFactory(context, dbName)) + .addMigrations( + MIGRATION_1_2, + MIGRATION_21_24, + MIGRATION_22_24, + MIGRATION_24_25, + ).setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING) + .setTransactionExecutor( + java.util.concurrent.Executors + .newFixedThreadPool(4), + ).setQueryExecutor( + java.util.concurrent.Executors + .newFixedThreadPool(4), + ).build() } } diff --git a/app/src/main/kotlin/com/metrolist/music/viewmodels/BackupRestoreViewModel.kt b/app/src/main/kotlin/com/metrolist/music/viewmodels/BackupRestoreViewModel.kt index add1ff8835..83311612fa 100644 --- a/app/src/main/kotlin/com/metrolist/music/viewmodels/BackupRestoreViewModel.kt +++ b/app/src/main/kotlin/com/metrolist/music/viewmodels/BackupRestoreViewModel.kt @@ -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() + 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() + 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() + } + 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() } - context.startActivity(intent) - Runtime.getRuntime().exit(0) }.onFailure { reportException(it) Timber.tag("RESTORE").e(it, "Restore failed") diff --git a/app/src/main/res/values/metrolist_strings.xml b/app/src/main/res/values/metrolist_strings.xml index e06e3c4aa1..996f837ba6 100644 --- a/app/src/main/res/values/metrolist_strings.xml +++ b/app/src/main/res/values/metrolist_strings.xml @@ -896,6 +896,7 @@ This will restore your app data from the selected backup You will need to log in again after restoring. The following account will be signed out: Restore + Cannot restore backup: database version incompatible. Your current data has been preserved. Checking for previous account… No account found