Skip to content

Commit c1933d6

Browse files
committed
feat(android): add ProfileMigrations scaffold + safety export on upgrade
1 parent 4ac31ec commit c1933d6

3 files changed

Lines changed: 98 additions & 1 deletion

File tree

android/app/src/main/java/com/gooserelay/gooserelayvpn/data/local/AppDatabase.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ abstract class AppDatabase : RoomDatabase() {
1919
context.applicationContext,
2020
AppDatabase::class.java,
2121
"gooserelay_vpn.db"
22-
).fallbackToDestructiveMigration().build().also { INSTANCE = it }
22+
)
23+
.addMigrations(*ProfileMigrations.ALL)
24+
.build().also { INSTANCE = it }
2325
}
2426
}
2527
}

android/app/src/main/java/com/gooserelay/gooserelayvpn/data/local/ProfileDao.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ interface ProfileDao {
2323
@Query("SELECT * FROM profiles ORDER BY createdAt DESC LIMIT 1")
2424
suspend fun getNewestProfile(): ProfileEntity?
2525

26+
@Query("SELECT * FROM profiles")
27+
suspend fun getAllOnce(): List<ProfileEntity>
28+
2629
@Insert(onConflict = OnConflictStrategy.REPLACE)
2730
suspend fun insertProfile(profile: ProfileEntity): Long
2831

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package com.gooserelay.gooserelayvpn.data.local
2+
3+
import android.content.Context
4+
import androidx.room.migration.Migration
5+
import androidx.room.Room
6+
import androidx.room.RoomDatabase
7+
import androidx.sqlite.db.SupportSQLiteDatabase
8+
import com.google.gson.Gson
9+
import com.google.gson.JsonObject
10+
import java.io.File
11+
12+
/**
13+
* Central place for Room migrations. Each Migration(N, N+1) lives here.
14+
*
15+
* Convention: every migration that risks user data MUST be preceded by
16+
* an on-upgrade export of the current profile table to
17+
* `cacheDir/profiles_backup_<fromVersion>_to_<toVersion>_<timestamp>.json`
18+
* before the schema change. See [SafetyExportMigration].
19+
*/
20+
object ProfileMigrations {
21+
22+
private val gson = Gson()
23+
24+
/**
25+
* Run before [delegate] applies its schema change. Reads every profile
26+
* from the v`from` schema and writes a JSON snapshot to the app's cache
27+
* directory. If the export fails, the migration is NOT applied and an
28+
* [IllegalStateException] is thrown so the user sees a crash instead
29+
* of silent data loss.
30+
*/
31+
class SafetyExportMigration(
32+
private val context: Context,
33+
private val from: Int,
34+
private val to: Int,
35+
private val delegate: Migration
36+
) : Migration(from, to) {
37+
38+
override fun migrate(db: SupportSQLiteDatabase) {
39+
exportCurrentProfiles(db)
40+
delegate.migrate(db)
41+
}
42+
43+
private fun exportCurrentProfiles(db: SupportSQLiteDatabase) {
44+
val cursor = db.query("SELECT * FROM profiles")
45+
val rows = mutableListOf<JsonObject>()
46+
cursor.use { c ->
47+
while (c.moveToNext()) {
48+
val obj = JsonObject()
49+
// Column order matches ProfileEntity field order at v3.
50+
// Use column names defensively — older schema snapshots
51+
// may differ.
52+
fun col(name: String): String =
53+
c.getString(c.getColumnIndexOrThrow(name))
54+
obj.apply {
55+
addProperty("id", c.getLong(c.getColumnIndexOrThrow("id")))
56+
addProperty("name", col("name"))
57+
addProperty("debugTiming", c.getInt(c.getColumnIndexOrThrow("debugTiming")) != 0)
58+
addProperty("socksHost", col("socksHost"))
59+
addProperty("socksPort", c.getInt(c.getColumnIndexOrThrow("socksPort")))
60+
addProperty("socksUser", col("socksUser"))
61+
addProperty("socksPass", col("socksPass"))
62+
addProperty("googleHost", col("googleHost"))
63+
addProperty("sniJson", col("sniJson"))
64+
addProperty("scriptKeysText", col("scriptKeysText"))
65+
addProperty("tunnelKey", col("tunnelKey"))
66+
addProperty("coalesceStepMs", c.getInt(c.getColumnIndexOrThrow("coalesceStepMs")))
67+
addProperty("idleSlotsPerBucket", c.getInt(c.getColumnIndexOrThrow("idleSlotsPerBucket")))
68+
addProperty("remoteUrl", if (c.isNull(c.getColumnIndexOrThrow("remoteUrl"))) null else col("remoteUrl"))
69+
addProperty("isSelected", c.getInt(c.getColumnIndexOrThrow("isSelected")) != 0)
70+
addProperty("createdAt", c.getLong(c.getColumnIndexOrThrow("createdAt")))
71+
}
72+
rows.add(obj)
73+
}
74+
}
75+
val snapshot = JsonObject().apply {
76+
addProperty("schemaVersion", from)
77+
add("profiles", gson.toJsonTree(rows))
78+
}
79+
val backupFile = File(
80+
context.cacheDir,
81+
"profiles_backup_v${from}_to_v${to}_${System.currentTimeMillis()}.json"
82+
)
83+
backupFile.writeText(gson.toJson(snapshot))
84+
}
85+
}
86+
87+
/**
88+
* All registered migrations. Add new `SafetyExportMigration(...)`
89+
* entries here as the schema evolves. Empty until v3→v4 is needed.
90+
*/
91+
val ALL: Array<Migration> = arrayOf()
92+
}

0 commit comments

Comments
 (0)