diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..f678e6a --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,33 @@ +name: Android client acceptance + +on: + push: + branches: [main, "codex/**"] + pull_request: + +jobs: + test-and-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + - uses: android-actions/setup-android@v3 + - name: Install Android SDK + run: sdkmanager "platforms;android-37" "build-tools;37.0.0" "platform-tools" + - name: Restore verified native dependencies + run: python3 Restore-Dependencies.py + - name: Unit tests and unsigned candidate + working-directory: V2rayNG + run: | + chmod +x gradlew + ./gradlew :app:testPlaystoreReleaseUnitTest :app:assemblePlaystoreRelease --no-daemon + - uses: actions/upload-artifact@v4 + with: + name: android-unsigned-candidate + path: V2rayNG/app/build/outputs/apk/playstore/release/*.apk + diff --git a/README-UCVL.md b/README-UCVL.md index 0b99868..67e5354 100644 --- a/README-UCVL.md +++ b/README-UCVL.md @@ -1,6 +1,6 @@ # 纵横家 Android 客户端 -版本 `1.0.1` 候选;包名 `cn.toplc.zonghengjia`。这是公司 Android 客户端的 GPL-3.0 衍生源码,不包含服务器端、账号、节点凭据或公司签名私钥。 +版本 `1.0.2` 候选;包名 `cn.toplc.zonghengjia`。这是公司 Android 客户端的 GPL-3.0 衍生源码,不包含服务器端、账号、节点凭据或公司签名私钥。 ## 已实现 @@ -11,6 +11,7 @@ - Android Keystore 加密会话、同一证书覆盖升级保留配置。 - VPN 后台每 60 秒检查授权,401/403 时断开并清除会话;临时网络错误不误当账号撤销。 - 退出等待服务停止,TLS 清理在后台线程执行;系统 VPN 关闭后由 Android 恢复原网络。 +- 兼容后台 `config_contract=2`,连接时把节点地址切换到统一 NPM 公网网关,同时保留每个节点的端口和 Reality SNI;旧后台仍按原配置工作。 ## 验收边界 diff --git a/Sign-Candidate.ps1 b/Sign-Candidate.ps1 index 8a400bf..b832832 100644 --- a/Sign-Candidate.ps1 +++ b/Sign-Candidate.ps1 @@ -1,4 +1,7 @@ -param([switch]$InstrumentationOnly) +param( + [switch]$InstrumentationOnly, + [string]$Version = '1.0.2' +) $ErrorActionPreference = 'Stop' $androidRoot = $PSScriptRoot $env:JAVA_HOME = Join-Path $androidRoot '.tools/jdk-staging/jdk-21.0.12.1+1' @@ -16,7 +19,7 @@ try { if ($InstrumentationOnly) { $inputs = @(Get-ChildItem -LiteralPath (Join-Path $androidRoot 'V2rayNG/app/build/outputs/apk/androidTest/playstore/release') -Filter '*.apk') } else { - $inputs = @(Get-ChildItem -LiteralPath (Join-Path $androidRoot 'V2rayNG/app/build/outputs/apk/playstore/release') -Filter 'UCVL-Zonghengjia-1.0.1-*.apk') + $inputs = @(Get-ChildItem -LiteralPath (Join-Path $androidRoot 'V2rayNG/app/build/outputs/apk/playstore/release') -Filter "UCVL-Zonghengjia-$Version-*.apk") } if (-not $inputs.Count) { throw 'No candidate APKs were built.' } foreach ($inputFile in $inputs) { diff --git a/V2rayNG/app/build.gradle.kts b/V2rayNG/app/build.gradle.kts index 0dc03a1..3fbb9ae 100644 --- a/V2rayNG/app/build.gradle.kts +++ b/V2rayNG/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "cn.toplc.zonghengjia" minSdk = 24 targetSdk = 37 - versionCode = 10001 - versionName = "1.0.1" + versionCode = 10002 + versionName = "1.0.2" multiDexEnabled = true val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';') diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ucvl/UcvlConfigBridge.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ucvl/UcvlConfigBridge.kt index aee5d19..76915d0 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ucvl/UcvlConfigBridge.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ucvl/UcvlConfigBridge.kt @@ -8,10 +8,12 @@ import java.net.URLEncoder object UcvlConfigBridge { /** Keep the server's complete rules, credentials and node selection. Adapt only local runtime. */ fun buildRuntime(config: JsonObject, remarks: String, socksPort: Int, httpPort: Int, - socksUsername: String? = null, socksPassword: String? = null): JsonObject { + socksUsername: String? = null, socksPassword: String? = null, + gatewayAddress: String? = null): JsonObject { require(socksPort in 1024..65535 && httpPort in 1024..65535 && socksPort != httpPort) - buildVlessUri(config, remarks) // Validate the supported server contract before saving. val result = config.deepCopy() + overrideProxyServerAddress(result, gatewayAddress) + buildVlessUri(result, remarks) // Validate the supported server contract before saving. result.addProperty("remarks", remarks) result.add("log", JsonParser.parseString("""{"loglevel":"warning"}""")) val inbounds = JsonArray() @@ -57,6 +59,35 @@ object UcvlConfigBridge { return result } + fun overrideProxyServerAddress(config: JsonObject, gatewayAddress: String?): JsonObject { + val address = gatewayAddress?.trim().orEmpty() + if (address.isEmpty()) return config + val outbounds = config.getAsJsonArray("outbounds") ?: return config + outbounds.map { it.asJsonObject } + .filter { it.get("tag")?.asString == "proxy" } + .forEach { outbound -> + outbound.getAsJsonObject("settings")?.getAsJsonArray("vnext") + ?.forEach { it.asJsonObject.addProperty("address", address) } + } + return config + } + + fun isPublicIpv4(value: String): Boolean { + val parts = value.split('.').mapNotNull { it.toIntOrNull() } + if (parts.size != 4 || parts.any { it !in 0..255 }) return false + val (a, b, c) = parts + if (a == 0 || a == 10 || a == 127 || a >= 224) return false + if (a == 100 && b in 64..127) return false + if (a == 169 && b == 254) return false + if (a == 172 && b in 16..31) return false + if (a == 192 && b == 168) return false + if (a == 192 && b == 0 && (c == 0 || c == 2)) return false + if (a == 198 && (b == 18 || b == 19)) return false + if (a == 198 && b == 51 && c == 100) return false + if (a == 203 && b == 0 && c == 113) return false + return true + } + fun buildVlessUri(config: JsonObject, remarks: String): String { val outbounds = config.getAsJsonArray("outbounds") ?: throw IllegalArgumentException("线路配置没有出站信息。") diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ucvl/UcvlMainActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ucvl/UcvlMainActivity.kt index 1aa9eda..fdd991c 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ucvl/UcvlMainActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ucvl/UcvlMainActivity.kt @@ -313,7 +313,8 @@ class UcvlMainActivity : AppCompatActivity() { MmkvManager.encodeSettings(AppConfig.PREF_PER_APP_PROXY, false) MmkvManager.encodeSettings(AppConfig.PREF_APPEND_HTTP_PROXY, false) val raw = UcvlConfigBridge.buildRuntime(response.config, response.node.name, port, port + 1, - SettingsManager.getSocksUsername(), SettingsManager.getSocksPassword()).toString() + SettingsManager.getSocksUsername(), SettingsManager.getSocksPassword(), + response.gateway?.preferredAddress()).toString() val ownedGuid = MmkvManager.decodeSettingsString(OWNED_PROFILE_KEY).orEmpty() val selected = MmkvManager.encodeServerConfig(ownedGuid, CustomFmt.parse(raw)) MmkvManager.encodeServerRaw(selected, raw) diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ucvl/UcvlModels.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ucvl/UcvlModels.kt index 9be745d..966aef2 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ucvl/UcvlModels.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ucvl/UcvlModels.kt @@ -49,11 +49,21 @@ data class UcvlRouteMode(val id: String, val name: String) { } data class UcvlConfigResponse( + @SerializedName("config_contract") val configContract: Int? = null, + val gateway: UcvlGateway? = null, val node: UcvlNode, @SerializedName("route_mode") val routeMode: String, val config: JsonObject, ) +data class UcvlGateway( + val host: String = "", + val ipv4: String? = null, +) { + fun preferredAddress(): String? = ipv4?.takeIf(UcvlConfigBridge::isPublicIpv4) + ?: host.trim().takeIf { it.isNotEmpty() } +} + data class UcvlApiError(val error: String? = null) class UcvlApiException(val statusCode: Int, message: String) : Exception(message) diff --git a/V2rayNG/app/src/test/java/com/v2ray/ang/ucvl/UcvlConfigBridgeTest.kt b/V2rayNG/app/src/test/java/com/v2ray/ang/ucvl/UcvlConfigBridgeTest.kt index e71e6a4..ba164c6 100644 --- a/V2rayNG/app/src/test/java/com/v2ray/ang/ucvl/UcvlConfigBridgeTest.kt +++ b/V2rayNG/app/src/test/java/com/v2ray/ang/ucvl/UcvlConfigBridgeTest.kt @@ -41,6 +41,20 @@ class UcvlConfigBridgeTest { assertFalse(rules.any { it.asJsonObject.get("network")?.asString == "tcp,udp" }) } + @Test fun usesUnifiedNpmGatewayWithoutChangingPortOrRealitySni() { + val runtime = UcvlConfigBridge.buildRuntime( + fixture(), "VPN002", 10808, 10809, gatewayAddress = "59.110.91.17") + val proxy = runtime.getAsJsonArray("outbounds")[0].asJsonObject + val server = proxy.getAsJsonObject("settings").getAsJsonArray("vnext")[0].asJsonObject + assertEquals("59.110.91.17", server.get("address").asString) + assertEquals(11082, server.get("port").asInt) + assertEquals("example.com", proxy.getAsJsonObject("streamSettings") + .getAsJsonObject("realitySettings").get("serverName").asString) + assertTrue(UcvlConfigBridge.isPublicIpv4("59.110.91.17")) + assertFalse(UcvlConfigBridge.isPublicIpv4("100.73.44.33")) + assertFalse(UcvlConfigBridge.isPublicIpv4("198.18.0.1")) + } + @Test(expected = IllegalArgumentException::class) fun rejectsConflictingLocalPorts() { UcvlConfigBridge.buildRuntime(fixture(), "VPN002", 10808, 10808)