Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0737d5665 |
Binary file not shown.
File diff suppressed because one or more lines are too long
+3
-1
@@ -1,6 +1,8 @@
|
|||||||
# 纵横家 Android 客户端
|
# 纵横家 Android 客户端
|
||||||
|
|
||||||
版本 `1.0.2` 候选;包名 `cn.toplc.zonghengjia`。这是公司 Android 客户端的 GPL-3.0 衍生源码,不包含服务器端、账号、节点凭据或公司签名私钥。
|
版本 `1.0.4` 本地候选,尚未作为正式版发布;包名 `cn.toplc.zonghengjia`。这是公司 Android 客户端的 GPL-3.0 衍生源码,不包含服务器端、账号、节点凭据或公司签名私钥。
|
||||||
|
|
||||||
|
1.0.4 纳入2026-09-17的后台授权隔离修复:区分“旧会话停止”和“当前账号撤销”,防止重新登录后的延迟响应误报新账号失效。旧1.0.3候选APK不含此修复。修复历史见`acceptance/AUTH-SESSION-ISOLATION-20260917.md`;1.0.4的构建、签名和本地验证见`acceptance/ACCEPTANCE-1.0.4.md`。本地检查不替代真实服务/设备VPN验收,候选未发布。
|
||||||
|
|
||||||
## 2026-09-08:1.0.3 本地候选
|
## 2026-09-08:1.0.3 本地候选
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
# 纵横家 Android 客户端
|
# 纵横家 Android 客户端
|
||||||
|
|
||||||
版本 `1.0.3` 本地候选;包名 `cn.toplc.zonghengjia`。这是公司 Android 客户端的 GPL-3.0 衍生源码,不包含服务器端、账号、节点凭据或公司签名私钥。
|
版本 `1.0.4` 本地候选,尚未作为正式版发布;包名 `cn.toplc.zonghengjia`。这是公司 Android 客户端的 GPL-3.0 衍生源码,不包含服务器端、账号、节点凭据或公司签名私钥。
|
||||||
|
|
||||||
2026-09-08:新增公司 Git 正式版检查、提示和用户确认后的浏览器下载,不自动停止 VPN。34 项 JVM 测试及专用模拟器 1.0.2 → 1.0.3 同证书覆盖升级通过;合成会话、登录名和设备标识保留,登录页已截图复核。详见 `acceptance/ACCEPTANCE-1.0.3.md`。未发布新 APK、未在实体手机验收、未完成真实 VPN 长期测试。下文较早的节点/独立 UID 记录属于之前版本,不替代 1.0.3 验收。
|
1.0.4 纳入2026-09-17的后台授权隔离修复:区分“旧会话停止”和“当前账号撤销”,防止重新登录后的延迟响应误报新账号失效。旧1.0.3候选APK不含此修复。修复历史见`acceptance/AUTH-SESSION-ISOLATION-20260917.md`;1.0.4的构建、签名和本地验证见`acceptance/ACCEPTANCE-1.0.4.md`。本地检查不替代真实服务/设备VPN验收,候选未发布。
|
||||||
|
|
||||||
|
## 2026-09-08:1.0.3 本地候选
|
||||||
|
|
||||||
|
新增启动与手动 Git 更新检查,只提示 `pro.ucvl.cn` 正式 Android Release 的通用 APK;忽略草稿/试用/其它平台,下载路径固定在公司公开安装包仓库。提示不改变 VPN,不共享平台登录令牌。用户同意后打开下载;系统要求的 APK 安装授权仍需确认,尚不是应用内静默下载安装。
|
||||||
|
|
||||||
|
1.0.3 的 34 项单元测试和 APK 构建通过,五种 APK 已沿用公司原证书签名,尚未上传或向员工推送。构建请使用 ASCII 路径:中文目录下本轮 Gradle 测试工作进程未能加载测试类;独立 ASCII 源码副本重编译后全套通过,没有关闭或跳过失败测试。
|
||||||
|
|
||||||
## 已实现
|
## 已实现
|
||||||
|
|
||||||
@@ -13,6 +19,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;旧后台仍按原配置工作。
|
||||||
|
|
||||||
## 验收边界
|
## 验收边界
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ android {
|
|||||||
applicationId = "cn.toplc.zonghengjia"
|
applicationId = "cn.toplc.zonghengjia"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 10003
|
versionCode = 10004
|
||||||
versionName = "1.0.3"
|
versionName = "1.0.4"
|
||||||
multiDexEnabled = true
|
multiDexEnabled = true
|
||||||
|
|
||||||
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
||||||
|
|||||||
@@ -141,10 +141,12 @@ class CoreVpnService : VpnService(), ServiceControl {
|
|||||||
UcvlApiClient().use { api ->
|
UcvlApiClient().use { api ->
|
||||||
UcvlAuthorizationGuard.watch(token, UcvlSecureStore::loadToken,
|
UcvlAuthorizationGuard.watch(token, UcvlSecureStore::loadToken,
|
||||||
{ api.profile(it) }, {
|
{ api.profile(it) }, {
|
||||||
if (UcvlSecureStore.loadToken() == token) UcvlSecureStore.clearToken()
|
if (UcvlSecureStore.loadToken() == token) {
|
||||||
sendBroadcast(Intent(UcvlAuthorizationGuard.ACTION_REVOKED).setPackage(packageName))
|
UcvlSecureStore.clearToken()
|
||||||
|
sendBroadcast(Intent(UcvlAuthorizationGuard.ACTION_REVOKED).setPackage(packageName))
|
||||||
|
}
|
||||||
stopAllService()
|
stopAllService()
|
||||||
})
|
}, sessionChanged = { stopAllService() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import kotlinx.coroutines.CancellationException
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
|
||||||
/** Runs in the VPN service, so closing the activity cannot bypass revocation. */
|
/** Runs in the VPN service, so closing the activity cannot bypass revocation. */
|
||||||
object UcvlAuthorizationGuard {
|
object UcvlAuthorizationGuard {
|
||||||
@@ -14,23 +15,34 @@ object UcvlAuthorizationGuard {
|
|||||||
loadToken: () -> String?,
|
loadToken: () -> String?,
|
||||||
check: suspend (String) -> Unit,
|
check: suspend (String) -> Unit,
|
||||||
revoke: () -> Unit,
|
revoke: () -> Unit,
|
||||||
|
sessionChanged: () -> Unit,
|
||||||
intervalMillis: Long = 60_000,
|
intervalMillis: Long = 60_000,
|
||||||
) {
|
) {
|
||||||
|
fun stopIfSessionChanged(): Boolean {
|
||||||
|
if (loadToken() == token) return false
|
||||||
|
// Stop the old tunnel, but do not revoke or hide a replacement login.
|
||||||
|
sessionChanged()
|
||||||
|
return true
|
||||||
|
}
|
||||||
while (currentCoroutineContext().isActive) {
|
while (currentCoroutineContext().isActive) {
|
||||||
if (loadToken() != token) { revoke(); return }
|
if (stopIfSessionChanged()) return
|
||||||
try {
|
try {
|
||||||
check(token)
|
check(token)
|
||||||
} catch (cancelled: CancellationException) {
|
} catch (cancelled: CancellationException) {
|
||||||
throw cancelled
|
throw cancelled
|
||||||
} catch (error: UcvlApiException) {
|
} catch (error: UcvlApiException) {
|
||||||
|
currentCoroutineContext().ensureActive()
|
||||||
if (error.statusCode == 401 || error.statusCode == 403) {
|
if (error.statusCode == 401 || error.statusCode == 403) {
|
||||||
// A late reply for an old login must never erase a newer login.
|
// A late reply for an old login must never erase a newer login.
|
||||||
if (loadToken() == token) revoke()
|
if (!stopIfSessionChanged()) revoke()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Transient loss of connectivity is not evidence that an account was revoked.
|
// Transient loss of connectivity is not evidence that an account was revoked.
|
||||||
}
|
}
|
||||||
|
currentCoroutineContext().ensureActive()
|
||||||
|
// Recheck immediately even after success/temporary failure, not 60 seconds later.
|
||||||
|
if (stopIfSessionChanged()) return
|
||||||
delay(intervalMillis)
|
delay(intervalMillis)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
package com.v2ray.ang.ucvl
|
package com.v2ray.ang.ucvl
|
||||||
|
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
import org.junit.Assert.*
|
import org.junit.Assert.*
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class UcvlAuthorizationGuardTest {
|
class UcvlAuthorizationGuardTest {
|
||||||
@Test fun revokedLoginStops() = runBlocking {
|
@Test fun revokedLoginStops() = runBlocking {
|
||||||
var stopped = false
|
var stopped = false
|
||||||
UcvlAuthorizationGuard.watch("old", { "old" }, { throw UcvlApiException(401, "revoked") }, { stopped = true }, 1)
|
UcvlAuthorizationGuard.watch("old", { "old" }, { throw UcvlApiException(401, "revoked") }, { stopped = true }, { fail("Unchanged session") }, 1)
|
||||||
assertTrue(stopped)
|
assertTrue(stopped)
|
||||||
}
|
}
|
||||||
@Test fun transientFailureDoesNotRevoke() = runBlocking {
|
@Test fun transientFailureDoesNotRevoke() = runBlocking {
|
||||||
@@ -18,18 +20,93 @@ class UcvlAuthorizationGuardTest {
|
|||||||
if (calls == 1) throw java.io.IOException("offline")
|
if (calls == 1) throw java.io.IOException("offline")
|
||||||
assertFalse(stopped)
|
assertFalse(stopped)
|
||||||
throw UcvlApiException(403, "revoked")
|
throw UcvlApiException(403, "revoked")
|
||||||
}, { stopped = true }, 1)
|
}, { stopped = true }, { fail("Unchanged session") }, 1)
|
||||||
assertEquals(2, calls)
|
assertEquals(2, calls)
|
||||||
assertTrue(stopped)
|
assertTrue(stopped)
|
||||||
}
|
}
|
||||||
@Test fun staleResponseCannotClearNewLogin() = runBlocking {
|
@Test fun staleResponseCannotClearNewLogin() = runBlocking {
|
||||||
var token = "old"
|
var token = "old"
|
||||||
var stopped = false
|
var stopped = false
|
||||||
|
var oldTunnelStopped = false
|
||||||
UcvlAuthorizationGuard.watch("old", { token }, {
|
UcvlAuthorizationGuard.watch("old", { token }, {
|
||||||
token = "new"
|
token = "new"
|
||||||
throw UcvlApiException(401, "old response")
|
throw UcvlApiException(401, "old response")
|
||||||
}, { stopped = true }, 1)
|
}, { stopped = true }, { oldTunnelStopped = true }, 1)
|
||||||
assertFalse(stopped)
|
assertFalse(stopped)
|
||||||
|
assertTrue(oldTunnelStopped)
|
||||||
|
assertEquals("new", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun lateSuccessMustNotRevokeNewLogin() = runBlocking {
|
||||||
|
var token = "old"
|
||||||
|
var revoked = false
|
||||||
|
var oldTunnelStopped = false
|
||||||
|
withTimeout(1_000) {
|
||||||
|
UcvlAuthorizationGuard.watch("old", { token }, {
|
||||||
|
token = "new"
|
||||||
|
}, { revoked = true }, { oldTunnelStopped = true }, 60_000)
|
||||||
|
}
|
||||||
|
assertFalse("An old successful request must not revoke a replacement login", revoked)
|
||||||
|
assertTrue("Old tunnel must stop without waiting for the next poll", oldTunnelStopped)
|
||||||
|
assertEquals("new", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun replacementBeforeRequestStopsOnlyOldTunnel() = runBlocking {
|
||||||
|
var changes = 0
|
||||||
|
UcvlAuthorizationGuard.watch("old", { "new" }, { fail("Must not query old token") },
|
||||||
|
{ fail("Must not revoke new login") }, { changes++ })
|
||||||
|
assertEquals(1, changes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun removedLoginStopsOldTunnelWithoutFalseRevocation() = runBlocking {
|
||||||
|
var changes = 0
|
||||||
|
UcvlAuthorizationGuard.watch("old", { null }, { fail("Already logged out") },
|
||||||
|
{ fail("No current login to revoke") }, { changes++ })
|
||||||
|
assertEquals(1, changes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun lateTransientErrorStopsChangedSessionImmediately() = runBlocking {
|
||||||
|
var token = "old"
|
||||||
|
var changes = 0
|
||||||
|
withTimeout(1_000) {
|
||||||
|
UcvlAuthorizationGuard.watch("old", { token }, {
|
||||||
|
token = "new"
|
||||||
|
throw java.io.IOException("offline")
|
||||||
|
}, { fail("Network errors must not revoke a new login") }, { changes++ }, 60_000)
|
||||||
|
}
|
||||||
|
assertEquals(1, changes)
|
||||||
|
assertEquals("new", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun cancelledRequestDoesNotRevokeOrStopAnotherSession() = runBlocking {
|
||||||
|
var cancellationPropagated = false
|
||||||
|
try {
|
||||||
|
UcvlAuthorizationGuard.watch("old", { "old" }, { throw CancellationException("service stopped") },
|
||||||
|
{ fail("Cancelled watcher must not revoke") }, { fail("Cancelled watcher must not stop") })
|
||||||
|
} catch (_: CancellationException) { cancellationPropagated = true }
|
||||||
|
assertTrue(cancellationPropagated)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun sameLoginSuccessKeepsWatchingUntilActuallyRevoked() = runBlocking {
|
||||||
|
var calls = 0
|
||||||
|
var revocations = 0
|
||||||
|
UcvlAuthorizationGuard.watch("old", { "old" }, {
|
||||||
|
calls++
|
||||||
|
if (calls == 2) throw UcvlApiException(403, "revoked")
|
||||||
|
assertEquals(0, revocations)
|
||||||
|
}, { revocations++ }, { fail("Same session") }, 1)
|
||||||
|
assertEquals(2, calls)
|
||||||
|
assertEquals(1, revocations)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun lateForbiddenMustNotRevokeNewLogin() = runBlocking {
|
||||||
|
var token = "old"
|
||||||
|
var changes = 0
|
||||||
|
UcvlAuthorizationGuard.watch("old", { token }, {
|
||||||
|
token = "new"
|
||||||
|
throw UcvlApiException(403, "old forbidden response")
|
||||||
|
}, { fail("New login must survive") }, { changes++ }, 1)
|
||||||
|
assertEquals(1, changes)
|
||||||
assertEquals("new", token)
|
assertEquals("new", token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Android 1.0.3 本地候选验收
|
# Android 1.0.3 本地候选验收
|
||||||
|
|
||||||
|
## 2026-09-17 源码追加:后台授权会话隔离
|
||||||
|
|
||||||
|
已在隔离单测复现并修复旧会话成功回包导致新登录误报授权失效,以及会话已替换时旧隧道停止不及时的问题。修复前定向4项中1失败;修复后定向10项、全量41项全部通过,0跳过。详见[会话隔离验收](AUTH-SESSION-ISOLATION-20260917.md)。**本次只编译源码并运行JVM测试,未重新打包/签名/安装/发布;下方1.0.3旧APK哈希不包含此次源码修复,不能混用验收证据。**
|
||||||
|
|
||||||
|
## 2026-09-08 至 09-09 原候选包记录
|
||||||
|
|
||||||
时间:2026-09-08,状态复核于 2026-09-09。未发布 1.0.3 安装包、未通知员工;对应 GPL 源码和验收记录已同步公司公共 Git 的 `codex/overnight-20260908` 维护分支,尚未合并正式分支。源码同步不代表真机 VPN 验收完成。
|
时间:2026-09-08,状态复核于 2026-09-09。未发布 1.0.3 安装包、未通知员工;对应 GPL 源码和验收记录已同步公司公共 Git 的 `codex/overnight-20260908` 维护分支,尚未合并正式分支。源码同步不代表真机 VPN 验收完成。
|
||||||
|
|
||||||
- 公司 Git 正式版检查:独立无凭据 HTTP 客户端,忽略草稿/试用版,只允许规范的公司 Git 下载链接;失败不停止 VPN。更新由用户确认后浏览器下载,尚不是应用内自动安装。
|
- 公司 Git 正式版检查:独立无凭据 HTTP 客户端,忽略草稿/试用版,只允许规范的公司 Git 下载链接;失败不停止 VPN。更新由用户确认后浏览器下载,尚不是应用内自动安装。
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Android 1.0.4 isolated candidate
|
||||||
|
|
||||||
|
Status: local signed candidate built and verified on 2026-09-24. Not published, not installed on a physical device, and not accepted as a stable release.
|
||||||
|
|
||||||
|
VersionName 1.0.4 / base versionCode 10004; the existing playstore ABI mapping produces APK versionCode 4010004 (previous candidate: 4010003). Includes the 2026-09-17 authorization-session isolation fix without changing the VPN or network policy.
|
||||||
|
|
||||||
|
The candidate was built in a fresh ASCII directory using cached JDK 21 / SDK 37 / Gradle 9.4.1 with `--offline --no-daemon --max-workers=2`. All 41 JVM tests passed with zero failures/errors/skips, including 10 authorization lifecycle cases for old success/401/403 responses, replaced or removed login, transient failure, and cancellation. All five ABI/universal release APKs were assembled. No emulator, Android service instrumentation or production API was used.
|
||||||
|
|
||||||
|
All five APK manifests independently report package `cn.toplc.zonghengjia`, versionName `1.0.4`, versionCode `4010004`. APK Signature Scheme v2/v3 verification passed. The certificate SHA256 is `0f1a09870d6ee1e73b62138f99463e7066f4228ce28b7f16ff0aeccca59ea14f`, independently compared with retained 1.0.2 and 1.0.3 universal APKs. Signing material was not exported. Old APKs remain unchanged.
|
||||||
|
|
||||||
|
| Artifact suffix | Bytes | SHA256 |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| android-universal.apk | 64228400 | `fc8ef82ba2ef28cb26991401c8ea5feca492f929a7bad4aa6b39e4787baaf416` |
|
||||||
|
| android-arm64-v8a.apk | 27503013 | `7527441177d858b48975aa882d1313e7cb693653ced5c5de6d17fd3abce83a9f` |
|
||||||
|
| android-armeabi-v7a.apk | 27855275 | `4121ea1f442cc3ec507ede50a19587bcaca983d147ec3af6ad27b7c62f229ca5` |
|
||||||
|
| android-x86_64.apk | 28387740 | `f24a1f9b753030a4bd68278d685a03d0fdb7d8e0e34e96bd16e8fa6eba164125` |
|
||||||
|
| android-x86.apk | 28842387 | `bd912c09f338f57f34806914fa45f86a8d7792a56bb24552ebf5a13888234b61` |
|
||||||
|
|
||||||
|
Every APK filename starts with `UCVL-Zonghengjia-1.0.4-`. The immutable build-input manifest SHA256 is `404c9c802321c39c2589c7d7c10e0e2d418af6e2d03d86f7de2ee8354f3bedeb`. The candidate report additionally binds signed APK ZIP-entry bytes to the unsigned Gradle outputs and the GPL snapshot to each compiled source input; seven AAR/JNI inputs are checked against the existing public restoration locks.
|
||||||
|
|
||||||
|
The first wrapper run stopped after successful Gradle completion because its PowerShell result aggregation used dictionaries with Measure-Object. The aggregation was corrected, and signing resumed against the same verified input manifest and completed outputs; tests/build were not falsely reported failed or silently rerun against different source.
|
||||||
|
|
||||||
|
Remaining device gates: real Android VPN permission and data path, service/broadcast behavior when a login replaces an in-flight authorization request, disconnect/recovery, Wi-Fi/mobile transitions, sleep/wake and authenticated sustained business traffic. JVM tests and APK signatures do not establish these results.
|
||||||
|
|
||||||
|
GPL source is exported separately using the publication tool that excludes the private Zero3 helper. No phone, board, host network, iOS or remote Git operation is part of this candidate preparation.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Android 后台授权检查:旧会话与新登录隔离
|
||||||
|
|
||||||
|
状态:本地源码候选修复,未发布APK,未在手机安装。版本号仍为1.0.3;此前1.0.3已签名候选包不包含本次修复,不可将其旧哈希作为本次修复后的二进制证据。
|
||||||
|
|
||||||
|
## 复现与原因
|
||||||
|
|
||||||
|
原UcvlAuthorizationGuard在检查开始时发现保存的令牌不等于该VPN的令牌,会调用与401/403相同的revoke回调。该回调虽然不清除较新的令牌,却无条件发送AUTHORIZATION_REVOKED广播,使新登录界面被退回登录页。成功或暂时性失败的旧请求还要等下一轮60秒检查才停止旧隧道;旧401/403回包遇到新令牌时则直接返回,没有调用停止旧隧道回调。
|
||||||
|
|
||||||
|
隔离复现:新增lateSuccessMustNotRevokeNewLogin,在授权请求中模拟令牌由old变为new。原代码执行4项测试,3通过、该项真实失败;证据android-auth-before-20260917.json/.log。全部为合成令牌和回调,不是生产网络或真实用户账号复现。
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
|
||||||
|
- 将sessionChanged(只停止旧VPN)与revoke(当前令牌确实被401/403拒绝)分离。
|
||||||
|
- 请求前、成功/临时失败后立即核对会话,发生变化不等待下一次60秒轮询。
|
||||||
|
- 旧401/403响应属于已替换会话时停止旧隧道,但不撤销新令牌。
|
||||||
|
- CoreVpnService只有确认仍属于同一令牌时才清除令牌并发出撤销广播;替换/退出会话仅停止旧服务。
|
||||||
|
- 协程取消继续向上传播,不把取消视为账号撤销;保持临时断网不撤销同一会话的策略。
|
||||||
|
|
||||||
|
这不是新增自动换节点功能,也不声称修复了Activity所有网络请求或所有换网问题。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
独立ASCII临时副本:`C:/Users/Administrator/AppData/Local/Temp/ucvl-auth-20260917-907433b0081d432aa9a6d763f8bb061b`。使用已有JDK21、Android SDK37、Gradle9.4.1缓存,全程--offline、--no-daemon;没有启动模拟器或读取签名私钥。
|
||||||
|
|
||||||
|
1. 原代码定向回归:4项,1项失败(预期暴露缺陷)。
|
||||||
|
2. 修复后定向回归:10项全部通过,0跳过。覆盖同会话撤销、临时离线、旧401/403、旧成功响应、预先替换/退出、取消、持续检查;成功和临时失败的会话变更需在1秒上限内退出,不等60秒。
|
||||||
|
3. 全量`:app:testPlaystoreReleaseUnitTest`:**41项全部通过,0失败、0错误、0跳过**。包括授权10、桥接5、版本解析7和其余19项;修复后的主应用Kotlin及测试Kotlin编译通过。
|
||||||
|
4. 没有运行assemble、签名、APK安装、VPN授权、换网或真实服务广播/UI仪器测试。不等于Android实机、Codex长连接或全平台验收。
|
||||||
|
|
||||||
|
定向复测证据android-auth-after-20260917.json/.log,全量证据android-auth-full-20260917.json/.log。复现工具`tools/Test-SessionGuard-Isolated.ps1`,首次不要删除原源码/旧安装,测试仅复制到专用临时目录;不会连接公司生产API。
|
||||||
|
|
||||||
|
本次源文件SHA256:
|
||||||
|
|
||||||
|
| 文件 | SHA256 |
|
||||||
|
| --- | --- |
|
||||||
|
| UcvlAuthorizationGuard.kt | 5C7B01D602EB0F5B45BBE88D7F12E3BC70FED4296AE5B6578C2F25A918FD87C4 |
|
||||||
|
| CoreVpnService.kt | 4F55A79F7179D26AF622998C9A70A0D2C64BC1C60B56DDC9E10BA06EE921D850 |
|
||||||
|
| UcvlAuthorizationGuardTest.kt | ACB7BD96F1D1766D59B4D42DC12295621EDEE98E9DCCF78FE566B83770ADF919 |
|
||||||
|
|
||||||
|
下一步:在专用Android设备/模拟器做真实service生命周期、重新登录后旧响应/撤销广播、断网恢复验证,再在实体手机补VPN及长连接验收,之后方可决定发布。当前旧候选APK、公司Git公开版本、Windows现用安装、生产后台/节点均未修改,没有员工通知。
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"phase": "after",
|
||||||
|
"started_at": "2026-09-17T10:51:11.1475501+08:00",
|
||||||
|
"runtime": "C:\\Users\\Administrator\\AppData\\Local\\Temp\\ucvl-auth-20260917-907433b0081d432aa9a6d763f8bb061b",
|
||||||
|
"offline": true,
|
||||||
|
"exit_code": 0,
|
||||||
|
"suites": [
|
||||||
|
{
|
||||||
|
"name": "com.v2ray.ang.ucvl.UcvlAuthorizationGuardTest",
|
||||||
|
"tests": 10,
|
||||||
|
"failures": 0,
|
||||||
|
"errors": 0,
|
||||||
|
"skipped": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source_copied": true,
|
||||||
|
"emulator_started": false,
|
||||||
|
"device_installed": false,
|
||||||
|
"apk_published": false,
|
||||||
|
"network_modified": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"phase": "before",
|
||||||
|
"started_at": "2026-09-17T08:59:15.9076805+08:00",
|
||||||
|
"runtime": "C:\\Users\\Administrator\\AppData\\Local\\Temp\\ucvl-auth-20260917-907433b0081d432aa9a6d763f8bb061b",
|
||||||
|
"offline": true,
|
||||||
|
"exit_code": 1,
|
||||||
|
"suites": [
|
||||||
|
{
|
||||||
|
"name": "com.v2ray.ang.ucvl.UcvlAuthorizationGuardTest",
|
||||||
|
"tests": 4,
|
||||||
|
"failures": 1,
|
||||||
|
"errors": 0,
|
||||||
|
"skipped": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source_copied": true,
|
||||||
|
"emulator_started": false,
|
||||||
|
"device_installed": false,
|
||||||
|
"apk_published": false,
|
||||||
|
"network_modified": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"phase": "full",
|
||||||
|
"started_at": "2026-09-17T12:10:25.1756796+08:00",
|
||||||
|
"runtime": "C:\\Users\\Administrator\\AppData\\Local\\Temp\\ucvl-auth-20260917-907433b0081d432aa9a6d763f8bb061b",
|
||||||
|
"offline": true,
|
||||||
|
"exit_code": 0,
|
||||||
|
"suites": [
|
||||||
|
{
|
||||||
|
"name": "com.v2ray.ang.fmt.ShadowsocksFmtTest",
|
||||||
|
"tests": 15,
|
||||||
|
"failures": 0,
|
||||||
|
"errors": 0,
|
||||||
|
"skipped": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "com.v2ray.ang.HttpUtilTest",
|
||||||
|
"tests": 1,
|
||||||
|
"failures": 0,
|
||||||
|
"errors": 0,
|
||||||
|
"skipped": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "com.v2ray.ang.ucvl.UcvlAuthorizationGuardTest",
|
||||||
|
"tests": 10,
|
||||||
|
"failures": 0,
|
||||||
|
"errors": 0,
|
||||||
|
"skipped": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "com.v2ray.ang.ucvl.UcvlConfigBridgeTest",
|
||||||
|
"tests": 5,
|
||||||
|
"failures": 0,
|
||||||
|
"errors": 0,
|
||||||
|
"skipped": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "com.v2ray.ang.ucvl.UcvlReleaseCheckerTest",
|
||||||
|
"tests": 7,
|
||||||
|
"failures": 0,
|
||||||
|
"errors": 0,
|
||||||
|
"skipped": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "com.v2ray.ang.UtilsTest",
|
||||||
|
"tests": 3,
|
||||||
|
"failures": 0,
|
||||||
|
"errors": 0,
|
||||||
|
"skipped": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"source_copied": true,
|
||||||
|
"emulator_started": false,
|
||||||
|
"device_installed": false,
|
||||||
|
"apk_published": false,
|
||||||
|
"network_modified": false
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"url": "https://pro.ucvl.cn/admin/ucvl-software-downloads/releases/download/android-v1.0.1-candidate.1/UCVL-Zonghengjia-1.0.1-android-universal.apk",
|
||||||
|
"release": "https://pro.ucvl.cn/admin/ucvl-software-downloads/releases/tag/android-v1.0.1-candidate.1",
|
||||||
|
"source_zip": "https://pro.ucvl.cn/admin/ucvl-zonghengjia-android/archive/android-v1.0.1-candidate.1.zip",
|
||||||
|
"version": "1.0.1",
|
||||||
|
"size": 64220208,
|
||||||
|
"sha256": "87747619f838f432c94c90785e6523bf5a9e82bb3a138b47ba3c9e293e172d45",
|
||||||
|
"anonymous_download_verified": true,
|
||||||
|
"prerelease": true,
|
||||||
|
"physical_android_device_tested": false,
|
||||||
|
"stable_update_changed": false,
|
||||||
|
"employee_notification_sent": false,
|
||||||
|
"windows_and_macos_source_private": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"repository": "https://pro.ucvl.cn/admin/ucvl-zonghengjia-android",
|
||||||
|
"source_zip": "https://pro.ucvl.cn/admin/ucvl-zonghengjia-android/archive/android-v1.0.1-candidate.1.zip",
|
||||||
|
"commit": "0a8bdc9fff9cc865d0619fc67d94bca5de5ac83a",
|
||||||
|
"tag": "android-v1.0.1-candidate.1",
|
||||||
|
"anonymous_download_verified": true,
|
||||||
|
"source_files": 971,
|
||||||
|
"bytes": 2450691,
|
||||||
|
"sha256": "eb22a45f8d2e2cf77edf7f8fa6e058ea04383260c40d8e336bffed2dc97cd16b",
|
||||||
|
"other_source_repositories": {
|
||||||
|
"ucvl-zonghengjia": "not anonymously readable",
|
||||||
|
"ucvl-zonghengjia-macos": "not anonymously readable"
|
||||||
|
},
|
||||||
|
"binary_release_created": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"timestamp": "2026-09-03T11:00:29.868500+08:00",
|
||||||
|
"host_network_changed": false,
|
||||||
|
"separate_uid_no_explicit_proxy": true,
|
||||||
|
"physical_android_device_tested": false,
|
||||||
|
"results": [],
|
||||||
|
"pass": false,
|
||||||
|
"baseline": {
|
||||||
|
"transport_vpn": false,
|
||||||
|
"uid": 10150,
|
||||||
|
"explicit_proxy": false,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"target": "www.google.com",
|
||||||
|
"pass": false,
|
||||||
|
"error_type": "ConnectException"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target": "api.openai.com",
|
||||||
|
"pass": false,
|
||||||
|
"error_type": "ConnectException"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target": "chatgpt.com",
|
||||||
|
"pass": false,
|
||||||
|
"error_type": "ConnectException"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target": "vpn.toplc.cn",
|
||||||
|
"status": 200,
|
||||||
|
"pass": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"error": "Connect test exited early: ngOs.open(ForwardingOs.java:563)\n\tat libcore.io.BlockGuardOs.open(BlockGuardOs.java:274)\n\tat libcore.io.ForwardingOs.open(ForwardingOs.java:563)\n\tat android.app.ActivityThread$AndroidOs.open(ActivityThread.java:8591)\n\tat libcore.io.IoBridge.open(IoBridge.java:560)\n\t... 37 more\n\nINSTRUMENTATION_STATUS: stream=\nError in connectFromDashboard(com.v2ray.ang.ucvl.VpnLifecycleTest):\njava.io.FileNotFoundException: /data/user/0/cn.toplc.zonghengjia/files/vpn-acceptance-fixture.json: open failed: EACCES (Permission denied)\n\tat libcore.io.IoBridge.open(IoBridge.java:574)\n\tat java.io.FileInputStream.<init>(FileInputStream.java:179)\n\tat kotlin.io.FilesKt__FileReadWriteKt.readText(FileReadWrite.kt:135)\n\tat kotlin.io.FilesKt__FileReadWriteKt.readText$default(FileReadWrite.kt:135)\n\tat com.v2ray.ang.ucvl.VpnLifecycleTest.connectFromDashboard(VpnLifecycleTest.kt:22)\n\t... 32 trimmed\nCaused by: android.system.ErrnoException: open failed: EACCES (Permission denied)\n\tat libcore.io.Linux.open(Native Method)\n\tat libcore.io.ForwardingOs.open(ForwardingOs.java:563)\n\tat libcore.io.BlockGuardOs.open(BlockGuardOs.java:274)\n\tat libcore.io.ForwardingOs.open(ForwardingOs.java:563)\n\tat android.app.ActivityThread$AndroidOs.open(ActivityThread.java:8591)\n\tat libcore.io.IoBridge.open(IoBridge.java:560)\n\t... 37 more\n\nINSTRUMENTATION_STATUS: test=connectFromDashboard\nINSTRUMENTATION_STATUS_CODE: -2\nINSTRUMENTATION_RESULT: stream=\n\nTime: 0.011\nThere was 1 failure:\n1) connectFromDashboard(com.v2ray.ang.ucvl.VpnLifecycleTest)\njava.io.FileNotFoundException: /data/user/0/cn.toplc.zonghengjia/files/vpn-acceptance-fixture.json: open failed: EACCES (Permission denied)\n\tat libcore.io.IoBridge.open(IoBridge.java:574)\n\tat java.io.FileInputStream.<init>(FileInputStream.java:179)\n\tat kotlin.io.FilesKt__FileReadWriteKt.readText(FileReadWrite.kt:135)\n\tat kotlin.io.FilesKt__FileReadWriteKt.readText$default(FileReadWrite.kt:135)\n\tat com.v2ray.ang.ucvl.VpnLifecycleTest.connectFromDashboard(VpnLifecycleTest.kt:22)\n\t... 32 trimmed\nCaused by: android.system.ErrnoException: open failed: EACCES (Permission denied)\n\tat libcore.io.Linux.open(Native Method)\n\tat libcore.io.ForwardingOs.open(ForwardingOs.java:563)\n\tat libcore.io.BlockGuardOs.open(BlockGuardOs.java:274)\n\tat libcore.io.ForwardingOs.open(ForwardingOs.java:563)\n\tat android.app.ActivityThread$AndroidOs.open(ActivityThread.java:8591)\n\tat libcore.io.IoBridge.open(IoBridge.java:560)\n\t... 37 more\n\nFAILURES!!!\nTests run: 1, Failures: 1\n\n\nINSTRUMENTATION_CODE: -1\n"
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"timestamp": "2026-09-08T22:13:37.462276+08:00",
|
"timestamp": "2026-09-08T22:13:37.462276+08:00",
|
||||||
"serial": "emulator-5554",
|
"serial": "emulator-5554",
|
||||||
"old_version": "1.0.2",
|
"old_version": "1.0.2",
|
||||||
"new_version": "1.0.3",
|
"new_version": "1.0.3",
|
||||||
"passed": true,
|
"passed": true,
|
||||||
"physical_device_tested": false,
|
"physical_device_tested": false,
|
||||||
"host_network_modified": false,
|
"host_network_modified": false,
|
||||||
"authenticated_vpn_connection_tested": false,
|
"authenticated_vpn_connection_tested": false,
|
||||||
"steps": [
|
"steps": [
|
||||||
"seed_synthetic_session_on_1.0.2",
|
"seed_synthetic_session_on_1.0.2",
|
||||||
"cover_upgrade_preserves_encrypted_session_login_and_device_id",
|
"cover_upgrade_preserves_encrypted_session_login_and_device_id",
|
||||||
"login_visible_empty_submission_safe_fixture_cleared"
|
"login_visible_empty_submission_safe_fixture_cleared"
|
||||||
],
|
],
|
||||||
"apk_sha256": "866d17bc303aa9cbc71b71c954ddd1f3ed50251326fc035a9e1d140ff230ee66"
|
"apk_sha256": "866d17bc303aa9cbc71b71c954ddd1f3ed50251326fc035a9e1d140ff230ee66"
|
||||||
}
|
}
|
||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
|||||||
|
# Local candidate only. Uses cached dependencies; never starts adb or an emulator.
|
||||||
|
param([string]$JobDirectory = '',[switch]$ResumeAfterBuild)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$repo = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
|
||||||
|
$tempRoot = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetTempPath())
|
||||||
|
if ($ResumeAfterBuild -and -not $JobDirectory) { throw 'Resume requires the exact completed build directory' }
|
||||||
|
if (-not $JobDirectory) { $JobDirectory = Join-Path $tempRoot ('ucvl-android-104-' + [guid]::NewGuid().ToString('N')) }
|
||||||
|
$job = [IO.Path]::GetFullPath($JobDirectory)
|
||||||
|
if ([IO.Path]::GetDirectoryName($job) -ine $tempRoot -or [IO.Path]::GetFileName($job) -cnotmatch '^ucvl-android-104-[a-f0-9]{32}$' -or $job -match '[^\x00-\x7F]') {
|
||||||
|
throw 'A fresh scoped ASCII temporary directory is required'
|
||||||
|
}
|
||||||
|
if ((Test-Path -LiteralPath $job) -and -not $ResumeAfterBuild) { throw 'Candidate directory already exists' }
|
||||||
|
foreach ($name in @('universal','arm64-v8a','armeabi-v7a','x86','x86_64')) {
|
||||||
|
if (Test-Path -LiteralPath (Join-Path $repo "releases/UCVL-Zonghengjia-1.0.4-android-$name.apk")) { throw 'Signed 1.0.4 candidate already exists; do not replace it' }
|
||||||
|
}
|
||||||
|
$buildText = Get-Content -LiteralPath (Join-Path $repo 'V2rayNG/app/build.gradle.kts') -Raw
|
||||||
|
if ($buildText -notmatch 'versionCode = 10004' -or $buildText -notmatch 'versionName = "1.0.4"') { throw 'Source version mismatch' }
|
||||||
|
$project = Join-Path $job 'project'
|
||||||
|
$source = Join-Path $repo 'V2rayNG'
|
||||||
|
function Save-Json([string]$Name,$Value) {
|
||||||
|
[IO.File]::WriteAllText((Join-Path $job "evidence/$Name"),($Value | ConvertTo-Json -Depth 12),[Text.UTF8Encoding]::new($false))
|
||||||
|
}
|
||||||
|
$env:JAVA_HOME = Join-Path $repo '.tools/jdk-staging/jdk-21.0.12.1+1'
|
||||||
|
$env:ANDROID_HOME = Join-Path $repo '.tools/android-sdk'
|
||||||
|
if (-not $ResumeAfterBuild) {
|
||||||
|
$null = New-Item -ItemType Directory -Path $job,$project,(Join-Path $job 'evidence'),(Join-Path $job 'artifacts')
|
||||||
|
foreach ($name in @('gradlew','gradlew.bat','gradle.properties','settings.gradle.kts','build.gradle.kts','gradle')) {
|
||||||
|
Copy-Item -LiteralPath (Join-Path $source $name) -Destination $project -Recurse
|
||||||
|
}
|
||||||
|
$null = New-Item -ItemType Directory -Path (Join-Path $project 'app')
|
||||||
|
foreach ($name in @('src','libs','build.gradle.kts','proguard-rules.pro')) {
|
||||||
|
Copy-Item -LiteralPath (Join-Path $source "app/$name") -Destination (Join-Path $project 'app') -Recurse
|
||||||
|
}
|
||||||
|
$inputs = @(Get-ChildItem -LiteralPath $project -File -Recurse | Sort-Object FullName | ForEach-Object {
|
||||||
|
[ordered]@{Path=[IO.Path]::GetRelativePath($project,$_.FullName).Replace('\','/');Bytes=$_.Length;Sha256=(Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash}
|
||||||
|
})
|
||||||
|
Save-Json 'build-inputs.json' $inputs
|
||||||
|
$inputsHash = (Get-FileHash -LiteralPath (Join-Path $job 'evidence/build-inputs.json')).Hash
|
||||||
|
$started = [DateTimeOffset]::Now
|
||||||
|
Push-Location $project
|
||||||
|
try {
|
||||||
|
& ./gradlew.bat --offline --no-daemon --max-workers=2 :app:testPlaystoreReleaseUnitTest :app:assemblePlaystoreRelease *> (Join-Path $job 'evidence/gradle.log')
|
||||||
|
$buildExit = $LASTEXITCODE
|
||||||
|
} finally { Pop-Location }
|
||||||
|
$suites = @(Get-ChildItem -LiteralPath (Join-Path $project 'app/build/test-results/testPlaystoreReleaseUnitTest') -Filter 'TEST-*.xml' -File -ErrorAction SilentlyContinue | ForEach-Object {
|
||||||
|
[xml]$xml = Get-Content -LiteralPath $_.FullName -Raw
|
||||||
|
[pscustomobject]@{Name=$xml.testsuite.name;Tests=[int]$xml.testsuite.tests;Failures=[int]$xml.testsuite.failures;Errors=[int]$xml.testsuite.errors;Skipped=[int]$xml.testsuite.skipped}
|
||||||
|
})
|
||||||
|
$tests = [ordered]@{StartedAt=$started.ToString('O');ExitCode=$buildExit;Offline=$true;Suites=$suites;BuildInputsSha256=$inputsHash;PhysicalDeviceTested=$false;EmulatorStarted=$false;Published=$false}
|
||||||
|
Save-Json 'tests.json' $tests
|
||||||
|
} else {
|
||||||
|
$tests = Get-Content -LiteralPath (Join-Path $job 'evidence/tests.json') -Raw | ConvertFrom-Json
|
||||||
|
$suites = $tests.Suites
|
||||||
|
$buildExit = $tests.ExitCode
|
||||||
|
$inputs = Get-Content -LiteralPath (Join-Path $job 'evidence/build-inputs.json') -Raw | ConvertFrom-Json
|
||||||
|
$inputsHash = (Get-FileHash -LiteralPath (Join-Path $job 'evidence/build-inputs.json')).Hash
|
||||||
|
if ($inputsHash -ne $tests.BuildInputsSha256) { throw 'Completed build input manifest changed' }
|
||||||
|
}
|
||||||
|
if ($buildExit -ne 0) { throw "Gradle failed ($buildExit); evidence retained at $job" }
|
||||||
|
if (($suites | Measure-Object Tests -Sum).Sum -ne 41 -or ($suites | Measure-Object Failures -Sum).Sum -ne 0 -or ($suites | Measure-Object Errors -Sum).Sum -ne 0 -or ($suites | Measure-Object Skipped -Sum).Sum -ne 0) {
|
||||||
|
throw 'Unexpected unit test coverage or failures'
|
||||||
|
}
|
||||||
|
foreach ($row in $inputs) {
|
||||||
|
if ((Get-FileHash -LiteralPath (Join-Path $project $row.Path)).Hash -ne $row.Sha256 -or (Get-FileHash -LiteralPath (Join-Path $source $row.Path)).Hash -ne $row.Sha256) {
|
||||||
|
throw "Input changed during build: $($row.Path)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
& (Join-Path $repo 'Sign-Candidate.ps1') -Version '1.0.4' -InputDirectory (Join-Path $project 'app/build/outputs/apk/playstore/release') *> (Join-Path $job 'evidence/signing.log')
|
||||||
|
$java = Join-Path $env:JAVA_HOME 'bin/java.exe'
|
||||||
|
$signer = Join-Path $env:ANDROID_HOME 'build-tools/37.0.0/lib/apksigner.jar'
|
||||||
|
$aapt = Join-Path $env:ANDROID_HOME 'build-tools/37.0.0/aapt.exe'
|
||||||
|
$expectedCertificate = '0f1a09870d6ee1e73b62138f99463e7066f4228ce28b7f16ff0aeccca59ea14f'
|
||||||
|
foreach ($version in @('1.0.2','1.0.3')) {
|
||||||
|
$verification = (& $java -jar $signer verify --verbose --print-certs (Join-Path $repo "releases/UCVL-Zonghengjia-$version-android-universal.apk") 2>&1 | Out-String)
|
||||||
|
if ($LASTEXITCODE -ne 0 -or $verification -notmatch $expectedCertificate) { throw "Old $version signing identity differs" }
|
||||||
|
}
|
||||||
|
$packages = @(foreach ($file in Get-ChildItem -LiteralPath (Join-Path $repo 'releases') -Filter 'UCVL-Zonghengjia-1.0.4-android-*.apk' -File | Sort-Object Name) {
|
||||||
|
$verification = (& $java -jar $signer verify --verbose --print-certs $file.FullName 2>&1 | Out-String)
|
||||||
|
if ($LASTEXITCODE -ne 0 -or $verification -notmatch $expectedCertificate -or $verification -notmatch 'v2\): true' -or $verification -notmatch 'v3\): true') { throw "Candidate signature mismatch: $($file.Name)" }
|
||||||
|
$copy = Join-Path $job "artifacts/$($file.Name)"
|
||||||
|
Copy-Item -LiteralPath $file.FullName -Destination $copy
|
||||||
|
$badging = (& $aapt dump badging $copy 2>&1 | Out-String)
|
||||||
|
if ($LASTEXITCODE -ne 0 -or $badging -notmatch "package: name='cn.toplc.zonghengjia' versionCode='4010004' versionName='1.0.4'") { throw "Candidate manifest mismatch: $($file.Name)" }
|
||||||
|
[ordered]@{Name=$file.Name;Bytes=$file.Length;Sha256=(Get-FileHash -LiteralPath $copy).Hash;Version='1.0.4';VersionCode=4010004;SignerSha256=$expectedCertificate;V2=$true;V3=$true}
|
||||||
|
})
|
||||||
|
if ($packages.Count -ne 5) { throw 'Expected all five ABI/universal packages' }
|
||||||
|
Save-Json 'candidate.json' ([ordered]@{Version='1.0.4';VersionCode=4010004;BaseVersionCode=10004;JobDirectory=$job;BuiltAt=[DateTimeOffset]::Now.ToString('O');Packages=$packages;BuildInputsSha256=$inputsHash;UnitTests=41;AuthorizationLifecycleJvmTests=10;SameSignerAs=@('1.0.2','1.0.3');PhysicalDeviceTested=$false;AndroidServiceBroadcastTested=$false;StableReleaseAccepted=$false;Published=$false})
|
||||||
|
Write-Output "CANDIDATE_JOB=$job"
|
||||||
|
Write-Output 'CANDIDATE_BUILD_SIGN_VERIFY_PASSED'
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""Bind isolated APKs to their exact build inputs and reviewed GPL source. No network or devices."""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[1]
|
||||||
|
VERSION = '1.0.4'
|
||||||
|
LOCKED_BINARY_INPUTS = {
|
||||||
|
'app/libs/libv2ray.aar': '3d43b9344723e9c0625527de4f2bea0ee02c21180224e5ecab3384af143ca6d0',
|
||||||
|
'app/libs/quickie-foss-1.14.0.aar': '4d90e9cb37e07b57eaa3a839be2abdc422c0ce9ac8e969720a2a71c968cd771f',
|
||||||
|
'app/libs/Toasty-1.5.2.aar': '57866e731ebe3ef82328942cd4d96cf940b2406c9440f33857f69777027f36b5',
|
||||||
|
'app/libs/arm64-v8a/libhev-socks5-tunnel.so': '4eb0b2353f4fb6d45f43cca39fe72197ce9df8c8f5ba5a82feac87abceb9ea7a',
|
||||||
|
'app/libs/armeabi-v7a/libhev-socks5-tunnel.so': '816c48bab1785fcdaaf7fc2f49dc6b0733cf11e68b9379640d22d6a539f306ff',
|
||||||
|
'app/libs/x86/libhev-socks5-tunnel.so': '6e42da45387d76630a8ffd52ea45cd5738395da39df31dba1a48806fa79d3572',
|
||||||
|
'app/libs/x86_64/libhev-socks5-tunnel.so': 'c61a54f38f61feb73b465d2245fe6e4746c2efba7e9024de8de025ce3fc9206e',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def digest(path):
|
||||||
|
with Path(path).open('rb') as stream:
|
||||||
|
return hashlib.file_digest(stream, 'sha256').hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def save(path, value):
|
||||||
|
with Path(path).open('x', encoding='utf-8', newline='\n') as stream:
|
||||||
|
json.dump(value, stream, indent=2, ensure_ascii=False)
|
||||||
|
stream.write('\n')
|
||||||
|
|
||||||
|
|
||||||
|
def main(job):
|
||||||
|
job = Path(job).resolve()
|
||||||
|
if not re.fullmatch(r'ucvl-android-104-[a-f0-9]{32}', job.name):
|
||||||
|
raise RuntimeError('Unexpected candidate job')
|
||||||
|
candidate = json.loads((job / 'evidence/candidate.json').read_text(encoding='utf-8'))
|
||||||
|
if candidate['Version'] != VERSION or candidate['VersionCode'] != 4010004 or candidate['Published']:
|
||||||
|
raise RuntimeError('Unexpected candidate contract')
|
||||||
|
inputs_path = job / 'evidence/build-inputs.json'
|
||||||
|
if digest(inputs_path) != candidate['BuildInputsSha256'].lower():
|
||||||
|
raise RuntimeError('Build input manifest changed')
|
||||||
|
inputs = json.loads(inputs_path.read_text(encoding='utf-8'))
|
||||||
|
public = job / 'public-source-final'
|
||||||
|
matched = 0
|
||||||
|
binary_inputs = []
|
||||||
|
for row in inputs:
|
||||||
|
name, expected = row['Path'], row['Sha256'].lower()
|
||||||
|
if digest(job / 'project' / name) != expected or digest(REPO / 'V2rayNG' / name) != expected:
|
||||||
|
raise RuntimeError('Build/source drift: ' + name)
|
||||||
|
if name in LOCKED_BINARY_INPUTS:
|
||||||
|
if expected != LOCKED_BINARY_INPUTS[name]:
|
||||||
|
raise RuntimeError('Dependency differs from public restoration lock: ' + name)
|
||||||
|
binary_inputs.append({'path': name, 'sha256': expected})
|
||||||
|
else:
|
||||||
|
if digest(public / 'V2rayNG' / name) != expected:
|
||||||
|
raise RuntimeError('GPL snapshot does not match compiled input: ' + name)
|
||||||
|
matched += 1
|
||||||
|
if len(binary_inputs) != len(LOCKED_BINARY_INPUTS):
|
||||||
|
raise RuntimeError('Missing locked binary dependencies')
|
||||||
|
|
||||||
|
inventory = []
|
||||||
|
forbidden = {'.jks', '.keystore', '.p12', '.pfx', '.pem', '.apk', '.aar', '.so', '.idsig', '.log'}
|
||||||
|
for path in sorted(public.rglob('*')):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
name = path.relative_to(public).as_posix()
|
||||||
|
if path.is_symlink() or name.casefold().startswith('zero3-helper/') or any(part.casefold() in {'.git', '.tools', 'build', 'releases', '.gradle'} for part in path.relative_to(public).parts):
|
||||||
|
raise RuntimeError('Private/generated directory exported: ' + name)
|
||||||
|
if path.suffix.casefold() in forbidden or path.name in {'signing.properties', 'local.properties'}:
|
||||||
|
raise RuntimeError('Private/binary build input exported: ' + name)
|
||||||
|
if re.search(rb'-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----', path.read_bytes()):
|
||||||
|
raise RuntimeError('Private key marker exported: ' + name)
|
||||||
|
inventory.append({'path': name, 'bytes': path.stat().st_size, 'sha256': digest(path)})
|
||||||
|
required = ['LICENSE', 'README.md', 'README-UPSTREAM.md', 'SOURCE-PROVENANCE.json', 'Restore-Dependencies.py',
|
||||||
|
'V2rayNG/app/src/main/java/com/v2ray/ang/ucvl/UcvlAuthorizationGuard.kt',
|
||||||
|
'V2rayNG/app/src/test/java/com/v2ray/ang/ucvl/UcvlAuthorizationGuardTest.kt']
|
||||||
|
if any(not (public / name).is_file() for name in required):
|
||||||
|
raise RuntimeError('GPL source snapshot incomplete')
|
||||||
|
output = REPO / '.tools/candidates' / ('android-1.0.4-20260924-' + job.name.rsplit('-', 1)[1][:8] + '-final')
|
||||||
|
output.mkdir(parents=True, exist_ok=False)
|
||||||
|
packages = []
|
||||||
|
for package in candidate['Packages']:
|
||||||
|
signed = job / 'artifacts' / package['Name']
|
||||||
|
if digest(signed) != package['Sha256'].lower():
|
||||||
|
raise RuntimeError('Signed APK changed')
|
||||||
|
unsigned = job / 'project/app/build/outputs/apk/playstore/release' / package['Name']
|
||||||
|
with zipfile.ZipFile(unsigned) as before, zipfile.ZipFile(signed) as after:
|
||||||
|
added = set(after.namelist()) - set(before.namelist())
|
||||||
|
if before.testzip() is not None or after.testzip() is not None or set(before.namelist()) - set(after.namelist()):
|
||||||
|
raise RuntimeError('Signed APK removed or corrupted compiled entries')
|
||||||
|
if added != {'META-INF/MANIFEST.MF', 'META-INF/UCVL-ZON.RSA', 'META-INF/UCVL-ZON.SF'}:
|
||||||
|
raise RuntimeError('Unexpected entries added during signing')
|
||||||
|
for name in before.namelist():
|
||||||
|
if before.read(name) != after.read(name):
|
||||||
|
raise RuntimeError('APK entry modified after build: ' + name)
|
||||||
|
dex = {name: hashlib.sha256(after.read(name)).hexdigest() for name in after.namelist() if re.fullmatch(r'classes\d*\.dex', name)}
|
||||||
|
shutil.copy2(signed, output / signed.name)
|
||||||
|
packages.append({**package, 'DexSha256': dex, 'AllUnsignedZipEntriesPreserved': True, 'AddedSigningMetadata': sorted(added)})
|
||||||
|
|
||||||
|
save(output / 'android-1.0.4-source-inventory.json', inventory)
|
||||||
|
source_archive = output / 'UCVL-Zonghengjia-1.0.4-source.zip'
|
||||||
|
with zipfile.ZipFile(source_archive, 'x', compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
|
||||||
|
for row in inventory:
|
||||||
|
archive.write(public / row['path'], 'UCVL-Zonghengjia-1.0.4-source/' + row['path'])
|
||||||
|
with zipfile.ZipFile(source_archive) as archive:
|
||||||
|
if archive.testzip() is not None or len(archive.namelist()) != len(inventory):
|
||||||
|
raise RuntimeError('Source archive verification failed')
|
||||||
|
report = {
|
||||||
|
'Version': VERSION, 'BaseVersionCode': 10004, 'ApkVersionCode': 4010004,
|
||||||
|
'Packages': packages, 'SourceArchive': source_archive.name, 'SourceArchiveSha256': digest(source_archive),
|
||||||
|
'SourceInventorySha256': digest(output / 'android-1.0.4-source-inventory.json'), 'SourceFiles': len(inventory),
|
||||||
|
'CompiledPublicSourceInputsMatched': matched, 'LockedRestorableBinaryInputs': binary_inputs,
|
||||||
|
'BuildInputManifestSha256': digest(inputs_path), 'UnitTests': 41, 'AuthorizationLifecycleJvmTests': 10,
|
||||||
|
'OriginalCompanySignerMatches102And103': True, 'PrivateZero3Excluded': True,
|
||||||
|
'PhysicalDeviceTested': False, 'AndroidServiceBroadcastTested': False, 'EmulatorStarted': False,
|
||||||
|
'StableReleaseAccepted': False, 'Published': False,
|
||||||
|
'RemainingGates': ['Physical Android VPN permission and data path', 'Real Service/broadcast lifecycle for replaced login',
|
||||||
|
'Wi-Fi/mobile transitions, sleep/wake and recovery', 'Authenticated sustained business session'],
|
||||||
|
}
|
||||||
|
save(output / 'android-1.0.4-candidate.json', report)
|
||||||
|
shutil.copytree(job / 'evidence', output / 'local-evidence')
|
||||||
|
shutil.copytree(job / 'source-evidence-final', output / 'source-export-evidence')
|
||||||
|
with (output / 'SHA256SUMS.txt').open('x', encoding='ascii', newline='\n') as stream:
|
||||||
|
for path in sorted(output.iterdir()):
|
||||||
|
if path.is_file() and path.name != 'SHA256SUMS.txt':
|
||||||
|
stream.write(digest(path) + ' ' + path.name + '\n')
|
||||||
|
print(json.dumps({'output': str(output), 'source_files': len(inventory), 'source_sha256': digest(source_archive),
|
||||||
|
'public_compiled_inputs_matched': matched, 'packages': packages}, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('job')
|
||||||
|
main(parser.parse_args().job)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
param(
|
||||||
|
[string]$Runtime = '',
|
||||||
|
[ValidateSet('before','after','full')][string]$Phase = 'before'
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||||
|
$source = Join-Path $repo 'V2rayNG'
|
||||||
|
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
|
||||||
|
if (-not $Runtime) { $Runtime = Join-Path $tempRoot ('ucvl-auth-20260917-' + [guid]::NewGuid().ToString('N')) }
|
||||||
|
$Runtime = [IO.Path]::GetFullPath($Runtime)
|
||||||
|
if (-not $Runtime.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase) -or (Split-Path $Runtime -Leaf) -notmatch '^ucvl-auth-20260917-[a-f0-9]{32}$') {
|
||||||
|
throw 'Only a dedicated temporary ASCII build directory is allowed'
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Path $Runtime -Force | Out-Null
|
||||||
|
foreach ($name in @('gradlew','gradlew.bat','gradle.properties','settings.gradle.kts','build.gradle.kts')) {
|
||||||
|
Copy-Item -LiteralPath (Join-Path $source $name) -Destination $Runtime -Force
|
||||||
|
}
|
||||||
|
Copy-Item -LiteralPath (Join-Path $source 'gradle') -Destination $Runtime -Recurse -Force
|
||||||
|
$appTarget = Join-Path $Runtime 'app'
|
||||||
|
New-Item -ItemType Directory -Path $appTarget -Force | Out-Null
|
||||||
|
foreach ($name in @('src','libs','build.gradle.kts','proguard-rules.pro')) {
|
||||||
|
Copy-Item -LiteralPath (Join-Path $source ('app/' + $name)) -Destination $appTarget -Recurse -Force
|
||||||
|
}
|
||||||
|
$env:JAVA_HOME = Join-Path $repo '.tools/jdk-staging/jdk-21.0.12.1+1'
|
||||||
|
$env:ANDROID_HOME = Join-Path $repo '.tools/android-sdk'
|
||||||
|
if (-not (Test-Path (Join-Path $env:JAVA_HOME 'bin/java.exe'))) { throw 'Expected JDK 21 is missing' }
|
||||||
|
$log = Join-Path $repo ('acceptance/android-auth-' + $Phase + '-20260917.log')
|
||||||
|
$started = Get-Date
|
||||||
|
Push-Location $Runtime
|
||||||
|
try {
|
||||||
|
$gradleArgs = @('--offline','--no-daemon','--max-workers=2',':app:testPlaystoreReleaseUnitTest')
|
||||||
|
if ($Phase -ne 'full') { $gradleArgs += @('--tests','com.v2ray.ang.ucvl.UcvlAuthorizationGuardTest') }
|
||||||
|
& .\gradlew.bat @gradleArgs *> $log
|
||||||
|
$testExit = $LASTEXITCODE
|
||||||
|
} finally { Pop-Location }
|
||||||
|
$suites = @()
|
||||||
|
$resultDir = Join-Path $Runtime 'app/build/test-results/testPlaystoreReleaseUnitTest'
|
||||||
|
if (Test-Path $resultDir) {
|
||||||
|
foreach ($file in Get-ChildItem $resultDir -Filter 'TEST-*.xml' -File) {
|
||||||
|
if ($file.LastWriteTime -lt $started) { continue }
|
||||||
|
[xml]$xml = Get-Content -LiteralPath $file.FullName -Raw
|
||||||
|
$suites += [ordered]@{name=$xml.testsuite.name; tests=[int]$xml.testsuite.tests; failures=[int]$xml.testsuite.failures; errors=[int]$xml.testsuite.errors; skipped=[int]$xml.testsuite.skipped}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$report = [ordered]@{phase=$Phase; started_at=$started.ToString('o'); runtime=$Runtime; offline=$true; exit_code=$testExit; suites=$suites; source_copied=$true; emulator_started=$false; device_installed=$false; apk_published=$false; network_modified=$false}
|
||||||
|
$report | ConvertTo-Json -Depth 5 | Set-Content -Encoding utf8 (Join-Path $repo ('acceptance/android-auth-' + $Phase + '-20260917.json'))
|
||||||
|
$report | ConvertTo-Json -Depth 5
|
||||||
|
exit $testExit
|
||||||
Reference in New Issue
Block a user