diff --git a/app/schemas/com.tobevpn.app.data.local.AppDatabase/22.json b/app/schemas/com.tobevpn.app.data.local.AppDatabase/22.json new file mode 100644 index 0000000..c74f5bc --- /dev/null +++ b/app/schemas/com.tobevpn.app.data.local.AppDatabase/22.json @@ -0,0 +1,678 @@ +{ + "formatVersion": 1, + "database": { + "version": 22, + "identityHash": "4c952d9d73b6b7dd07123365a3a1fae3", + "entities": [ + { + "tableName": "session", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `authState` TEXT NOT NULL, `telegramId` INTEGER, `pendingAuthToken` TEXT, `userPlan` TEXT NOT NULL, `planDisplayName` TEXT, `planExpiresAt` INTEGER, `accessToken` TEXT, `refreshToken` TEXT, `accessExpiresAt` INTEGER, `refreshExpiresAt` INTEGER, `isLinked` INTEGER NOT NULL, `shortUuid` TEXT, `panelUserUuid` TEXT, `email` TEXT, `isAdminProfile` INTEGER NOT NULL, `telegramName` TEXT, `telegramUsername` TEXT, `photoUrl` TEXT, `subscriptionUrl` TEXT, PRIMARY KEY(`deviceId`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authState", + "columnName": "authState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "telegramId", + "columnName": "telegramId", + "affinity": "INTEGER" + }, + { + "fieldPath": "pendingAuthToken", + "columnName": "pendingAuthToken", + "affinity": "TEXT" + }, + { + "fieldPath": "userPlan", + "columnName": "userPlan", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "planDisplayName", + "columnName": "planDisplayName", + "affinity": "TEXT" + }, + { + "fieldPath": "planExpiresAt", + "columnName": "planExpiresAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT" + }, + { + "fieldPath": "refreshToken", + "columnName": "refreshToken", + "affinity": "TEXT" + }, + { + "fieldPath": "accessExpiresAt", + "columnName": "accessExpiresAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "refreshExpiresAt", + "columnName": "refreshExpiresAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "isLinked", + "columnName": "isLinked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shortUuid", + "columnName": "shortUuid", + "affinity": "TEXT" + }, + { + "fieldPath": "panelUserUuid", + "columnName": "panelUserUuid", + "affinity": "TEXT" + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT" + }, + { + "fieldPath": "isAdminProfile", + "columnName": "isAdminProfile", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "telegramName", + "columnName": "telegramName", + "affinity": "TEXT" + }, + { + "fieldPath": "telegramUsername", + "columnName": "telegramUsername", + "affinity": "TEXT" + }, + { + "fieldPath": "photoUrl", + "columnName": "photoUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "subscriptionUrl", + "columnName": "subscriptionUrl", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "deviceId" + ] + } + }, + { + "tableName": "usage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `bytesUsed` INTEGER NOT NULL, `bytesLimit` INTEGER NOT NULL, `timeUsedSeconds` INTEGER NOT NULL, `timeLimitSeconds` INTEGER NOT NULL, `lastConnectedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bytesUsed", + "columnName": "bytesUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bytesLimit", + "columnName": "bytesLimit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeUsedSeconds", + "columnName": "timeUsedSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeLimitSeconds", + "columnName": "timeLimitSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastConnectedAt", + "columnName": "lastConnectedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `address` TEXT NOT NULL, `port` INTEGER NOT NULL, `uuid` TEXT NOT NULL, `flow` TEXT NOT NULL, `security` TEXT NOT NULL, `sni` TEXT NOT NULL, `fingerprint` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `shortId` TEXT NOT NULL, `network` TEXT NOT NULL, `path` TEXT NOT NULL, `host` TEXT NOT NULL DEFAULT '', `alpn` TEXT NOT NULL DEFAULT '', `headerType` TEXT NOT NULL DEFAULT '', `serviceName` TEXT NOT NULL DEFAULT '', `extra` TEXT NOT NULL DEFAULT '', `mode` TEXT NOT NULL, `spx` TEXT NOT NULL, `protocol` TEXT NOT NULL DEFAULT 'VLESS', `obfsPassword` TEXT NOT NULL DEFAULT '', `pinSha256` TEXT NOT NULL DEFAULT '', `portHopping` TEXT NOT NULL DEFAULT '', `echConfigList` TEXT NOT NULL DEFAULT '', `country` TEXT NOT NULL, `isOnline` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "port", + "columnName": "port", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "security", + "columnName": "security", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sni", + "columnName": "sni", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortId", + "columnName": "shortId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "network", + "columnName": "network", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "alpn", + "columnName": "alpn", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "headerType", + "columnName": "headerType", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "serviceName", + "columnName": "serviceName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "extra", + "columnName": "extra", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spx", + "columnName": "spx", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "protocol", + "columnName": "protocol", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'VLESS'" + }, + { + "fieldPath": "obfsPassword", + "columnName": "obfsPassword", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "pinSha256", + "columnName": "pinSha256", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "portHopping", + "columnName": "portHopping", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "echConfigList", + "columnName": "echConfigList", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "country", + "columnName": "country", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isOnline", + "columnName": "isOnline", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "traffic_log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bytesUsed` INTEGER NOT NULL, `timeUsedSeconds` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `serverSource` TEXT NOT NULL DEFAULT 'STANDARD', `timestamp` INTEGER NOT NULL, `isAuthenticated` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bytesUsed", + "columnName": "bytesUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeUsedSeconds", + "columnName": "timeUsedSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverSource", + "columnName": "serverSource", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'STANDARD'" + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isAuthenticated", + "columnName": "isAuthenticated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "app_filter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, PRIMARY KEY(`packageName`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "packageName" + ] + } + }, + { + "tableName": "pending_promocode_activations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`telegramId` INTEGER NOT NULL, `code` TEXT NOT NULL, `requestId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`telegramId`, `code`))", + "fields": [ + { + "fieldPath": "telegramId", + "columnName": "telegramId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requestId", + "columnName": "requestId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "telegramId", + "code" + ] + }, + "indices": [ + { + "name": "index_pending_promocode_activations_requestId", + "unique": true, + "columnNames": [ + "requestId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_pending_promocode_activations_requestId` ON `${TABLE_NAME}` (`requestId`)" + } + ] + }, + { + "tableName": "base_station_bypass_servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `address` TEXT NOT NULL, `port` INTEGER NOT NULL, `uuid` TEXT NOT NULL, `flow` TEXT NOT NULL, `security` TEXT NOT NULL, `sni` TEXT NOT NULL, `fingerprint` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `shortId` TEXT NOT NULL, `network` TEXT NOT NULL, `path` TEXT NOT NULL, `host` TEXT NOT NULL DEFAULT '', `alpn` TEXT NOT NULL DEFAULT '', `headerType` TEXT NOT NULL DEFAULT '', `serviceName` TEXT NOT NULL DEFAULT '', `extra` TEXT NOT NULL DEFAULT '', `mode` TEXT NOT NULL, `spx` TEXT NOT NULL, `protocol` TEXT NOT NULL DEFAULT 'VLESS', `obfsPassword` TEXT NOT NULL DEFAULT '', `pinSha256` TEXT NOT NULL DEFAULT '', `portHopping` TEXT NOT NULL DEFAULT '', `echConfigList` TEXT NOT NULL DEFAULT '', `country` TEXT NOT NULL, `isOnline` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "port", + "columnName": "port", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flow", + "columnName": "flow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "security", + "columnName": "security", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sni", + "columnName": "sni", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortId", + "columnName": "shortId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "network", + "columnName": "network", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "alpn", + "columnName": "alpn", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "headerType", + "columnName": "headerType", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "serviceName", + "columnName": "serviceName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "extra", + "columnName": "extra", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spx", + "columnName": "spx", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "protocol", + "columnName": "protocol", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'VLESS'" + }, + { + "fieldPath": "obfsPassword", + "columnName": "obfsPassword", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "pinSha256", + "columnName": "pinSha256", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "portHopping", + "columnName": "portHopping", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "echConfigList", + "columnName": "echConfigList", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "country", + "columnName": "country", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isOnline", + "columnName": "isOnline", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "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, '4c952d9d73b6b7dd07123365a3a1fae3')" + ] + } +} diff --git a/app/src/androidTest/java/com/tobevpn/app/data/local/AppDatabaseMigrationTest.kt b/app/src/androidTest/java/com/tobevpn/app/data/local/AppDatabaseMigrationTest.kt index 35a766f..14a539d 100644 --- a/app/src/androidTest/java/com/tobevpn/app/data/local/AppDatabaseMigrationTest.kt +++ b/app/src/androidTest/java/com/tobevpn/app/data/local/AppDatabaseMigrationTest.kt @@ -83,6 +83,44 @@ class AppDatabaseMigrationTest { } } + @Test + fun migration21To22AddsBypassProtocolDefaultsAndPreservesBothCaches() { + helper.createDatabase(TEST_DATABASE, 21).use { database -> + insertVersion19Server(database, STANDARD_TABLE, "standard-id") + insertVersion19Server(database, BYPASS_TABLE, "bs:legacy-id") + database.execSQL( + "UPDATE $STANDARD_TABLE SET protocol = 'HYSTERIA2', obfsPassword = 'saved-secret'", + ) + } + + helper.runMigrationsAndValidate( + TEST_DATABASE, + 22, + true, + DatabaseModule.MIGRATION_21_22, + ).use { database -> + assertMigratedServer(database, STANDARD_TABLE, "standard-id") + assertMigratedServer(database, BYPASS_TABLE, "bs:legacy-id") + database.query( + "SELECT protocol, obfsPassword, pinSha256, portHopping, echConfigList " + + "FROM $BYPASS_TABLE", + ).use { cursor -> + assertEquals(1, cursor.count) + cursor.moveToFirst() + assertEquals("VLESS", cursor.getString(cursor.getColumnIndexOrThrow("protocol"))) + listOf("obfsPassword", "pinSha256", "portHopping", "echConfigList").forEach { column -> + assertEquals("", cursor.getString(cursor.getColumnIndexOrThrow(column))) + } + } + database.query("SELECT protocol, obfsPassword FROM $STANDARD_TABLE").use { cursor -> + assertEquals(1, cursor.count) + cursor.moveToFirst() + assertEquals("HYSTERIA2", cursor.getString(0)) + assertEquals("saved-secret", cursor.getString(1)) + } + } + } + private fun insertVersion19Server( database: SupportSQLiteDatabase, table: String, diff --git a/app/src/main/java/com/tobevpn/app/data/local/AppDatabase.kt b/app/src/main/java/com/tobevpn/app/data/local/AppDatabase.kt index ca4b28d..f7e3094 100644 --- a/app/src/main/java/com/tobevpn/app/data/local/AppDatabase.kt +++ b/app/src/main/java/com/tobevpn/app/data/local/AppDatabase.kt @@ -27,7 +27,7 @@ import com.tobevpn.app.data.local.entity.UsageEntity PendingPromocodeActivationEntity::class, BaseStationBypassServerEntity::class, ], - version = 21, + version = 22, exportSchema = true, ) abstract class AppDatabase : RoomDatabase() { diff --git a/app/src/main/java/com/tobevpn/app/data/local/entity/BaseStationBypassServerEntity.kt b/app/src/main/java/com/tobevpn/app/data/local/entity/BaseStationBypassServerEntity.kt index 26de790..be86f0b 100644 --- a/app/src/main/java/com/tobevpn/app/data/local/entity/BaseStationBypassServerEntity.kt +++ b/app/src/main/java/com/tobevpn/app/data/local/entity/BaseStationBypassServerEntity.kt @@ -27,6 +27,11 @@ data class BaseStationBypassServerEntity( @ColumnInfo(defaultValue = "''") val extra: String = "", val mode: String = "", val spx: String = "", + @ColumnInfo(defaultValue = "'VLESS'") val protocol: String = "VLESS", + @ColumnInfo(defaultValue = "''") val obfsPassword: String = "", + @ColumnInfo(defaultValue = "''") val pinSha256: String = "", + @ColumnInfo(defaultValue = "''") val portHopping: String = "", + @ColumnInfo(defaultValue = "''") val echConfigList: String = "", val country: String = "", val isOnline: Boolean = true, val sortOrder: Int = 0, diff --git a/app/src/main/java/com/tobevpn/app/data/repository/BaseStationBypassRepository.kt b/app/src/main/java/com/tobevpn/app/data/repository/BaseStationBypassRepository.kt index 503000e..4b47ce8 100644 --- a/app/src/main/java/com/tobevpn/app/data/repository/BaseStationBypassRepository.kt +++ b/app/src/main/java/com/tobevpn/app/data/repository/BaseStationBypassRepository.kt @@ -7,8 +7,10 @@ import com.tobevpn.app.data.local.entity.BaseStationBypassServerEntity import com.tobevpn.app.data.remote.await import com.tobevpn.app.domain.model.FingerprintOverride import com.tobevpn.app.domain.model.Server +import com.tobevpn.app.domain.model.ServerProtocol import com.tobevpn.app.domain.model.ServerSource import com.tobevpn.app.util.SafeDiagnostics +import com.tobevpn.app.vpn.Hysteria2UrlParser import com.tobevpn.app.vpn.VlessUrlParser import java.io.ByteArrayOutputStream import java.io.IOException @@ -68,7 +70,9 @@ class BaseStationBypassRepository @Inject constructor( suspend fun refreshServers(): Result> = withContext(Dispatchers.IO) { try { val servers = fetchProfileServers() - serverDao.replaceAll(servers.mapIndexed { index, server -> server.toEntity(index) }) + serverDao.replaceAll( + servers.mapIndexed { index, server -> server.toBaseStationBypassEntity(index) }, + ) SafeDiagnostics.info( TAG, "Base-station bypass profile refreshed: count=${servers.size}", @@ -283,9 +287,15 @@ internal object BaseStationBypassProfileParser { val links = rawProfile .lineSequence() .map { it.trim().removePrefix("\uFEFF") } - .filter { it.startsWith("vless://") } + .filter { link -> + link.startsWith("vless://") || + link.startsWith("hysteria2://", ignoreCase = true) || + link.startsWith("hy2://", ignoreCase = true) + } .toList() - val decoded = links.mapNotNull(VlessUrlParser::parse) + val decoded = links.mapNotNull { link -> + VlessUrlParser.parse(link) ?: Hysteria2UrlParser.parse(link) + } val parsed = decoded.filterNot(Server::isSentinel) val servers = parsed .asSequence() @@ -364,6 +374,9 @@ internal object BaseStationBypassProfileParser { server.id == stableId(server, includeExtendedTransport = true) internal fun isSupportedProfile(server: Server): Boolean { + if (server.protocol == ServerProtocol.HYSTERIA2) { + return server.network == "hysteria" && server.security == "tls" + } val headerType = server.headerType.lowercase(Locale.ROOT) if (server.network !in SUPPORTED_NETWORKS || server.security !in SUPPORTED_SECURITIES @@ -431,6 +444,17 @@ internal object BaseStationBypassProfileParser { server.extra.takeIf { server.network == "xhttp" }.orEmpty(), ) } + // Keep existing VLESS selection ids unchanged. Hysteria2 profiles + // also need their protocol-specific settings in the configuration id. + if (server.protocol == ServerProtocol.HYSTERIA2) { + configFields += listOf( + server.protocol.name, + server.obfsPassword, + server.pinSha256, + server.portHopping, + server.echConfigList, + ) + } val configFingerprint = configFields.joinToString(separator = "\u0000") val digest = MessageDigest.getInstance("SHA-256") .digest(configFingerprint.toByteArray(Charsets.UTF_8)) @@ -563,7 +587,7 @@ internal object BaseStationBypassProfileParser { ) } -private fun Server.toEntity(sortOrder: Int) = BaseStationBypassServerEntity( +internal fun Server.toBaseStationBypassEntity(sortOrder: Int) = BaseStationBypassServerEntity( id = id, name = name, address = address, @@ -584,12 +608,17 @@ private fun Server.toEntity(sortOrder: Int) = BaseStationBypassServerEntity( extra = extra, mode = mode, spx = spx, + protocol = protocol.name, + obfsPassword = obfsPassword, + pinSha256 = pinSha256, + portHopping = portHopping, + echConfigList = echConfigList, country = country, isOnline = isOnline, sortOrder = sortOrder, ) -private fun BaseStationBypassServerEntity.toDomain() = Server( +internal fun BaseStationBypassServerEntity.toDomain() = Server( id = id, name = name, address = address, @@ -610,6 +639,11 @@ private fun BaseStationBypassServerEntity.toDomain() = Server( extra = extra, mode = mode, spx = spx, + protocol = ServerProtocol.entries.firstOrNull { it.name == protocol } ?: ServerProtocol.VLESS, + obfsPassword = obfsPassword, + pinSha256 = pinSha256, + portHopping = portHopping, + echConfigList = echConfigList, country = country, isOnline = isOnline, source = ServerSource.BASE_STATION_BYPASS, diff --git a/app/src/main/java/com/tobevpn/app/di/DatabaseModule.kt b/app/src/main/java/com/tobevpn/app/di/DatabaseModule.kt index 287f97a..8827a27 100644 --- a/app/src/main/java/com/tobevpn/app/di/DatabaseModule.kt +++ b/app/src/main/java/com/tobevpn/app/di/DatabaseModule.kt @@ -55,6 +55,7 @@ object DatabaseModule { MIGRATION_18_19, MIGRATION_19_20, MIGRATION_20_21, + MIGRATION_21_22, ) .fallbackToDestructiveMigration(dropAllTables = false) // TRUNCATE journal (no WAL) — committed writes land in the @@ -221,8 +222,8 @@ object DatabaseModule { } } - // v20 -> v21: Hysteria2 profiles in the standard server cache. The - // base-station bypass profile stays VLESS-only, so its table is untouched. + // v20 -> v21: Hysteria2 profiles in the standard server cache. Bypass + // support was added later in v22, so this historical migration stays intact. internal val MIGRATION_20_21 = object : Migration(20, 21) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE servers ADD COLUMN protocol TEXT NOT NULL DEFAULT 'VLESS'") @@ -233,6 +234,17 @@ object DatabaseModule { } } + // v21 -> v22: retain Hysteria2 profiles in the bypass cache as well. + internal val MIGRATION_21_22 = object : Migration(21, 22) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE base_station_bypass_servers ADD COLUMN protocol TEXT NOT NULL DEFAULT 'VLESS'") + db.execSQL("ALTER TABLE base_station_bypass_servers ADD COLUMN obfsPassword TEXT NOT NULL DEFAULT ''") + db.execSQL("ALTER TABLE base_station_bypass_servers ADD COLUMN pinSha256 TEXT NOT NULL DEFAULT ''") + db.execSQL("ALTER TABLE base_station_bypass_servers ADD COLUMN portHopping TEXT NOT NULL DEFAULT ''") + db.execSQL("ALTER TABLE base_station_bypass_servers ADD COLUMN echConfigList TEXT NOT NULL DEFAULT ''") + } + } + /** * Probe the DB file to make sure it's openable with the current * SQLCipher passphrase. Originally this wiped the file on any diff --git a/app/src/test/java/com/tobevpn/app/data/repository/BaseStationBypassProfileParserTest.kt b/app/src/test/java/com/tobevpn/app/data/repository/BaseStationBypassProfileParserTest.kt index b5f42d3..66e6398 100644 --- a/app/src/test/java/com/tobevpn/app/data/repository/BaseStationBypassProfileParserTest.kt +++ b/app/src/test/java/com/tobevpn/app/data/repository/BaseStationBypassProfileParserTest.kt @@ -1,6 +1,7 @@ package com.tobevpn.app.data.repository import com.tobevpn.app.domain.model.Server +import com.tobevpn.app.domain.model.ServerProtocol import com.tobevpn.app.domain.model.ServerSource import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -10,6 +11,92 @@ import org.junit.Test class BaseStationBypassProfileParserTest { + @Test + fun `keeps hysteria2 CIDR entry with encoded flag and display name`() { + // Same shape as the missing subscription entry; endpoint and auth are fixtures. + val result = BaseStationBypassProfileParser.parseWithFilterSummary( + "hysteria2://550e8400-e29b-41d4-a716-446655440000@203.0.113.5:443" + + "?sni=hy2.example.com&fp=firefox" + + "#%F0%9F%87%B5%F0%9F%87%B1%20Poland%20%7C%20%F0%9F%8C%90%20%5B%2ACIDR%5D", + ) + + val server = result.servers.single() + assertEquals(ServerProtocol.HYSTERIA2, server.protocol) + assertEquals(ServerSource.BASE_STATION_BYPASS, server.source) + assertEquals("203.0.113.5", server.address) + assertEquals("hy2.example.com", server.sni) + assertEquals("Poland | [*CIDR]", server.name) + assertEquals("PL", server.country) + assertTrue(server.id.startsWith("bs:")) + assertTrue(BaseStationBypassProfileParser.isSupportedProfile(server)) + assertEquals( + "links=1 unparsable=0 sentinel=0 rejected=0 duplicates=0 kept=1", + result.filterSummary, + ) + } + + @Test + fun `mixed subscription keeps vless and hysteria2 and deduplicates hy2 alias`() { + val result = BaseStationBypassProfileParser.parseWithFilterSummary( + listOf( + "vless://pass@node.example:443?security=tls#VLESS", + "hysteria2://pass@node.example:443#Hysteria2", + "hy2://pass@node.example:443#Alias", + "HYSTERIA2://other@other.example:443#Other", + "hysteria2://broken", + "hy2://@node.example:443", + ).joinToString("\n"), + ) + + assertEquals(listOf("VLESS", "Hysteria2", "Other"), result.servers.map(Server::name)) + assertEquals( + listOf(ServerProtocol.VLESS, ServerProtocol.HYSTERIA2, ServerProtocol.HYSTERIA2), + result.servers.map(Server::protocol), + ) + assertEquals(3, result.servers.map(Server::id).distinct().size) + assertEquals( + "links=6 unparsable=2 sentinel=0 rejected=0 duplicates=1 kept=3", + result.filterSummary, + ) + } + + @Test + fun `hysteria2 config variants get separate ids`() { + val link = "hysteria2://pass@node.example:443?sni=hy2.example" + val servers = BaseStationBypassProfileParser.parse( + listOf( + link, + "$link&obfs=salamander&obfs-password=secret", + "$link&pinSHA256=${"ab".repeat(32)}", + "$link&mport=20000-30000", + "$link&ech=fixture-ech-config", + "$link&alpn=h3,h2", + ).joinToString("\n"), + ) + + assertEquals(6, servers.size) + assertEquals(6, servers.map(Server::id).distinct().size) + assertTrue(servers.all(BaseStationBypassProfileParser::hasCurrentStableId)) + } + + @Test + fun `hysteria2 profile survives bypass cache round trip with complete vpn config`() { + val server = BaseStationBypassProfileParser.parse( + "hy2://user:pass@node.example:443,20000-30000?sni=hy2.example&alpn=h3,h2" + + "&obfs=salamander&obfs-password=secret&pinSHA256=${"ab".repeat(32)}" + + "&ech=fixture-ech-config#Poland", + ).single() + + val entity = server.toBaseStationBypassEntity(sortOrder = 7) + val restored = entity.toDomain() + + assertEquals(7, entity.sortOrder) + assertEquals(ServerProtocol.HYSTERIA2, restored.protocol) + assertEquals(server, restored) + assertTrue(BaseStationBypassProfileParser.isSupportedProfile(restored)) + assertTrue(BaseStationBypassProfileParser.hasCurrentStableId(restored)) + } + @Test fun `parses direct vless lines and preserves unencoded display name`() { val servers = BaseStationBypassProfileParser.parse(