Skip to content

Commit 29ceb88

Browse files
committed
fix(android): register v2->v3 and v3->v4 migrations, bump DB to v4
1 parent 27b8a05 commit 29ceb88

5 files changed

Lines changed: 365 additions & 6 deletions

File tree

android/app/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ dependencies {
167167
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
168168
testImplementation("com.google.truth:truth:1.4.4")
169169
testImplementation("org.json:json:20240303")
170+
testImplementation("org.xerial:sqlite-jdbc:3.45.1.0")
170171

171172
// Instrumented tests (Room migration)
172173
androidTestImplementation("androidx.test.ext:junit:1.2.1")
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
{
2+
"formatVersion": 1,
3+
"database": {
4+
"version": 4,
5+
"identityHash": "21577208642f1e1ac37aa9674fd36d98",
6+
"entities": [
7+
{
8+
"tableName": "profiles",
9+
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `debugTiming` INTEGER NOT NULL, `socksHost` TEXT NOT NULL, `socksPort` INTEGER NOT NULL, `socksUser` TEXT NOT NULL, `socksPass` TEXT NOT NULL, `googleHost` TEXT NOT NULL, `sniJson` TEXT NOT NULL, `scriptKeysText` TEXT NOT NULL, `tunnelKey` TEXT NOT NULL, `coalesceStepMs` INTEGER NOT NULL, `idleSlotsPerBucket` INTEGER NOT NULL, `remoteUrl` TEXT, `isSelected` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)",
10+
"fields": [
11+
{
12+
"fieldPath": "id",
13+
"columnName": "id",
14+
"affinity": "INTEGER",
15+
"notNull": true
16+
},
17+
{
18+
"fieldPath": "name",
19+
"columnName": "name",
20+
"affinity": "TEXT",
21+
"notNull": true
22+
},
23+
{
24+
"fieldPath": "debugTiming",
25+
"columnName": "debugTiming",
26+
"affinity": "INTEGER",
27+
"notNull": true
28+
},
29+
{
30+
"fieldPath": "socksHost",
31+
"columnName": "socksHost",
32+
"affinity": "TEXT",
33+
"notNull": true
34+
},
35+
{
36+
"fieldPath": "socksPort",
37+
"columnName": "socksPort",
38+
"affinity": "INTEGER",
39+
"notNull": true
40+
},
41+
{
42+
"fieldPath": "socksUser",
43+
"columnName": "socksUser",
44+
"affinity": "TEXT",
45+
"notNull": true
46+
},
47+
{
48+
"fieldPath": "socksPass",
49+
"columnName": "socksPass",
50+
"affinity": "TEXT",
51+
"notNull": true
52+
},
53+
{
54+
"fieldPath": "googleHost",
55+
"columnName": "googleHost",
56+
"affinity": "TEXT",
57+
"notNull": true
58+
},
59+
{
60+
"fieldPath": "sniJson",
61+
"columnName": "sniJson",
62+
"affinity": "TEXT",
63+
"notNull": true
64+
},
65+
{
66+
"fieldPath": "scriptKeysText",
67+
"columnName": "scriptKeysText",
68+
"affinity": "TEXT",
69+
"notNull": true
70+
},
71+
{
72+
"fieldPath": "tunnelKey",
73+
"columnName": "tunnelKey",
74+
"affinity": "TEXT",
75+
"notNull": true
76+
},
77+
{
78+
"fieldPath": "coalesceStepMs",
79+
"columnName": "coalesceStepMs",
80+
"affinity": "INTEGER",
81+
"notNull": true
82+
},
83+
{
84+
"fieldPath": "idleSlotsPerBucket",
85+
"columnName": "idleSlotsPerBucket",
86+
"affinity": "INTEGER",
87+
"notNull": true
88+
},
89+
{
90+
"fieldPath": "remoteUrl",
91+
"columnName": "remoteUrl",
92+
"affinity": "TEXT",
93+
"notNull": false
94+
},
95+
{
96+
"fieldPath": "isSelected",
97+
"columnName": "isSelected",
98+
"affinity": "INTEGER",
99+
"notNull": true
100+
},
101+
{
102+
"fieldPath": "createdAt",
103+
"columnName": "createdAt",
104+
"affinity": "INTEGER",
105+
"notNull": true
106+
}
107+
],
108+
"primaryKey": {
109+
"autoGenerate": true,
110+
"columnNames": [
111+
"id"
112+
]
113+
},
114+
"indices": [],
115+
"foreignKeys": []
116+
}
117+
],
118+
"views": [],
119+
"setupQueries": [
120+
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
121+
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '21577208642f1e1ac37aa9674fd36d98')"
122+
]
123+
}
124+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import androidx.room.Database
55
import androidx.room.Room
66
import androidx.room.RoomDatabase
77

