Add Android Git update prompts and tested non-destructive upgrade
Android client acceptance / test-and-build (push) Has been cancelled
Android client acceptance / test-and-build (push) Has been cancelled
This commit is contained in:
@@ -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 源码副本重编译后全套通过,没有关闭或跳过失败测试。
|
||||
|
||||
## 已实现
|
||||
|
||||
- 公司账号登录、浏览器注册、设备授权、个人信息、套餐/额度/有效期。
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user