diff --git a/android_app/app/src/androidTest/java/com/health/openscale/core/usecase/BackupRestoreUseCasesTest.kt b/android_app/app/src/androidTest/java/com/health/openscale/core/usecase/BackupRestoreUseCasesTest.kt new file mode 100644 index 000000000..c6a0203c3 --- /dev/null +++ b/android_app/app/src/androidTest/java/com/health/openscale/core/usecase/BackupRestoreUseCasesTest.kt @@ -0,0 +1,323 @@ +package com.health.openscale.core.usecase + +import android.content.Context +import android.content.ContextWrapper +import android.database.sqlite.SQLiteDatabase +import android.net.Uri +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.health.openscale.core.data.ActivityLevel +import com.health.openscale.core.data.GenderType +import com.health.openscale.core.data.User +import com.health.openscale.core.database.AppDatabase +import com.health.openscale.core.database.DatabaseRepository +import com.health.openscale.core.facade.SettingsFacadeImpl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.io.FileOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +@RunWith(AndroidJUnit4::class) +class BackupRestoreUseCasesTest { + private lateinit var baseContext: Context + private lateinit var sandboxRoot: File + private lateinit var sandboxContext: Context + private lateinit var database: AppDatabase + private lateinit var repository: DatabaseRepository + private lateinit var useCases: BackupRestoreUseCases + private lateinit var dbFile: File + + @Before + fun setUp() = runBlocking { + baseContext = InstrumentationRegistry.getInstrumentation().targetContext + sandboxRoot = File(baseContext.cacheDir, "backup-restore-test-${System.nanoTime()}").apply { + mkdirs() + } + + sandboxContext = object : ContextWrapper(baseContext) { + override fun getApplicationContext(): Context = this + + override fun getDatabasePath(name: String): File { + return File(sandboxRoot, name).also { file -> + file.parentFile?.mkdirs() + } + } + } + + database = buildDatabase(sandboxContext) + + repository = DatabaseRepository( + database = database, + userDao = database.userDao(), + userGoalsDao = database.userGoalsDao(), + measurementDao = database.measurementDao(), + measurementTypeDao = database.measurementTypeDao(), + measurementValueDao = database.measurementValueDao() + ) + + val dataStore = PreferenceDataStoreFactory.create( + scope = CoroutineScope(SupervisorJob() + Dispatchers.IO), + produceFile = { File(sandboxRoot, "settings.preferences_pb") } + ) + val settings = SettingsFacadeImpl(dataStore) + useCases = BackupRestoreUseCases(sandboxContext, repository, settings) + + repository.insertUser( + User( + name = "restore-test-user", + birthDate = 946684800000L, + gender = GenderType.FEMALE, + heightCm = 170f, + activityLevel = ActivityLevel.MODERATE, + useAssistedWeighing = false + ) + ) + + dbFile = sandboxContext.getDatabasePath(AppDatabase.DATABASE_NAME) + assertTrue("expected seeded test database to exist", dbFile.exists()) + assertEquals(1, repository.getAllUsers().first().size) + } + + @After + fun tearDown() { + runCatching { database.close() } + sandboxRoot.deleteRecursively() + } + + @Test + fun restoreDatabase_withZipMissingMainDb_keepsExistingData() = runBlocking { + val invalidZip = File(sandboxRoot, "invalid-backup.zip") + ZipOutputStream(FileOutputStream(invalidZip)).use { zip -> + zip.putNextEntry(ZipEntry("not-the-database.txt")) + zip.write("wrong backup payload".toByteArray()) + zip.closeEntry() + } + + val result = useCases.restoreDatabase(Uri.fromFile(invalidZip), baseContext.contentResolver) + + assertTrue("restore should fail for zip without openScale.db", result.isFailure) + assertTrue("failed restore should leave the live database file in place", dbFile.exists()) + + assertEquals("failed restore should not mutate live in-memory data", 1, repository.getAllUsers().first().size) + + val reopened = buildDatabase(sandboxContext) + + try { + val reopenedRepo = DatabaseRepository( + database = reopened, + userDao = reopened.userDao(), + userGoalsDao = reopened.userGoalsDao(), + measurementDao = reopened.measurementDao(), + measurementTypeDao = reopened.measurementTypeDao(), + measurementValueDao = reopened.measurementValueDao() + ) + + assertEquals( + "the original record should still exist after a failed restore", + 1, + reopenedRepo.getAllUsers().first().size + ) + } finally { + reopened.close() + } + } + + @Test + fun restoreDatabase_withUnrelatedSqliteFile_keepsExistingData() = runBlocking { + val unrelatedDb = File(sandboxRoot, "unrelated.db") + val sqliteDb = SQLiteDatabase.openOrCreateDatabase(unrelatedDb, null) + try { + sqliteDb.execSQL("CREATE TABLE unrelated_data (id INTEGER PRIMARY KEY, value TEXT)") + sqliteDb.execSQL("INSERT INTO unrelated_data(value) VALUES ('not openscale')") + } finally { + sqliteDb.close() + } + + val result = useCases.restoreDatabase(Uri.fromFile(unrelatedDb), baseContext.contentResolver) + + assertTrue("restore should fail for unrelated SQLite databases", result.isFailure) + assertTrue("failed restore should leave the live database file in place", dbFile.exists()) + assertEquals("failed restore should not mutate live in-memory data", 1, repository.getAllUsers().first().size) + + val reopened = buildDatabase(sandboxContext) + try { + val reopenedRepo = DatabaseRepository( + database = reopened, + userDao = reopened.userDao(), + userGoalsDao = reopened.userGoalsDao(), + measurementDao = reopened.measurementDao(), + measurementTypeDao = reopened.measurementTypeDao(), + measurementValueDao = reopened.measurementValueDao() + ) + + assertEquals( + "the original record should still exist after rejecting an unrelated database", + 1, + reopenedRepo.getAllUsers().first().size + ) + } finally { + reopened.close() + } + } + + @Test + fun restoreDatabase_withLegacySingleFile_restoresAndMigrates() = runBlocking { + val legacyDb = File(sandboxRoot, "legacy-openscale.db") + createLegacyDatabase(legacyDb) + + val result = useCases.restoreDatabase(Uri.fromFile(legacyDb), baseContext.contentResolver) + assertTrue("restore should accept legacy openScale single-file databases", result.isSuccess) + + val reopened = buildDatabase(sandboxContext) + try { + val reopenedRepo = DatabaseRepository( + database = reopened, + userDao = reopened.userDao(), + userGoalsDao = reopened.userGoalsDao(), + measurementDao = reopened.measurementDao(), + measurementTypeDao = reopened.measurementTypeDao(), + measurementValueDao = reopened.measurementValueDao() + ) + + val users = reopenedRepo.getAllUsers().first() + assertEquals(1, users.size) + assertEquals("legacy-user", users.single().name) + } finally { + reopened.close() + } + } + + @Test + fun restoreDatabase_withValidBackupZip_restoresPreviousSnapshot() = runBlocking { + val backupZip = File(sandboxRoot, "valid-backup.zip") + useCases.backupDatabase(Uri.fromFile(backupZip), baseContext.contentResolver).getOrThrow() + + repository.insertUser( + User( + name = "post-backup-user", + birthDate = 978307200000L, + gender = GenderType.MALE, + heightCm = 180f, + activityLevel = ActivityLevel.MILD, + useAssistedWeighing = false + ) + ) + assertEquals(2, repository.getAllUsers().first().size) + + val result = useCases.restoreDatabase(Uri.fromFile(backupZip), baseContext.contentResolver) + assertTrue("restore from app-generated backup should succeed", result.isSuccess) + + val reopened = buildDatabase(sandboxContext) + try { + val reopenedRepo = DatabaseRepository( + database = reopened, + userDao = reopened.userDao(), + userGoalsDao = reopened.userGoalsDao(), + measurementDao = reopened.measurementDao(), + measurementTypeDao = reopened.measurementTypeDao(), + measurementValueDao = reopened.measurementValueDao() + ) + + val users = reopenedRepo.getAllUsers().first() + assertEquals(1, users.size) + assertEquals("restore-test-user", users.single().name) + } finally { + reopened.close() + } + } + + private fun buildDatabase(context: Context): AppDatabase = + Room.databaseBuilder( + context, + AppDatabase::class.java, + AppDatabase.DATABASE_NAME + ) + .setJournalMode(RoomDatabase.JournalMode.TRUNCATE) + .addMigrations( + com.health.openscale.core.database.MIGRATION_6_7, + com.health.openscale.core.database.MIGRATION_7_8, + com.health.openscale.core.database.MIGRATION_8_9, + com.health.openscale.core.database.MIGRATION_9_10, + com.health.openscale.core.database.MIGRATION_10_11, + com.health.openscale.core.database.MIGRATION_11_12, + com.health.openscale.core.database.MIGRATION_12_13, + com.health.openscale.core.database.MIGRATION_13_14 + ) + .build() + + private fun createLegacyDatabase(file: File) { + val database = SQLiteDatabase.openOrCreateDatabase(file, null) + try { + database.execSQL( + """ + CREATE TABLE scaleUsers ( + id INTEGER PRIMARY KEY, + username TEXT NOT NULL, + birthday INTEGER NOT NULL, + gender INTEGER NOT NULL, + bodyHeight REAL NOT NULL, + activityLevel INTEGER NOT NULL + ) + """.trimIndent() + ) + database.execSQL( + """ + CREATE TABLE scaleMeasurements ( + id INTEGER PRIMARY KEY, + userId INTEGER NOT NULL, + datetime INTEGER, + enabled INTEGER NOT NULL, + weight REAL, + fat REAL, + water REAL, + muscle REAL, + visceralFat REAL, + lbm REAL, + waist REAL, + hip REAL, + bone REAL, + chest REAL, + thigh REAL, + biceps REAL, + neck REAL, + caliper1 REAL, + caliper2 REAL, + caliper3 REAL, + calories REAL, + comment TEXT + ) + """.trimIndent() + ) + database.execSQL( + """ + INSERT INTO scaleUsers (id, username, birthday, gender, bodyHeight, activityLevel) + VALUES (1, 'legacy-user', 946684800000, 1, 168.0, 2) + """.trimIndent() + ) + database.execSQL( + """ + INSERT INTO scaleMeasurements (id, userId, datetime, enabled, weight, comment) + VALUES (1, 1, 1712325600000, 1, 72.5, 'legacy measurement') + """.trimIndent() + ) + database.execSQL("PRAGMA user_version = 6") + } finally { + database.close() + } + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/usecase/BackupRestoreUseCases.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/BackupRestoreUseCases.kt index 8072def7d..4a76c0aa2 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/usecase/BackupRestoreUseCases.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/BackupRestoreUseCases.kt @@ -19,6 +19,7 @@ package com.health.openscale.core.usecase import android.content.ContentResolver import android.content.Context +import android.database.sqlite.SQLiteDatabase import android.net.Uri import com.health.openscale.core.database.DatabaseRepository import com.health.openscale.core.facade.SettingsFacade @@ -30,6 +31,9 @@ import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.io.IOException +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption import java.util.zip.ZipEntry import java.util.zip.ZipInputStream import java.util.zip.ZipOutputStream @@ -78,9 +82,9 @@ class BackupRestoreUseCases @Inject constructor( candidates.forEach { f -> if (f.exists() && f.isFile) { try { - FileInputStream(f).use { `in` -> + FileInputStream(f).use { input -> zip.putNextEntry(ZipEntry(f.name)) - `in`.copyTo(zip) + input.copyTo(zip) zip.closeEntry() added += f.name } @@ -103,89 +107,53 @@ class BackupRestoreUseCases @Inject constructor( val dbName = repository.getDatabaseName() val dbFile = appContext.getDatabasePath(dbName) val dbDir = dbFile.parentFile ?: error("Database directory not found") - - // Close DB before touching the files - LogManager.d(TAG, "Closing database for restore…") - repository.closeDatabase() - - // Helper - fun deleteIfExists(file: File) { - if (file.exists() && !file.delete()) { - LogManager.w(TAG, "Could not delete ${file.absolutePath} before restore.") - } + val restored = mutableListOf() + // Restore into a temporary workspace first so the live database is untouched + // until the incoming payload has been staged and validated. + val restoreSessionDir = File(dbDir, "$dbName.restore-${System.currentTimeMillis()}").apply { + mkdirs() } + val stagingDir = File(restoreSessionDir, "staging").apply { mkdirs() } + val rollbackDir = File(restoreSessionDir, "rollback").apply { mkdirs() } - val restored = mutableListOf() - var format = "zip" + try { + val format = withContext(Dispatchers.IO) { + stageRestorePayload( + restoreUri = restoreUri, + contentResolver = contentResolver, + stagingDir = stagingDir, + dbName = dbName, + restored = restored + ) + } - withContext(Dispatchers.IO) { - // Peek first 4 bytes to detect ZIP - val isZip = contentResolver.openInputStream(restoreUri)?.use { ins -> - val header = ByteArray(4) - val read = ins.read(header) - read == 4 && header[0] == 0x50.toByte() && header[1] == 0x4B.toByte() && - header[2] == 0x03.toByte() && header[3] == 0x04.toByte() - } ?: false + LogManager.d(TAG, "Closing database for restore...") + repository.closeDatabase() - val shm = File(dbDir, "$dbName-shm") - val wal = File(dbDir, "$dbName-wal") - - if (isZip) { - contentResolver.openInputStream(restoreUri)?.use { input -> - ZipInputStream(input).use { zis -> - // clean slate - deleteIfExists(dbFile) - deleteIfExists(shm) - deleteIfExists(wal) - - var hasMain = false - var entry = zis.nextEntry - while (entry != null) { - val out = File(dbDir, entry.name) - - // Path traversal guard - if (!out.canonicalPath.startsWith(dbDir.canonicalPath)) { - LogManager.e(TAG, "Skipping ${entry.name} (path traversal)") - entry = zis.nextEntry - continue - } + withContext(Dispatchers.IO) { + swapStagedDatabaseFiles( + dbDir = dbDir, + stagingDir = stagingDir, + rollbackDir = rollbackDir, + dbName = dbName + ) + } - deleteIfExists(out) - FileOutputStream(out).use { zis.copyTo(it) } - restored += entry.name - if (entry.name == dbName) hasMain = true - entry = zis.nextEntry - } - require(hasMain) { "Main DB file '$dbName' missing in ZIP" } - } - } ?: throw IOException("Cannot open InputStream for Uri: $restoreUri") - } else { - // Legacy single-file: treat input as raw DB file - format = "legacy" - contentResolver.openInputStream(restoreUri)?.use { input -> - deleteIfExists(dbFile) - deleteIfExists(shm) - deleteIfExists(wal) - - val tmp = File(dbDir, "$dbName.tmp-restore") - FileOutputStream(tmp).use { output -> input.copyTo(output) } - if (!tmp.renameTo(dbFile)) { - // If rename fails (FS boundaries), leave the copied file as final - tmp.copyTo(dbFile, overwrite = true) - tmp.delete() - } - restored += dbName - } ?: throw IOException("Cannot open InputStream for Uri: $restoreUri") + LogManager.i(TAG, "Restore completed. Format=$format, Files=$restored") + } finally { + if (restoreSessionDir.exists() && !restoreSessionDir.deleteRecursively()) { + LogManager.w( + TAG, + "Could not fully delete temporary restore session dir: ${restoreSessionDir.absolutePath}" + ) } } - - LogManager.i(TAG, "Restore completed. Format=$format, Files=$restored") } /** Close and delete the entire Room database (plus -shm/-wal). */ suspend fun wipeDatabase() = runCatching { val dbName = repository.getDatabaseName() - LogManager.d(TAG, "Closing database for wipe…") + LogManager.d(TAG, "Closing database for wipe...") repository.closeDatabase() val dbFile = appContext.getDatabasePath(dbName) @@ -208,6 +176,210 @@ class BackupRestoreUseCases @Inject constructor( settings.setCurrentUserId(null) } - LogManager.i(TAG, "Wipe complete. dbDeleted=$dbDeleted shmDeleted=$shmDeleted walDeleted=$walDeleted name=$dbName") + LogManager.i( + TAG, + "Wipe complete. dbDeleted=$dbDeleted shmDeleted=$shmDeleted walDeleted=$walDeleted name=$dbName" + ) + } + + private fun stageRestorePayload( + restoreUri: Uri, + contentResolver: ContentResolver, + stagingDir: File, + dbName: String, + restored: MutableList + ): String { + val mainDb = File(stagingDir, dbName) + val allowedNames = setOf(dbName, "$dbName-shm", "$dbName-wal") + // Support both the current ZIP backup format and older single-file database exports. + val isZip = contentResolver.openInputStream(restoreUri)?.use { input -> + val header = ByteArray(4) + val read = input.read(header) + read == 4 && + header[0] == 0x50.toByte() && + header[1] == 0x4B.toByte() && + header[2] == 0x03.toByte() && + header[3] == 0x04.toByte() + } ?: false + + if (isZip) { + contentResolver.openInputStream(restoreUri)?.use { input -> + ZipInputStream(input).use { zis -> + var entry = zis.nextEntry + while (entry != null) { + val entryName = entry.name + when { + entry.isDirectory -> Unit + // ZIP restores only accept the database files at the archive root. + entryName.contains('/') || entryName.contains('\\') -> { + LogManager.w(TAG, "Skipping nested ZIP entry '$entryName' during restore.") + } + entryName !in allowedNames -> { + LogManager.w(TAG, "Skipping unexpected ZIP entry '$entryName' during restore.") + } + else -> { + val out = File(stagingDir, entryName) + FileOutputStream(out).use { zis.copyTo(it) } + restored += entryName + } + } + entry = zis.nextEntry + } + } + } ?: throw IOException("Cannot open InputStream for Uri: $restoreUri") + } else { + contentResolver.openInputStream(restoreUri)?.use { input -> + FileOutputStream(mainDb).use { output -> input.copyTo(output) } + restored += dbName + } ?: throw IOException("Cannot open InputStream for Uri: $restoreUri") + } + + // The staged main database must both look like SQLite and match the openScale schema + // before the live files are closed or replaced. + require(mainDb.exists()) { "Main DB file '$dbName' missing in backup" } + require(isValidOpenScaleMainDb(mainDb)) { + "Main DB file '$dbName' is not a valid openScale database" + } + + return if (isZip) "zip" else "legacy" + } + + private fun swapStagedDatabaseFiles( + dbDir: File, + stagingDir: File, + rollbackDir: File, + dbName: String + ) { + val managedNames = listOf(dbName, "$dbName-shm", "$dbName-wal") + val liveFiles = managedNames.associateWith { name -> File(dbDir, name) } + val rollbackFiles = managedNames.associateWith { name -> File(rollbackDir, name) } + val stagedFiles = managedNames.associateWith { name -> File(stagingDir, name) } + + val movedLiveNames = mutableListOf() + try { + // Move the current live files aside first so they can be restored if the swap fails. + managedNames.forEach { name -> + val live = liveFiles.getValue(name) + if (live.exists()) { + moveReplacing(live, rollbackFiles.getValue(name)) + movedLiveNames += name + } + } + + // Promote the staged files into the live database location. + managedNames.forEach { name -> + val staged = stagedFiles.getValue(name) + if (staged.exists()) { + moveReplacing(staged, liveFiles.getValue(name)) + } + } + } catch (swapError: Exception) { + // Remove any partially restored files before moving the previous live files back. + managedNames.forEach { name -> + val live = liveFiles.getValue(name) + if (live.exists() && !live.delete()) { + LogManager.w( + TAG, + "Could not delete partially restored file ${live.absolutePath} during rollback." + ) + } + } + + movedLiveNames.asReversed().forEach { name -> + val rollback = rollbackFiles.getValue(name) + if (!rollback.exists()) return@forEach + + try { + moveReplacing(rollback, liveFiles.getValue(name)) + } catch (rollbackError: Exception) { + swapError.addSuppressed(rollbackError) + LogManager.e( + TAG, + "Failed to roll back database file '$name' after restore error.", + rollbackError + ) + } + } + + throw swapError + } + } + + private fun moveReplacing(source: File, destination: File) { + destination.parentFile?.mkdirs() + try { + // Keep the swap atomic when the filesystem supports it. + Files.move( + source.toPath(), + destination.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move( + source.toPath(), + destination.toPath(), + StandardCopyOption.REPLACE_EXISTING + ) + } + } + + private fun isValidOpenScaleMainDb(file: File): Boolean { + if (!hasSqliteHeader(file)) return false + + return try { + val database = SQLiteDatabase.openDatabase( + file.absolutePath, + null, + SQLiteDatabase.OPEN_READONLY + ) + try { + val tableNames = mutableSetOf() + val cursor = database.rawQuery( + "SELECT name FROM sqlite_master WHERE type='table'", + null + ) + cursor.use { + while (it.moveToNext()) { + tableNames += it.getString(0) + } + } + + // Accept both the current Room schema and the legacy schema that older + // openScale backups may still contain. + tableNames.containsAll(CURRENT_OPEN_SCALE_TABLES) || + tableNames.containsAll(LEGACY_OPEN_SCALE_TABLES) + } finally { + database.close() + } + } catch (_: Exception) { + false + } + } + + private fun hasSqliteHeader(file: File): Boolean { + if (!file.exists() || file.length() < SQLITE_HEADER_PREFIX.size) return false + + val header = ByteArray(SQLITE_HEADER_PREFIX.size) + FileInputStream(file).use { input -> + val read = input.read(header) + if (read != header.size) return false + } + + return header.contentEquals(SQLITE_HEADER_PREFIX) + } + + private companion object { + private val CURRENT_OPEN_SCALE_TABLES = setOf( + "User", + "Measurement", + "MeasurementType", + "MeasurementValue" + ) + private val LEGACY_OPEN_SCALE_TABLES = setOf( + "scaleUsers", + "scaleMeasurements" + ) + private val SQLITE_HEADER_PREFIX = "SQLite format 3\u0000".toByteArray(Charsets.US_ASCII) } }