8-
@Database(entities = [ProfileEntity::class], version = 3, exportSchema = true)
8+
@Database(entities = [ProfileEntity::class], version = 4, exportSchema = true)
99
abstract class AppDatabase : RoomDatabase() {
1010
abstract fun profileDao(): ProfileDao
1111

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

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,39 @@ object ProfileMigrations {
8484
}
8585
}
8686

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()
87+
private fun existingColumns(db: SupportSQLiteDatabase): Set<String> {
88+
val columns = mutableSetOf<String>()
89+
db.query("PRAGMA table_info(profiles)").use { c ->
90+
val nameIdx = c.getColumnIndex("name")
91+
while (c.moveToNext()) columns.add(c.getString(nameIdx))
92+
}
93+
return columns
94+
}
95+
96+
internal fun migrationSql2To3(existing: Set<String>): List<String> =
97+
listOf(
98+
"socksUser" to "ALTER TABLE profiles ADD COLUMN socksUser TEXT NOT NULL DEFAULT ''",
99+
"socksPass" to "ALTER TABLE profiles ADD COLUMN socksPass TEXT NOT NULL DEFAULT ''"
100+
).filterNot { it.first in existing }.map { it.second }
101+
102+
internal fun migrationSql3To4(existing: Set<String>): List<String> =
103+
listOf(
104+
"coalesceStepMs" to "ALTER TABLE profiles ADD COLUMN coalesceStepMs INTEGER NOT NULL DEFAULT 0",
105+
"idleSlotsPerBucket" to "ALTER TABLE profiles ADD COLUMN idleSlotsPerBucket INTEGER NOT NULL DEFAULT 2",
106+
"remoteUrl" to "ALTER TABLE profiles ADD COLUMN remoteUrl TEXT"
107+
).filterNot { it.first in existing }.map { it.second }
108+
109+
val MIGRATION_2_3 = object : Migration(2, 3) {
110+
override fun migrate(db: SupportSQLiteDatabase) {
111+
migrationSql2To3(existingColumns(db)).forEach { db.execSQL(it) }
112+
}
113+
}
114+
115+
val MIGRATION_3_4 = object : Migration(3, 4) {
116+
override fun migrate(db: SupportSQLiteDatabase) {
117+
migrationSql3To4(existingColumns(db)).forEach { db.execSQL(it) }
118+
}
119+
}
120+
121+
val ALL: Array<Migration> = arrayOf(MIGRATION_2_3, MIGRATION_3_4)
92122
}
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
package com.gooserelay.gooserelayvpn
2+
3+
import com.google.common.truth.Truth.assertThat
4+
import com.gooserelay.gooserelayvpn.data.local.ProfileMigrations
5+
import org.json.JSONObject
6+
import org.junit.Test
7+
import java.io.File
8+
import java.sql.Connection
9+
import java.sql.DriverManager
10+
11+
class ProfileMigrationsTest {
12+
13+
private fun openMemoryDb(): Connection =
14+
DriverManager.getConnection("jdbc:sqlite::memory:")
15+
16+
private fun existingColumns(conn: Connection): Set<String> {
17+
val columns = mutableSetOf<String>()
18+
conn.createStatement().use { st ->
19+
st.executeQuery("SELECT name FROM pragma_table_info('profiles')").use { rs ->
20+
while (rs.next()) columns.add(rs.getString(1))
21+
}
22+
}
23+
return columns
24+
}
25+
26+
private fun columnTypes(conn: Connection): Map<String, Pair<String, Int>> {
27+
val info = mutableMapOf<String, Pair<String, Int>>()
28+
conn.createStatement().use { st ->
29+
st.executeQuery("SELECT name, type, \"notnull\" FROM pragma_table_info('profiles')").use { rs ->
30+
while (rs.next()) info[rs.getString(1)] = rs.getString(2) to rs.getInt(3)
31+
}
32+
}
33+
return info
34+
}
35+
36+
private fun exec(conn: Connection, sql: String) {
37+
conn.createStatement().use { it.execute(sql) }
38+
}
39+
40+
@Test
41+
fun `registers exactly migrations 2 to 3 and 3 to 4`() {
42+
val ranges = ProfileMigrations.ALL.map { it.startVersion to it.endVersion }
43+
assertThat(ranges).containsExactly(2 to 3, 3 to 4).inOrder()
44+
}
45+
46+
@Test
47+
fun `v2 table migrates to all 16 columns preserving data`() {
48+
val schemaJson = File("schemas/com.gooserelay.gooserelayvpn.data.local.AppDatabase/2.json")
49+
.readText()
50+
val createSql = JSONObject(schemaJson)
51+
.getJSONObject("database")
52+
.getJSONArray("entities")
53+
.getJSONObject(0)
54+
.getString("createSql")
55+
.replace("\${TABLE_NAME}", "profiles")
56+
57+
openMemoryDb().use { conn ->
58+
exec(conn, createSql)
59+
exec(
60+
conn,
61+
"INSERT INTO profiles (name, debugTiming, socksHost, socksPort, googleHost, " +
62+
"sniJson, scriptKeysText, tunnelKey, isSelected, createdAt) " +
63+
"VALUES ('x', 0, '127.0.0.1', 1080, '216.239.38.120', '[]', '', 'k', 0, 1)"
64+
)
65+
66+
ProfileMigrations.migrationSql2To3(existingColumns(conn)).forEach { exec(conn, it) }
67+
ProfileMigrations.migrationSql3To4(existingColumns(conn)).forEach { exec(conn, it) }
68+
69+
val info = columnTypes(conn)
70+
assertThat(info.keys).containsExactly(
71+
"id", "name", "debugTiming", "socksHost", "socksPort", "socksUser", "socksPass",
72+
"googleHost", "sniJson", "scriptKeysText", "tunnelKey", "coalesceStepMs",
73+
"idleSlotsPerBucket", "remoteUrl", "isSelected", "createdAt"
74+
)
75+
assertThat(info["socksUser"]).isEqualTo("TEXT" to 1)
76+
assertThat(info["socksPass"]).isEqualTo("TEXT" to 1)
77+
assertThat(info["coalesceStepMs"]).isEqualTo("INTEGER" to 1)
78+
assertThat(info["idleSlotsPerBucket"]).isEqualTo("INTEGER" to 1)
79+
assertThat(info["remoteUrl"]).isEqualTo("TEXT" to 0)
80+
81+
conn.createStatement().use { st ->
82+
st.executeQuery(
83+
"SELECT name, debugTiming, socksHost, socksPort, googleHost, " +
84+
"sniJson, scriptKeysText, tunnelKey, isSelected, createdAt FROM profiles"
85+
).use { rs ->
86+
assertThat(rs.next()).isTrue()
87+
assertThat(rs.getString(1)).isEqualTo("x")
88+
assertThat(rs.getInt(2)).isEqualTo(0)
89+
assertThat(rs.getString(3)).isEqualTo("127.0.0.1")
90+
assertThat(rs.getInt(4)).isEqualTo(1080)
91+
assertThat(rs.getString(5)).isEqualTo("216.239.38.120")
92+
assertThat(rs.getString(6)).isEqualTo("[]")
93+
assertThat(rs.getString(7)).isEqualTo("")
94+
assertThat(rs.getString(8)).isEqualTo("k")
95+
assertThat(rs.getInt(9)).isEqualTo(0)
96+
assertThat(rs.getLong(10)).isEqualTo(1L)
97+
assertThat(rs.next()).isFalse()
98+
}
99+
}
100+
}
101+
}
102+
103+
@Test
104+
fun `v3 fifteen column table migrates to v4 preserving data`() {
105+
openMemoryDb().use { conn ->
106+
exec(
107+
conn,
108+
"CREATE TABLE IF NOT EXISTS `profiles` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
109+
"`name` TEXT NOT NULL, `debugTiming` INTEGER NOT NULL, `socksHost` TEXT NOT NULL, " +
110+
"`socksPort` INTEGER NOT NULL, `socksUser` TEXT NOT NULL, `socksPass` TEXT NOT NULL, " +
111+
"`googleHost` TEXT NOT NULL, `sniJson` TEXT NOT NULL, `scriptKeysText` TEXT NOT NULL, " +
112+
"`tunnelKey` TEXT NOT NULL, `coalesceStepMs` INTEGER NOT NULL, " +
113+
"`idleSlotsPerBucket` INTEGER NOT NULL, `isSelected` INTEGER NOT NULL, " +
114+
"`createdAt` INTEGER NOT NULL)"
115+
)
116+
exec(
117+
conn,
118+
"INSERT INTO profiles (name, debugTiming, socksHost, socksPort, socksUser, socksPass, " +
119+
"googleHost, sniJson, scriptKeysText, tunnelKey, coalesceStepMs, idleSlotsPerBucket, " +
120+
"isSelected, createdAt) " +
121+
"VALUES ('y', 1, '10.0.0.1', 1081, 'u', 'p', '216.239.38.120', '[]', 'sk', 'k2', 0, 2, 1, 2)"
122+
)
123+
124+
val before = existingColumns(conn)
125+
ProfileMigrations.migrationSql3To4(before).forEach { exec(conn, it) }
126+
127+
val after = existingColumns(conn)
128+
assertThat(after - before).containsExactly("remoteUrl")
129+
assertThat(after).containsExactly(
130+
"id", "name", "debugTiming", "socksHost", "socksPort", "socksUser", "socksPass",
131+
"googleHost", "sniJson", "scriptKeysText", "tunnelKey", "coalesceStepMs",
132+
"idleSlotsPerBucket", "remoteUrl", "isSelected", "createdAt"
133+
)
134+
135+
conn.createStatement().use { st ->
136+
st.executeQuery(
137+
"SELECT name, socksUser, socksPass, tunnelKey, coalesceStepMs, " +
138+
"idleSlotsPerBucket FROM profiles"
139+
).use { rs ->
140+
assertThat(rs.next()).isTrue()
141+
assertThat(rs.getString(1)).isEqualTo("y")
142+
assertThat(rs.getString(2)).isEqualTo("u")
143+
assertThat(rs.getString(3)).isEqualTo("p")
144+
assertThat(rs.getString(4)).isEqualTo("k2")
145+
assertThat(rs.getInt(5)).isEqualTo(0)
146+
assertThat(rs.getInt(6)).isEqualTo(2)
147+
assertThat(rs.next()).isFalse()
148+
}
149+
}
150+
}
151+
}
152+
153+
@Test
154+
fun `v3 thirteen column table migrates without duplicating socksUser and socksPass`() {
155+
openMemoryDb().use { conn ->
156+
exec(
157+
conn,
158+
"CREATE TABLE IF NOT EXISTS `profiles` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
159+
"`name` TEXT NOT NULL, `debugTiming` INTEGER NOT NULL, `socksHost` TEXT NOT NULL, " +
160+
"`socksPort` INTEGER NOT NULL, `socksUser` TEXT NOT NULL, `socksPass` TEXT NOT NULL, " +
161+
"`googleHost` TEXT NOT NULL, `sniJson` TEXT NOT NULL, `scriptKeysText` TEXT NOT NULL, " +
162+
"`tunnelKey` TEXT NOT NULL, `isSelected` INTEGER NOT NULL, " +
163+
"`createdAt` INTEGER NOT NULL)"
164+
)
165+
exec(
166+
conn,
167+
"INSERT INTO profiles (name, debugTiming, socksHost, socksPort, socksUser, socksPass, " +
168+
"googleHost, sniJson, scriptKeysText, tunnelKey, isSelected, createdAt) " +
169+
"VALUES ('z', 0, '127.0.0.1', 1080, 'u2', 'p2', '216.239.38.120', '[]', '', 'k3', 0, 3)"
170+
)
171+
172+
val before = existingColumns(conn)
173+
ProfileMigrations.migrationSql3To4(before).forEach { exec(conn, it) }
174+
175+
val after = existingColumns(conn)
176+
assertThat(after - before).containsExactly(
177+
"coalesceStepMs", "idleSlotsPerBucket", "remoteUrl"
178+
)
179+
assertThat(after.count { it == "socksUser" }).isEqualTo(1)
180+
assertThat(after.count { it == "socksPass" }).isEqualTo(1)
181+
182+
conn.createStatement().use { st ->
183+
st.executeQuery("SELECT name, socksUser, socksPass, tunnelKey FROM profiles").use { rs ->
184+
assertThat(rs.next()).isTrue()
185+
assertThat(rs.getString(1)).isEqualTo("z")
186+
assertThat(rs.getString(2)).isEqualTo("u2")
187+
assertThat(rs.getString(3)).isEqualTo("p2")
188+
assertThat(rs.getString(4)).isEqualTo("k3")
189+
assertThat(rs.next()).isFalse()
190+
}
191+
}
192+
}
193+
}
194+
195+
@Test
196+
fun `migrationSql3To4 is a no-op on the full v4 column set`() {
197+
val full16 = setOf(
198+
"id", "name", "debugTiming", "socksHost", "socksPort", "socksUser", "socksPass",
199+
"googleHost", "sniJson", "scriptKeysText", "tunnelKey", "coalesceStepMs",
200+
"idleSlotsPerBucket", "remoteUrl", "isSelected", "createdAt"
201+
)
202+
assertThat(ProfileMigrations.migrationSql3To4(full16)).isEmpty()
203+
}
204+
}

0 commit comments

Comments
 (0)