diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c6a77c4..e32f793 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -99,6 +99,10 @@ android { } } + ksp { + arg("room.schemaLocation", "$projectDir/schemas") + } + splits { abi { // Generate split APKs plus a universal APK. @@ -163,4 +167,9 @@ dependencies { testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") testImplementation("com.google.truth:truth:1.4.4") testImplementation("org.json:json:20240303") + + // Instrumented tests (Room migration) + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test:runner:1.6.2") + androidTestImplementation("androidx.room:room-testing:2.6.1") } diff --git a/android/app/src/main/java/com/gooserelay/gooserelayvpn/data/local/AppDatabase.kt b/android/app/src/main/java/com/gooserelay/gooserelayvpn/data/local/AppDatabase.kt index 5599475..69a0388 100644 --- a/android/app/src/main/java/com/gooserelay/gooserelayvpn/data/local/AppDatabase.kt +++ b/android/app/src/main/java/com/gooserelay/gooserelayvpn/data/local/AppDatabase.kt @@ -5,7 +5,7 @@ import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase -@Database(entities = [ProfileEntity::class], version = 3, exportSchema = false) +@Database(entities = [ProfileEntity::class], version = 3, exportSchema = true) abstract class AppDatabase : RoomDatabase() { abstract fun profileDao(): ProfileDao @@ -19,7 +19,9 @@ abstract class AppDatabase : RoomDatabase() { context.applicationContext, AppDatabase::class.java, "gooserelay_vpn.db" - ).fallbackToDestructiveMigration().build().also { INSTANCE = it } + ) + .addMigrations(*ProfileMigrations.ALL) + .build().also { INSTANCE = it } } } } diff --git a/android/app/src/main/java/com/gooserelay/gooserelayvpn/data/local/ProfileDao.kt b/android/app/src/main/java/com/gooserelay/gooserelayvpn/data/local/ProfileDao.kt index b99dbea..7020a49 100644 --- a/android/app/src/main/java/com/gooserelay/gooserelayvpn/data/local/ProfileDao.kt +++ b/android/app/src/main/java/com/gooserelay/gooserelayvpn/data/local/ProfileDao.kt @@ -23,6 +23,9 @@ interface ProfileDao { @Query("SELECT * FROM profiles ORDER BY createdAt DESC LIMIT 1") suspend fun getNewestProfile(): ProfileEntity? + @Query("SELECT * FROM profiles") + suspend fun getAllOnce(): List + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertProfile(profile: ProfileEntity): Long diff --git a/android/app/src/main/java/com/gooserelay/gooserelayvpn/data/local/ProfileMigrations.kt b/android/app/src/main/java/com/gooserelay/gooserelayvpn/data/local/ProfileMigrations.kt new file mode 100644 index 0000000..3cd164e --- /dev/null +++ b/android/app/src/main/java/com/gooserelay/gooserelayvpn/data/local/ProfileMigrations.kt @@ -0,0 +1,92 @@ +package com.gooserelay.gooserelayvpn.data.local + +import android.content.Context +import androidx.room.migration.Migration +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.sqlite.db.SupportSQLiteDatabase +import com.google.gson.Gson +import com.google.gson.JsonObject +import java.io.File + +/** + * Central place for Room migrations. Each Migration(N, N+1) lives here. + * + * Convention: every migration that risks user data MUST be preceded by + * an on-upgrade export of the current profile table to + * `cacheDir/profiles_backup__to__.json` + * before the schema change. See [SafetyExportMigration]. + */ +object ProfileMigrations { + + private val gson = Gson() + + /** + * Run before [delegate] applies its schema change. Reads every profile + * from the v`from` schema and writes a JSON snapshot to the app's cache + * directory. If the export fails, the migration is NOT applied and an + * [IllegalStateException] is thrown so the user sees a crash instead + * of silent data loss. + */ + class SafetyExportMigration( + private val context: Context, + private val from: Int, + private val to: Int, + private val delegate: Migration + ) : Migration(from, to) { + + override fun migrate(db: SupportSQLiteDatabase) { + exportCurrentProfiles(db) + delegate.migrate(db) + } + + private fun exportCurrentProfiles(db: SupportSQLiteDatabase) { + val cursor = db.query("SELECT * FROM profiles") + val rows = mutableListOf() + cursor.use { c -> + while (c.moveToNext()) { + val obj = JsonObject() + // Column order matches ProfileEntity field order at v3. + // Use column names defensively — older schema snapshots + // may differ. + fun col(name: String): String = + c.getString(c.getColumnIndexOrThrow(name)) + obj.apply { + addProperty("id", c.getLong(c.getColumnIndexOrThrow("id"))) + addProperty("name", col("name")) + addProperty("debugTiming", c.getInt(c.getColumnIndexOrThrow("debugTiming")) != 0) + addProperty("socksHost", col("socksHost")) + addProperty("socksPort", c.getInt(c.getColumnIndexOrThrow("socksPort"))) + addProperty("socksUser", col("socksUser")) + addProperty("socksPass", col("socksPass")) + addProperty("googleHost", col("googleHost")) + addProperty("sniJson", col("sniJson")) + addProperty("scriptKeysText", col("scriptKeysText")) + addProperty("tunnelKey", col("tunnelKey")) + addProperty("coalesceStepMs", c.getInt(c.getColumnIndexOrThrow("coalesceStepMs"))) + addProperty("idleSlotsPerBucket", c.getInt(c.getColumnIndexOrThrow("idleSlotsPerBucket"))) + addProperty("remoteUrl", if (c.isNull(c.getColumnIndexOrThrow("remoteUrl"))) null else col("remoteUrl")) + addProperty("isSelected", c.getInt(c.getColumnIndexOrThrow("isSelected")) != 0) + addProperty("createdAt", c.getLong(c.getColumnIndexOrThrow("createdAt"))) + } + rows.add(obj) + } + } + val snapshot = JsonObject().apply { + addProperty("schemaVersion", from) + add("profiles", gson.toJsonTree(rows)) + } + val backupFile = File( + context.cacheDir, + "profiles_backup_v${from}_to_v${to}_${System.currentTimeMillis()}.json" + ) + backupFile.writeText(gson.toJson(snapshot)) + } + } + + /** + * All registered migrations. Add new `SafetyExportMigration(...)` + * entries here as the schema evolves. Empty until v3→v4 is needed. + */ + val ALL: Array = arrayOf() +} diff --git a/android/app/src/test/java/com/gooserelay/gooserelayvpn/ProfilesViewModelParseTest.kt b/android/app/src/test/java/com/gooserelay/gooserelayvpn/ProfilesViewModelParseTest.kt index 86c15c1..de53c1b 100644 --- a/android/app/src/test/java/com/gooserelay/gooserelayvpn/ProfilesViewModelParseTest.kt +++ b/android/app/src/test/java/com/gooserelay/gooserelayvpn/ProfilesViewModelParseTest.kt @@ -20,6 +20,7 @@ class ProfilesViewModelParseTest { override suspend fun getSelectedProfile(): ProfileEntity? = null override fun getSelectedProfileFlow(): Flow = emptyFlow() override suspend fun getNewestProfile(): ProfileEntity? = null + override suspend fun getAllOnce(): List = emptyList() override suspend fun insertProfile(profile: ProfileEntity): Long = 0L override suspend fun updateProfile(profile: ProfileEntity) {} override suspend fun deleteProfile(profile: ProfileEntity) {}