diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index e32f793..efc6d7f 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -167,6 +167,7 @@ dependencies { testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") testImplementation("com.google.truth:truth:1.4.4") testImplementation("org.json:json:20240303") + testImplementation("org.xerial:sqlite-jdbc:3.45.1.0") // Instrumented tests (Room migration) androidTestImplementation("androidx.test.ext:junit:1.2.1") diff --git a/android/app/schemas/com.gooserelay.gooserelayvpn.data.local.AppDatabase/2.json b/android/app/schemas/com.gooserelay.gooserelayvpn.data.local.AppDatabase/2.json new file mode 100644 index 0000000..2f6ae02 --- /dev/null +++ b/android/app/schemas/com.gooserelay.gooserelayvpn.data.local.AppDatabase/2.json @@ -0,0 +1,94 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "d4abfbbd22cc59fc3cf354366d60a1a7", + "entities": [ + { + "tableName": "profiles", + "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, `googleHost` TEXT NOT NULL, `sniJson` TEXT NOT NULL, `scriptKeysText` TEXT NOT NULL, `tunnelKey` TEXT NOT NULL, `isSelected` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "debugTiming", + "columnName": "debugTiming", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "socksHost", + "columnName": "socksHost", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socksPort", + "columnName": "socksPort", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "googleHost", + "columnName": "googleHost", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sniJson", + "columnName": "sniJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scriptKeysText", + "columnName": "scriptKeysText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tunnelKey", + "columnName": "tunnelKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isSelected", + "columnName": "isSelected", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd4abfbbd22cc59fc3cf354366d60a1a7')" + ] + } +} \ No newline at end of file diff --git a/android/app/schemas/com.gooserelay.gooserelayvpn.data.local.AppDatabase/3.json b/android/app/schemas/com.gooserelay.gooserelayvpn.data.local.AppDatabase/3.json new file mode 100644 index 0000000..bf6573e --- /dev/null +++ b/android/app/schemas/com.gooserelay.gooserelayvpn.data.local.AppDatabase/3.json @@ -0,0 +1,118 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "7cf2680a4a5066b2e91da28c08c7e4d0", + "entities": [ + { + "tableName": "profiles", + "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, `isSelected` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "debugTiming", + "columnName": "debugTiming", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "socksHost", + "columnName": "socksHost", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socksPort", + "columnName": "socksPort", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "socksUser", + "columnName": "socksUser", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socksPass", + "columnName": "socksPass", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "googleHost", + "columnName": "googleHost", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sniJson", + "columnName": "sniJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scriptKeysText", + "columnName": "scriptKeysText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tunnelKey", + "columnName": "tunnelKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coalesceStepMs", + "columnName": "coalesceStepMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "idleSlotsPerBucket", + "columnName": "idleSlotsPerBucket", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSelected", + "columnName": "isSelected", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7cf2680a4a5066b2e91da28c08c7e4d0')" + ] + } +} \ No newline at end of file diff --git a/android/app/schemas/com.gooserelay.gooserelayvpn.data.local.AppDatabase/4.json b/android/app/schemas/com.gooserelay.gooserelayvpn.data.local.AppDatabase/4.json new file mode 100644 index 0000000..e9a81c7 --- /dev/null +++ b/android/app/schemas/com.gooserelay.gooserelayvpn.data.local.AppDatabase/4.json @@ -0,0 +1,124 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "21577208642f1e1ac37aa9674fd36d98", + "entities": [ + { + "tableName": "profiles", + "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)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "debugTiming", + "columnName": "debugTiming", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "socksHost", + "columnName": "socksHost", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socksPort", + "columnName": "socksPort", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "socksUser", + "columnName": "socksUser", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socksPass", + "columnName": "socksPass", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "googleHost", + "columnName": "googleHost", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sniJson", + "columnName": "sniJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scriptKeysText", + "columnName": "scriptKeysText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tunnelKey", + "columnName": "tunnelKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coalesceStepMs", + "columnName": "coalesceStepMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "idleSlotsPerBucket", + "columnName": "idleSlotsPerBucket", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteUrl", + "columnName": "remoteUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSelected", + "columnName": "isSelected", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '21577208642f1e1ac37aa9674fd36d98')" + ] + } +} \ No newline at end of file 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 69a0388..38e60f9 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 = true) +@Database(entities = [ProfileEntity::class], version = 4, exportSchema = true) abstract class AppDatabase : RoomDatabase() { abstract fun profileDao(): ProfileDao 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 index 3cd164e..331bd76 100644 --- 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 @@ -84,9 +84,39 @@ object ProfileMigrations { } } - /** - * All registered migrations. Add new `SafetyExportMigration(...)` - * entries here as the schema evolves. Empty until v3→v4 is needed. - */ - val ALL: Array = arrayOf() + private fun existingColumns(db: SupportSQLiteDatabase): Set { + val columns = mutableSetOf() + db.query("PRAGMA table_info(profiles)").use { c -> + val nameIdx = c.getColumnIndex("name") + while (c.moveToNext()) columns.add(c.getString(nameIdx)) + } + return columns + } + + internal fun migrationSql2To3(existing: Set): List = + listOf( + "socksUser" to "ALTER TABLE profiles ADD COLUMN socksUser TEXT NOT NULL DEFAULT ''", + "socksPass" to "ALTER TABLE profiles ADD COLUMN socksPass TEXT NOT NULL DEFAULT ''" + ).filterNot { it.first in existing }.map { it.second } + + internal fun migrationSql3To4(existing: Set): List = + listOf( + "coalesceStepMs" to "ALTER TABLE profiles ADD COLUMN coalesceStepMs INTEGER NOT NULL DEFAULT 0", + "idleSlotsPerBucket" to "ALTER TABLE profiles ADD COLUMN idleSlotsPerBucket INTEGER NOT NULL DEFAULT 2", + "remoteUrl" to "ALTER TABLE profiles ADD COLUMN remoteUrl TEXT" + ).filterNot { it.first in existing }.map { it.second } + + val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + migrationSql2To3(existingColumns(db)).forEach { db.execSQL(it) } + } + } + + val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + migrationSql3To4(existingColumns(db)).forEach { db.execSQL(it) } + } + } + + val ALL: Array = arrayOf(MIGRATION_2_3, MIGRATION_3_4) } diff --git a/android/app/src/test/java/com/gooserelay/gooserelayvpn/ProfileMigrationsTest.kt b/android/app/src/test/java/com/gooserelay/gooserelayvpn/ProfileMigrationsTest.kt new file mode 100644 index 0000000..6e59b10 --- /dev/null +++ b/android/app/src/test/java/com/gooserelay/gooserelayvpn/ProfileMigrationsTest.kt @@ -0,0 +1,204 @@ +package com.gooserelay.gooserelayvpn + +import com.google.common.truth.Truth.assertThat +import com.gooserelay.gooserelayvpn.data.local.ProfileMigrations +import org.json.JSONObject +import org.junit.Test +import java.io.File +import java.sql.Connection +import java.sql.DriverManager + +class ProfileMigrationsTest { + + private fun openMemoryDb(): Connection = + DriverManager.getConnection("jdbc:sqlite::memory:") + + private fun existingColumns(conn: Connection): Set { + val columns = mutableSetOf() + conn.createStatement().use { st -> + st.executeQuery("SELECT name FROM pragma_table_info('profiles')").use { rs -> + while (rs.next()) columns.add(rs.getString(1)) + } + } + return columns + } + + private fun columnTypes(conn: Connection): Map> { + val info = mutableMapOf>() + conn.createStatement().use { st -> + st.executeQuery("SELECT name, type, \"notnull\" FROM pragma_table_info('profiles')").use { rs -> + while (rs.next()) info[rs.getString(1)] = rs.getString(2) to rs.getInt(3) + } + } + return info + } + + private fun exec(conn: Connection, sql: String) { + conn.createStatement().use { it.execute(sql) } + } + + @Test + fun `registers exactly migrations 2 to 3 and 3 to 4`() { + val ranges = ProfileMigrations.ALL.map { it.startVersion to it.endVersion } + assertThat(ranges).containsExactly(2 to 3, 3 to 4).inOrder() + } + + @Test + fun `v2 table migrates to all 16 columns preserving data`() { + val schemaJson = File("schemas/com.gooserelay.gooserelayvpn.data.local.AppDatabase/2.json") + .readText() + val createSql = JSONObject(schemaJson) + .getJSONObject("database") + .getJSONArray("entities") + .getJSONObject(0) + .getString("createSql") + .replace("\${TABLE_NAME}", "profiles") + + openMemoryDb().use { conn -> + exec(conn, createSql) + exec( + conn, + "INSERT INTO profiles (name, debugTiming, socksHost, socksPort, googleHost, " + + "sniJson, scriptKeysText, tunnelKey, isSelected, createdAt) " + + "VALUES ('x', 0, '127.0.0.1', 1080, '216.239.38.120', '[]', '', 'k', 0, 1)" + ) + + ProfileMigrations.migrationSql2To3(existingColumns(conn)).forEach { exec(conn, it) } + ProfileMigrations.migrationSql3To4(existingColumns(conn)).forEach { exec(conn, it) } + + val info = columnTypes(conn) + assertThat(info.keys).containsExactly( + "id", "name", "debugTiming", "socksHost", "socksPort", "socksUser", "socksPass", + "googleHost", "sniJson", "scriptKeysText", "tunnelKey", "coalesceStepMs", + "idleSlotsPerBucket", "remoteUrl", "isSelected", "createdAt" + ) + assertThat(info["socksUser"]).isEqualTo("TEXT" to 1) + assertThat(info["socksPass"]).isEqualTo("TEXT" to 1) + assertThat(info["coalesceStepMs"]).isEqualTo("INTEGER" to 1) + assertThat(info["idleSlotsPerBucket"]).isEqualTo("INTEGER" to 1) + assertThat(info["remoteUrl"]).isEqualTo("TEXT" to 0) + + conn.createStatement().use { st -> + st.executeQuery( + "SELECT name, debugTiming, socksHost, socksPort, googleHost, " + + "sniJson, scriptKeysText, tunnelKey, isSelected, createdAt FROM profiles" + ).use { rs -> + assertThat(rs.next()).isTrue() + assertThat(rs.getString(1)).isEqualTo("x") + assertThat(rs.getInt(2)).isEqualTo(0) + assertThat(rs.getString(3)).isEqualTo("127.0.0.1") + assertThat(rs.getInt(4)).isEqualTo(1080) + assertThat(rs.getString(5)).isEqualTo("216.239.38.120") + assertThat(rs.getString(6)).isEqualTo("[]") + assertThat(rs.getString(7)).isEqualTo("") + assertThat(rs.getString(8)).isEqualTo("k") + assertThat(rs.getInt(9)).isEqualTo(0) + assertThat(rs.getLong(10)).isEqualTo(1L) + assertThat(rs.next()).isFalse() + } + } + } + } + + @Test + fun `v3 fifteen column table migrates to v4 preserving data`() { + openMemoryDb().use { conn -> + exec( + conn, + "CREATE TABLE IF NOT EXISTS `profiles` (`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, `isSelected` INTEGER NOT NULL, " + + "`createdAt` INTEGER NOT NULL)" + ) + exec( + conn, + "INSERT INTO profiles (name, debugTiming, socksHost, socksPort, socksUser, socksPass, " + + "googleHost, sniJson, scriptKeysText, tunnelKey, coalesceStepMs, idleSlotsPerBucket, " + + "isSelected, createdAt) " + + "VALUES ('y', 1, '10.0.0.1', 1081, 'u', 'p', '216.239.38.120', '[]', 'sk', 'k2', 0, 2, 1, 2)" + ) + + val before = existingColumns(conn) + ProfileMigrations.migrationSql3To4(before).forEach { exec(conn, it) } + + val after = existingColumns(conn) + assertThat(after - before).containsExactly("remoteUrl") + assertThat(after).containsExactly( + "id", "name", "debugTiming", "socksHost", "socksPort", "socksUser", "socksPass", + "googleHost", "sniJson", "scriptKeysText", "tunnelKey", "coalesceStepMs", + "idleSlotsPerBucket", "remoteUrl", "isSelected", "createdAt" + ) + + conn.createStatement().use { st -> + st.executeQuery( + "SELECT name, socksUser, socksPass, tunnelKey, coalesceStepMs, " + + "idleSlotsPerBucket FROM profiles" + ).use { rs -> + assertThat(rs.next()).isTrue() + assertThat(rs.getString(1)).isEqualTo("y") + assertThat(rs.getString(2)).isEqualTo("u") + assertThat(rs.getString(3)).isEqualTo("p") + assertThat(rs.getString(4)).isEqualTo("k2") + assertThat(rs.getInt(5)).isEqualTo(0) + assertThat(rs.getInt(6)).isEqualTo(2) + assertThat(rs.next()).isFalse() + } + } + } + } + + @Test + fun `v3 thirteen column table migrates without duplicating socksUser and socksPass`() { + openMemoryDb().use { conn -> + exec( + conn, + "CREATE TABLE IF NOT EXISTS `profiles` (`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, `isSelected` INTEGER NOT NULL, " + + "`createdAt` INTEGER NOT NULL)" + ) + exec( + conn, + "INSERT INTO profiles (name, debugTiming, socksHost, socksPort, socksUser, socksPass, " + + "googleHost, sniJson, scriptKeysText, tunnelKey, isSelected, createdAt) " + + "VALUES ('z', 0, '127.0.0.1', 1080, 'u2', 'p2', '216.239.38.120', '[]', '', 'k3', 0, 3)" + ) + + val before = existingColumns(conn) + ProfileMigrations.migrationSql3To4(before).forEach { exec(conn, it) } + + val after = existingColumns(conn) + assertThat(after - before).containsExactly( + "coalesceStepMs", "idleSlotsPerBucket", "remoteUrl" + ) + assertThat(after.count { it == "socksUser" }).isEqualTo(1) + assertThat(after.count { it == "socksPass" }).isEqualTo(1) + + conn.createStatement().use { st -> + st.executeQuery("SELECT name, socksUser, socksPass, tunnelKey FROM profiles").use { rs -> + assertThat(rs.next()).isTrue() + assertThat(rs.getString(1)).isEqualTo("z") + assertThat(rs.getString(2)).isEqualTo("u2") + assertThat(rs.getString(3)).isEqualTo("p2") + assertThat(rs.getString(4)).isEqualTo("k3") + assertThat(rs.next()).isFalse() + } + } + } + } + + @Test + fun `migrationSql3To4 is a no-op on the full v4 column set`() { + val full16 = setOf( + "id", "name", "debugTiming", "socksHost", "socksPort", "socksUser", "socksPass", + "googleHost", "sniJson", "scriptKeysText", "tunnelKey", "coalesceStepMs", + "idleSlotsPerBucket", "remoteUrl", "isSelected", "createdAt" + ) + assertThat(ProfileMigrations.migrationSql3To4(full16)).isEmpty() + } +}