Add Android Git update prompts and tested non-destructive upgrade
Android client acceptance / test-and-build (push) Has been cancelled

This commit is contained in:
UCVL Software Automation
2026-09-08 22:44:29 +08:00
parent 261dde435b
commit 9541d066b3
14 changed files with 333 additions and 9 deletions
+6
View File
@@ -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 源码副本重编译后全套通过,没有关闭或跳过失败测试。
## 已实现
- 公司账号登录、浏览器注册、设备授权、个人信息、套餐/额度/有效期。
+3 -1
View File
@@ -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 验收。
## 已实现
+12 -3
View File
@@ -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
+2 -2
View File
@@ -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(';')
@@ -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<TextView>(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() }
}
@@ -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<Button>(R.id.checkUpdatesButton).setOnClickListener {
val offer = availableUpdate
if (offer == null) checkUpdates(manual = true)
else MaterialAlertDialogBuilder(this).setTitle("发现新版本 ${offer.version}")
.setMessage("从公司 Git 下载并使用系统安装器覆盖升级,保留账号和配置。不要先卸载旧版本。Android 要求的安装授权仍需本人确认。")
.setPositiveButton("前往下载") { _, _ -> openWeb(offer.url) }
.setNegativeButton("暂不更新", null).show()
}
findViewById<View>(R.id.registerButton).setOnClickListener {
openWeb("https://vpn.toplc.cn/register/")
}
@@ -468,6 +480,30 @@ class UcvlMainActivity : AppCompatActivity() {
else -> exception.message?.takeIf { it.isNotBlank() } ?: "网络连接失败,请检查当前网络。"
}
private fun checkUpdates(manual: Boolean) {
if (checkingUpdates) return
checkingUpdates = true
val button = findViewById<Button>(R.id.checkUpdatesButton)
val status = findViewById<TextView>(R.id.updateStatus)
button.isEnabled = false
if (manual) status.text = "正在检查公司 Git…"
lifecycleScope.launch {
try {
availableUpdate = releases.latest(BuildConfig.VERSION_NAME)
val offer = availableUpdate
button.text = offer?.let { "下载新版本 ${it.version}" } ?: "检查软件更新"
status.text = if (offer != null) "新版本已发布,可覆盖升级保留配置" else if (manual) "暂无更新的 Android 正式版" else ""
} catch (cancelled: kotlinx.coroutines.CancellationException) {
throw cancelled
} catch (_: Exception) {
if (manual) status.text = "暂未取得版本信息,VPN 连接不受影响"
} finally {
checkingUpdates = false
button.isEnabled = true
}
}
}
private fun openWeb(url: String) {
try { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }
catch (_: android.content.ActivityNotFoundException) {
@@ -491,6 +527,7 @@ class UcvlMainActivity : AppCompatActivity() {
override fun onDestroy() {
unregisterReceiver(trafficReceiver)
api.close()
releases.close()
super.onDestroy()
}
@@ -0,0 +1,86 @@
package com.v2ray.ang.ucvl
import com.google.gson.JsonParser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.Closeable
import java.io.IOException
import java.net.Proxy
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
data class UcvlUpdateOffer(val version: String, val url: String, val size: Long)
/** Anonymous Git lookups never share platform credentials or control the VPN. */
class UcvlReleaseChecker : Closeable {
private val client = OkHttpClient.Builder().proxy(Proxy.NO_PROXY)
.followRedirects(false).followSslRedirects(false)
.connectTimeout(10, TimeUnit.SECONDS).callTimeout(20, TimeUnit.SECONDS).build()
suspend fun latest(current: String): UcvlUpdateOffer? = withContext(Dispatchers.IO) {
client.newCall(Request.Builder().url(RELEASE_API).header("Accept", "application/json").get().build())
.execute().use { response ->
if (!response.isSuccessful) throw IOException("暂时无法读取公司 Git 版本列表。")
val body = response.body
if (body.contentLength() > MAX_METADATA) throw IOException("版本列表过大。")
// Bound decoded body size as well, including chunked/gzip responses.
val source = body.source()
source.request(MAX_METADATA + 1)
if (source.buffer.size > MAX_METADATA) throw IOException("版本列表过大。")
select(source.readUtf8(), current)
}
}
override fun close() {
client.dispatcher.cancelAll()
cleanup.execute {
client.connectionPool.evictAll()
client.dispatcher.executorService.shutdown()
}
}
companion object {
const val RELEASE_API = "https://pro.ucvl.cn/api/v1/repos/admin/ucvl-software-downloads/releases?limit=50"
private const val DOWNLOAD_ROOT = "https://pro.ucvl.cn/admin/ucvl-software-downloads/releases/download/"
private const val MAX_METADATA = 1024L * 1024
private val tagPattern = Regex("^android-v(\\d+\\.\\d+\\.\\d+)$")
private val versionPattern = Regex("^(\\d+)\\.(\\d+)\\.(\\d+)$")
private val cleanup = Executors.newSingleThreadExecutor { work ->
Thread(work, "UCVL-release-cleanup").apply { isDaemon = true }
}
private fun parts(version: String): List<Int> = versionPattern.matchEntire(version)?.groupValues
?.drop(1)?.map { it.toIntOrNull() ?: throw IOException("无效版本号。") }
?: throw IOException("无效版本号。")
private fun compare(left: List<Int>, right: List<Int>): Int {
for (index in 0..2) if (left[index] != right[index]) return left[index].compareTo(right[index])
return 0
}
internal fun select(json: String, current: String): UcvlUpdateOffer? {
val installed = parts(current.substringBefore('-').substringBefore('+'))
val releases = JsonParser.parseString(json).asJsonArray.map { it.asJsonObject }
.filter { it.get("draft")?.asBoolean == false && it.get("prerelease")?.asBoolean == false }
.mapNotNull { release ->
tagPattern.matchEntire(release.get("tag_name")?.asString.orEmpty())?.let { match ->
Triple(release, match.groupValues[1], parts(match.groupValues[1]))
}
}
val selected = releases.maxWithOrNull { left, right -> compare(left.third, right.third) } ?: return null
if (compare(selected.third, installed) <= 0) return null
val version = selected.second
val filename = "UCVL-Zonghengjia-$version-android-universal.apk"
// Universal is an explicit cross-ABI package; never offer an arbitrary
// first attachment, a Windows EXE, or a different publisher's APK.
val assets = selected.first.getAsJsonArray("assets").map { it.asJsonObject }
.filter { it.get("name")?.asString == filename }
if (assets.size != 1) throw IOException("正式版本缺少唯一的 Android 通用安装包。")
val size = assets.single().get("size")?.asLong ?: 0
if (size <= 0 || size > 250L * 1024 * 1024) throw IOException("安装包大小异常。")
return UcvlUpdateOffer(version, "$DOWNLOAD_ROOT${selected.first.get("tag_name").asString}/$filename", size)
}
}
}
@@ -381,6 +381,16 @@
android:textColor="@color/ucvl_muted"
android:textSize="12sp" />
</LinearLayout>
<TextView
android:id="@+id/updateStatus"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:gravity="center" android:textSize="12sp" android:paddingHorizontal="20dp"
android:textColor="@color/ucvl_muted" />
<com.google.android.material.button.MaterialButton
android:id="@+id/checkUpdatesButton"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="match_parent" android:layout_height="48dp"
android:text="检查软件更新" />
<com.google.android.material.button.MaterialButton
android:id="@+id/openSourceButton"
style="@style/Widget.MaterialComponents.Button.TextButton"
@@ -0,0 +1,42 @@
package com.v2ray.ang.ucvl
import org.junit.Assert.*
import org.junit.Test
class UcvlReleaseCheckerTest {
private fun fixture(version: String = "1.0.3", tag: String = "android-v$version", draft: Boolean = false,
preview: Boolean = false, size: Long = 30000000, file: String = "UCVL-Zonghengjia-$version-android-universal.apk") =
"""[{"tag_name":"$tag","draft":$draft,"prerelease":$preview,"assets":[
{"name":"$file","size":$size,"browser_download_url":"https://example.invalid/unsafe.apk"}]}]"""
@Test fun usesOnlyCompanyGitCanonicalDownloadAndUniversalApk() {
val offer = UcvlReleaseChecker.select(fixture(), "1.0.2")!!
assertEquals("1.0.3", offer.version)
assertEquals("https://pro.ucvl.cn/admin/ucvl-software-downloads/releases/download/android-v1.0.3/UCVL-Zonghengjia-1.0.3-android-universal.apk", offer.url)
assertEquals(30000000L, offer.size)
}
@Test fun skipsDraftAndPreviewAndOtherPlatforms() {
assertNull(UcvlReleaseChecker.select(fixture(draft = true), "1.0.2"))
assertNull(UcvlReleaseChecker.select(fixture(preview = true), "1.0.2"))
assertNull(UcvlReleaseChecker.select(fixture(tag = "windows-v9.0.0"), "1.0.2"))
}
@Test fun comparesVersionsNumericallyWithoutDowngrades() {
assertNull(UcvlReleaseChecker.select(fixture(), "1.0.3"))
assertNull(UcvlReleaseChecker.select(fixture(), "1.0.4"))
assertNotNull(UcvlReleaseChecker.select(fixture(version = "1.0.10"), "1.0.9"))
assertNotNull(UcvlReleaseChecker.select(fixture(), "1.0.2+debug"))
assertNull(UcvlReleaseChecker.select("[]", "1.0.2"))
}
@Test(expected = java.io.IOException::class) fun rejectsMissingUniversal() {
UcvlReleaseChecker.select(fixture(file = "UCVL-Zonghengjia-1.0.3-android-arm64-v8a.apk"), "1.0.2")
}
@Test(expected = java.io.IOException::class) fun rejectsOversizedPackage() {
UcvlReleaseChecker.select(fixture(size = 251L * 1024 * 1024), "1.0.2")
}
@Test(expected = java.io.IOException::class) fun rejectsEmptyPackage() {
UcvlReleaseChecker.select(fixture(size = 0), "1.0.2")
}
@Test(expected = java.io.IOException::class) fun rejectsTraversalAttachment() {
UcvlReleaseChecker.select(fixture(file = "../unsafe.apk"), "1.0.2")
}
}
+13
View File
@@ -0,0 +1,13 @@
# Android 1.0.3 本地候选验收
时间:2026-09-08。未发布、未通知员工,未同步公共 GPL 对应源码。
- 公司 Git 正式版检查:独立无凭据 HTTP 客户端,忽略草稿/试用版,只允许规范的公司 Git 下载链接;失败不停止 VPN。更新由用户确认后浏览器下载,尚不是应用内自动安装。
- 34 项 JVM 单元测试通过,包括 7 项更新解析测试。Windows 中文工程路径导致 Gradle 测试加载失败,采用独立 ASCII 构建副本后全量通过,未忽略失败的测试。
- 1.0.3 五种 APK 已按原公司证书签名,签名 v2/v3 检查通过;旧 1.0.2 与新包证书一致。
- 专用 Android 模拟器 `ucvl_acceptance` 上:覆盖安装 1.0.2 → 1.0.3,保留合成会话、登录名与设备标识;随后清除合成会话,验证登录输入控件及空提交提示。证据 `upgrade-1.0.3.json`。没有对实体手机操作,没有登录生产账号,没有修改主机网络。
- 初始测试截屏在绘制前呈白屏,不能作为视觉验收。随后实际重新启动应用并等待界面稳定,`login-rendered-1.0.3.png` 已复核:登录/注册/更新入口完整显示,无截断。手动检查更新实际访问公司 Git 并显示“暂无更新的 Android 正式版”(服务器目前只有试用版),记录 `update-ui-1.0.3.xml`。
- 增强后的截图测试包 visual4 已重新编译、同证书安装并执行通过,`login-instrumentation-visual4.png` 保存实际登录及空提交错误提示。visual2/3 的截图断言曾失败;复核图像发现 visual3 已正常绘制,是采样落在白色标题字形导致误判,已改为检查右侧无文字的深浅背景对比并保留失败证据。这不改变 APK 本体。
- 未完成:真实手机 VPN 授权、所有节点连接/切换、休眠/换网/长连接、Codex 认证业务、断线恢复和更新下载→系统安装流程。不能以模拟器安装测试替代。
Universal APK SHA256:`866d17bc303aa9cbc71b71c954ddd1f3ed50251326fc035a9e1d140ff230ee66`。
Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

+16
View File
@@ -0,0 +1,16 @@
{
"timestamp": "2026-09-08T22:13:37.462276+08:00",
"serial": "emulator-5554",
"old_version": "1.0.2",
"new_version": "1.0.3",
"passed": true,
"physical_device_tested": false,
"host_network_modified": false,
"authenticated_vpn_connection_tested": false,
"steps": [
"seed_synthetic_session_on_1.0.2",
"cover_upgrade_preserves_encrypted_session_login_and_device_id",
"login_visible_empty_submission_safe_fixture_cleared"
],
"apk_sha256": "866d17bc303aa9cbc71b71c954ddd1f3ed50251326fc035a9e1d140ff230ee66"
}
+66
View File
@@ -0,0 +1,66 @@
"""Same-certificate upgrade test on our dedicated emulator; never a phone.
Seeds only a synthetic non-authenticating session. Does not alter host networking
or request a production account. Run after building/signing the test APK.
"""
import datetime
import hashlib
import json
from pathlib import Path
import subprocess
ROOT = Path(__file__).resolve().parents[1]
ADB = ROOT / ".tools/android-sdk/platform-tools/adb.exe"
PACKAGE = "cn.toplc.zonghengjia"
SERIAL = "emulator-5554"
def adb(*args, timeout=60):
return subprocess.run([str(ADB), "-s", SERIAL, *map(str, args)], capture_output=True,
text=True, encoding="utf-8", errors="replace", timeout=timeout,
check=True, creationflags=subprocess.CREATE_NO_WINDOW).stdout
def instrument(method):
result = adb("shell", "am", "instrument", "-w", "-r", "-e", "oldVersion", "1.0.2",
"-e", "newVersion", "1.0.3", "-e", "class",
"com.v2ray.ang.ucvl.UpgradeSmokeTest#" + method,
PACKAGE + ".test/androidx.test.runner.AndroidJUnitRunner", timeout=90)
if "OK (1 test)" not in result:
raise RuntimeError("Instrumentation failed: " + result[-3000:])
def main():
assert adb("shell", "getprop", "ro.kernel.qemu").strip() == "1", "Dedicated emulator only"
assert "ucvl_acceptance" in adb("emu", "avd", "name"), "Unexpected emulator image"
assert adb("shell", "getprop", "sys.boot_completed").strip() == "1", "Emulator has not booted"
old = ROOT / "releases/UCVL-Zonghengjia-1.0.2-android-universal.apk"
new = ROOT / "releases/UCVL-Zonghengjia-1.0.3-android-universal.apk"
test = ROOT / "releases/app-playstore-release-androidTest-1.0.3.apk"
assert all(path.is_file() for path in [old, new, test])
evidence = ROOT / "acceptance/upgrade-1.0.3.json"
report = {"timestamp": datetime.datetime.now().astimezone().isoformat(), "serial": SERIAL,
"old_version": "1.0.2", "new_version": "1.0.3", "passed": False,
"physical_device_tested": False, "host_network_modified": False,
"authenticated_vpn_connection_tested": False, "steps": []}
try:
adb("shell", "am", "force-stop", PACKAGE)
assert "Success" in adb("install", "-r", old)
assert "Success" in adb("install", "-r", test)
instrument("seedUpgrade")
report["steps"].append("seed_synthetic_session_on_1.0.2")
assert "Success" in adb("install", "-r", new)
instrument("verifyUpgrade")
report["steps"].append("cover_upgrade_preserves_encrypted_session_login_and_device_id")
instrument("loginVisibleAndEmptyFormIsSafe")
report["steps"].append("login_visible_empty_submission_safe_fixture_cleared")
report["apk_sha256"] = hashlib.sha256(new.read_bytes()).hexdigest()
report["passed"] = True
finally:
adb("shell", "am", "force-stop", PACKAGE)
evidence.write_text(json.dumps(report, indent=2), encoding="utf-8")
print(json.dumps(report))
if __name__ == "__main__":
main()