Release Android 1.0.2 with unified gateway routing
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
# 纵横家 Android 客户端
|
# 纵横家 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 加密会话、同一证书覆盖升级保留配置。
|
- Android Keystore 加密会话、同一证书覆盖升级保留配置。
|
||||||
- VPN 后台每 60 秒检查授权,401/403 时断开并清除会话;临时网络错误不误当账号撤销。
|
- VPN 后台每 60 秒检查授权,401/403 时断开并清除会话;临时网络错误不误当账号撤销。
|
||||||
- 退出等待服务停止,TLS 清理在后台线程执行;系统 VPN 关闭后由 Android 恢复原网络。
|
- 退出等待服务停止,TLS 清理在后台线程执行;系统 VPN 关闭后由 Android 恢复原网络。
|
||||||
|
- 兼容后台 `config_contract=2`,连接时把节点地址切换到统一 NPM 公网网关,同时保留每个节点的端口和 Reality SNI;旧后台仍按原配置工作。
|
||||||
|
|
||||||
## 验收边界
|
## 验收边界
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -1,4 +1,7 @@
|
|||||||
param([switch]$InstrumentationOnly)
|
param(
|
||||||
|
[switch]$InstrumentationOnly,
|
||||||
|
[string]$Version = '1.0.2'
|
||||||
|
)
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$androidRoot = $PSScriptRoot
|
$androidRoot = $PSScriptRoot
|
||||||
$env:JAVA_HOME = Join-Path $androidRoot '.tools/jdk-staging/jdk-21.0.12.1+1'
|
$env:JAVA_HOME = Join-Path $androidRoot '.tools/jdk-staging/jdk-21.0.12.1+1'
|
||||||
@@ -16,7 +19,7 @@ try {
|
|||||||
if ($InstrumentationOnly) {
|
if ($InstrumentationOnly) {
|
||||||
$inputs = @(Get-ChildItem -LiteralPath (Join-Path $androidRoot 'V2rayNG/app/build/outputs/apk/androidTest/playstore/release') -Filter '*.apk')
|
$inputs = @(Get-ChildItem -LiteralPath (Join-Path $androidRoot 'V2rayNG/app/build/outputs/apk/androidTest/playstore/release') -Filter '*.apk')
|
||||||
} else {
|
} 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.' }
|
if (-not $inputs.Count) { throw 'No candidate APKs were built.' }
|
||||||
foreach ($inputFile in $inputs) {
|
foreach ($inputFile in $inputs) {
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ android {
|
|||||||
applicationId = "cn.toplc.zonghengjia"
|
applicationId = "cn.toplc.zonghengjia"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 10001
|
versionCode = 10002
|
||||||
versionName = "1.0.1"
|
versionName = "1.0.2"
|
||||||
multiDexEnabled = true
|
multiDexEnabled = true
|
||||||
|
|
||||||
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import java.net.URLEncoder
|
|||||||
object UcvlConfigBridge {
|
object UcvlConfigBridge {
|
||||||
/** Keep the server's complete rules, credentials and node selection. Adapt only local runtime. */
|
/** Keep the server's complete rules, credentials and node selection. Adapt only local runtime. */
|
||||||
fun buildRuntime(config: JsonObject, remarks: String, socksPort: Int, httpPort: Int,
|
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)
|
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()
|
val result = config.deepCopy()
|
||||||
|
overrideProxyServerAddress(result, gatewayAddress)
|
||||||
|
buildVlessUri(result, remarks) // Validate the supported server contract before saving.
|
||||||
result.addProperty("remarks", remarks)
|
result.addProperty("remarks", remarks)
|
||||||
result.add("log", JsonParser.parseString("""{"loglevel":"warning"}"""))
|
result.add("log", JsonParser.parseString("""{"loglevel":"warning"}"""))
|
||||||
val inbounds = JsonArray()
|
val inbounds = JsonArray()
|
||||||
@@ -57,6 +59,35 @@ object UcvlConfigBridge {
|
|||||||
return result
|
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 {
|
fun buildVlessUri(config: JsonObject, remarks: String): String {
|
||||||
val outbounds = config.getAsJsonArray("outbounds")
|
val outbounds = config.getAsJsonArray("outbounds")
|
||||||
?: throw IllegalArgumentException("线路配置没有出站信息。")
|
?: throw IllegalArgumentException("线路配置没有出站信息。")
|
||||||
|
|||||||
@@ -313,7 +313,8 @@ class UcvlMainActivity : AppCompatActivity() {
|
|||||||
MmkvManager.encodeSettings(AppConfig.PREF_PER_APP_PROXY, false)
|
MmkvManager.encodeSettings(AppConfig.PREF_PER_APP_PROXY, false)
|
||||||
MmkvManager.encodeSettings(AppConfig.PREF_APPEND_HTTP_PROXY, false)
|
MmkvManager.encodeSettings(AppConfig.PREF_APPEND_HTTP_PROXY, false)
|
||||||
val raw = UcvlConfigBridge.buildRuntime(response.config, response.node.name, port, port + 1,
|
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 ownedGuid = MmkvManager.decodeSettingsString(OWNED_PROFILE_KEY).orEmpty()
|
||||||
val selected = MmkvManager.encodeServerConfig(ownedGuid, CustomFmt.parse(raw))
|
val selected = MmkvManager.encodeServerConfig(ownedGuid, CustomFmt.parse(raw))
|
||||||
MmkvManager.encodeServerRaw(selected, raw)
|
MmkvManager.encodeServerRaw(selected, raw)
|
||||||
|
|||||||
@@ -49,11 +49,21 @@ data class UcvlRouteMode(val id: String, val name: String) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data class UcvlConfigResponse(
|
data class UcvlConfigResponse(
|
||||||
|
@SerializedName("config_contract") val configContract: Int? = null,
|
||||||
|
val gateway: UcvlGateway? = null,
|
||||||
val node: UcvlNode,
|
val node: UcvlNode,
|
||||||
@SerializedName("route_mode") val routeMode: String,
|
@SerializedName("route_mode") val routeMode: String,
|
||||||
val config: JsonObject,
|
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)
|
data class UcvlApiError(val error: String? = null)
|
||||||
|
|
||||||
class UcvlApiException(val statusCode: Int, message: String) : Exception(message)
|
class UcvlApiException(val statusCode: Int, message: String) : Exception(message)
|
||||||
|
|||||||
@@ -41,6 +41,20 @@ class UcvlConfigBridgeTest {
|
|||||||
assertFalse(rules.any { it.asJsonObject.get("network")?.asString == "tcp,udp" })
|
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)
|
@Test(expected = IllegalArgumentException::class)
|
||||||
fun rejectsConflictingLocalPorts() {
|
fun rejectsConflictingLocalPorts() {
|
||||||
UcvlConfigBridge.buildRuntime(fixture(), "VPN002", 10808, 10808)
|
UcvlConfigBridge.buildRuntime(fixture(), "VPN002", 10808, 10808)
|
||||||
|
|||||||
Reference in New Issue
Block a user