From 9541d066b341eb6160a49a4dd86b44c0a677f2ad Mon Sep 17 00:00:00 2001 From: UCVL Software Automation Date: Tue, 8 Sep 2026 22:44:29 +0800 Subject: [PATCH] Add Android Git update prompts and tested non-destructive upgrade --- README-UCVL.md | 6 ++ README.md | 4 +- Sign-Candidate.ps1 | 15 ++- V2rayNG/app/build.gradle.kts | 4 +- .../com/v2ray/ang/ucvl/UpgradeSmokeTest.kt | 43 ++++++++- .../com/v2ray/ang/ucvl/UcvlMainActivity.kt | 37 ++++++++ .../com/v2ray/ang/ucvl/UcvlReleaseChecker.kt | 86 ++++++++++++++++++ .../main/res/layout/activity_ucvl_main.xml | 10 ++ .../v2ray/ang/ucvl/UcvlReleaseCheckerTest.kt | 42 +++++++++ acceptance/ACCEPTANCE-1.0.3.md | 13 +++ acceptance/login-instrumentation-visual4.png | Bin 0 -> 172680 bytes acceptance/login-rendered-1.0.3.png | Bin 0 -> 173721 bytes acceptance/upgrade-1.0.3.json | 16 ++++ tools/verify_upgrade.py | 66 ++++++++++++++ 14 files changed, 333 insertions(+), 9 deletions(-) create mode 100644 V2rayNG/app/src/main/java/com/v2ray/ang/ucvl/UcvlReleaseChecker.kt create mode 100644 V2rayNG/app/src/test/java/com/v2ray/ang/ucvl/UcvlReleaseCheckerTest.kt create mode 100644 acceptance/ACCEPTANCE-1.0.3.md create mode 100644 acceptance/login-instrumentation-visual4.png create mode 100644 acceptance/login-rendered-1.0.3.png create mode 100644 acceptance/upgrade-1.0.3.json create mode 100644 tools/verify_upgrade.py diff --git a/README-UCVL.md b/README-UCVL.md index 67e5354..3986c21 100644 --- a/README-UCVL.md +++ b/README-UCVL.md @@ -2,6 +2,12 @@ 版本 `1.0.2` 候选;包名 `cn.toplc.zonghengjia`。这是公司 Android 客户端的 GPL-3.0 衍生源码,不包含服务器端、账号、节点凭据或公司签名私钥。 +## 2026-09-08:1.0.3 本地候选 + +新增启动与手动 Git 更新检查,只提示 `pro.ucvl.cn` 正式 Android Release 的通用 APK;忽略草稿/试用/其它平台,下载路径固定在公司公开安装包仓库。提示不改变 VPN,不共享平台登录令牌。用户同意后打开下载;系统要求的 APK 安装授权仍需确认,尚不是应用内静默下载安装。 + +1.0.3 的 34 项单元测试和 APK 构建通过,五种 APK 已沿用公司原证书签名,尚未上传或向员工推送。构建请使用 ASCII 路径:中文目录下本轮 Gradle 测试工作进程未能加载测试类;独立 ASCII 源码副本重编译后全套通过,没有关闭或跳过失败测试。 + ## 已实现 - 公司账号登录、浏览器注册、设备授权、个人信息、套餐/额度/有效期。 diff --git a/README.md b/README.md index 0b99868..b6675c0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # 纵横家 Android 客户端 -版本 `1.0.1` 候选;包名 `cn.toplc.zonghengjia`。这是公司 Android 客户端的 GPL-3.0 衍生源码,不包含服务器端、账号、节点凭据或公司签名私钥。 +版本 `1.0.3` 本地候选;包名 `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 验收。 ## 已实现 diff --git a/Sign-Candidate.ps1 b/Sign-Candidate.ps1 index b832832..faf9b92 100644 --- a/Sign-Candidate.ps1 +++ b/Sign-Candidate.ps1 @@ -1,6 +1,8 @@ param( [switch]$InstrumentationOnly, - [string]$Version = '1.0.2' + [ValidatePattern('^\d+\.\d+\.\d+$')][string]$Version = '1.0.3', + [string]$InputDirectory = '', + [ValidatePattern('^[a-z0-9.-]*$')][string]$TestRevision = '' ) $ErrorActionPreference = 'Stop' $androidRoot = $PSScriptRoot @@ -16,7 +18,10 @@ $apkSigner = Join-Path $androidRoot '.tools/android-sdk/build-tools/37.0.0/apksi $zipAlign = Join-Path $androidRoot '.tools/android-sdk/build-tools/37.0.0/zipalign.exe' $keyStore = Join-Path $androidRoot '.tools/signing/ucvl-zonghengjia-release.jks' try { - if ($InstrumentationOnly) { + if ($InputDirectory) { + $inputs = @(Get-ChildItem -LiteralPath ([IO.Path]::GetFullPath($InputDirectory)) -Filter '*.apk') + if (-not $InstrumentationOnly) { $inputs = @($inputs | Where-Object Name -Like "UCVL-Zonghengjia-$Version-*.apk") } + } elseif ($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-$Version-*.apk") @@ -24,7 +29,11 @@ try { if (-not $inputs.Count) { throw 'No candidate APKs were built.' } foreach ($inputFile in $inputs) { $stageFile = Join-Path $androidRoot ('.tools/signing/aligned-' + $inputFile.Name) - $outputFile = Join-Path $androidRoot ('releases/' + $inputFile.Name) + $revision = if ($TestRevision) { "-$TestRevision" } else { '' } + if ($TestRevision -and -not $InstrumentationOnly) { throw 'TestRevision is only for instrumentation artifacts' } + $outputName = if ($InstrumentationOnly) { $inputFile.BaseName + "-$Version$revision.apk" } else { $inputFile.Name } + $outputFile = Join-Path $androidRoot ('releases/' + $outputName) + if (Test-Path -LiteralPath $outputFile) { throw "Refusing to overwrite existing signed artifact: $outputName" } & $zipAlign -f -p 4 $inputFile.FullName $stageFile if ($LASTEXITCODE) { throw 'APK alignment failed.' } & $apkSigner sign --ks $keyStore --ks-key-alias $signing['keyAlias'] --ks-pass env:UCVL_ANDROID_STORE_PASS --key-pass env:UCVL_ANDROID_KEY_PASS --out $outputFile $stageFile diff --git a/V2rayNG/app/build.gradle.kts b/V2rayNG/app/build.gradle.kts index 3fbb9ae..de56b26 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 = 10002 - versionName = "1.0.2" + versionCode = 10003 + versionName = "1.0.3" multiDexEnabled = true val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';') diff --git a/V2rayNG/app/src/androidTest/java/com/v2ray/ang/ucvl/UpgradeSmokeTest.kt b/V2rayNG/app/src/androidTest/java/com/v2ray/ang/ucvl/UpgradeSmokeTest.kt index 10fbf7e..e380e71 100644 --- a/V2rayNG/app/src/androidTest/java/com/v2ray/ang/ucvl/UpgradeSmokeTest.kt +++ b/V2rayNG/app/src/androidTest/java/com/v2ray/ang/ucvl/UpgradeSmokeTest.kt @@ -11,13 +11,22 @@ import com.v2ray.ang.handler.MmkvManager import org.junit.Assert.* import org.junit.Test import org.junit.runner.RunWith +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit /** Run only in the dedicated disposable Android acceptance emulator. No production accounts. */ @RunWith(AndroidJUnit4::class) class UpgradeSmokeTest { + private fun requireEmulator() { + val descriptor = InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand("getprop ro.kernel.qemu") + val value = android.os.ParcelFileDescriptor.AutoCloseInputStream(descriptor).bufferedReader().use { it.readText().trim() } + assertEquals("Never change a physical device's account fixture", "1", value) + } @Test fun seedUpgrade() { + requireEmulator() val context = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("1.0.0", context.packageManager.getPackageInfo(context.packageName, 0).versionName) + assertEquals(InstrumentationRegistry.getArguments().getString("oldVersion") ?: "1.0.2", + context.packageManager.getPackageInfo(context.packageName, 0).versionName) UcvlSecureStore.saveToken("ucvl-upgrade-fixture-not-a-real-token") UcvlSecureStore.saveLastLogin("ucvl-upgrade-fixture") MmkvManager.encodeSettings("ucvl_test_expected_device_id", UcvlSecureStore.deviceId()) @@ -25,8 +34,10 @@ class UpgradeSmokeTest { } @Test fun verifyUpgrade() { + requireEmulator() val context = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("1.0.1", context.packageManager.getPackageInfo(context.packageName, 0).versionName) + assertEquals(InstrumentationRegistry.getArguments().getString("newVersion") ?: "1.0.3", + context.packageManager.getPackageInfo(context.packageName, 0).versionName) assertEquals("ucvl-upgrade-fixture", UcvlSecureStore.lastLogin()) assertEquals(MmkvManager.decodeSettingsString("ucvl_test_expected_device_id"), UcvlSecureStore.deviceId()) assertEquals("ucvl-upgrade-fixture-not-a-real-token", UcvlSecureStore.loadToken()) @@ -35,6 +46,7 @@ class UpgradeSmokeTest { } @Test fun loginVisibleAndEmptyFormIsSafe() { + requireEmulator() UcvlSecureStore.clearToken() val inst = InstrumentationRegistry.getInstrumentation() val activity = inst.startActivitySync(Intent(inst.targetContext, UcvlMainActivity::class.java) @@ -48,10 +60,35 @@ class UpgradeSmokeTest { assertTrue(activity.findViewById(R.id.loginError).text.contains("请输入")) } inst.waitForIdleSync() - inst.uiAutomation.takeScreenshot().useBitmap { bitmap -> + // isShown/idle alone can precede the first rendered frame (white screenshot). + val rendered = CountDownLatch(1) + inst.runOnMainSync { + activity.window.decorView.postOnAnimation { + activity.window.decorView.postOnAnimation { rendered.countDown() } + } + } + assertTrue("Login did not render two frames", rendered.await(5, TimeUnit.SECONDS)) + // SurfaceFlinger may still be displaying the launch surface after app + // frames are queued. Poll a bounded visual condition, retaining failure evidence. + fun painted(bitmap: Bitmap): Boolean { + // Sample empty right-hand background, not the white title glyphs + // at the header centre (which caused a false blank-frame result). + val header = bitmap.getPixel(bitmap.width * 9 / 10, bitmap.height / 8) + val page = bitmap.getPixel(bitmap.width * 9 / 10, bitmap.height / 2) + return android.graphics.Color.red(page) - android.graphics.Color.red(header) > 80 + } + var screenshot = inst.uiAutomation.takeScreenshot() + val deadline = android.os.SystemClock.elapsedRealtime() + 10_000 + while (!painted(screenshot) && android.os.SystemClock.elapsedRealtime() < deadline) { + screenshot.recycle() + android.os.SystemClock.sleep(200) + screenshot = inst.uiAutomation.takeScreenshot() + } + screenshot.useBitmap { bitmap -> java.io.File(inst.targetContext.filesDir, "acceptance-login.png").outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + assertTrue("Login frame is still blank after rendering deadline", painted(bitmap)) } inst.runOnMainSync { activity.finish() } } 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 fdd991c..7706b54 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 @@ -44,6 +44,9 @@ import java.util.Locale class UcvlMainActivity : AppCompatActivity() { private val api = UcvlApiClient() + private val releases = UcvlReleaseChecker() + private var checkingUpdates = false + private var availableUpdate: UcvlUpdateOffer? = null private val mainViewModel: MainViewModel by viewModels() private var profile: UcvlProfile? = null private var busy = false @@ -131,6 +134,7 @@ class UcvlMainActivity : AppCompatActivity() { showDashboardLoading() refreshProfile(token, initial = true) } + checkUpdates(manual = false) } private fun bindViews() { @@ -161,6 +165,14 @@ class UcvlMainActivity : AppCompatActivity() { } private fun setupActions() { + findViewById