Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3868e26d27 | |||
| 9541d066b3 |
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
版本 `1.0.2` 候选;包名 `cn.toplc.zonghengjia`。这是公司 Android 客户端的 GPL-3.0 衍生源码,不包含服务器端、账号、节点凭据或公司签名私钥。
|
版本 `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 客户端
|
# 纵横家 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(
|
param(
|
||||||
[switch]$InstrumentationOnly,
|
[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'
|
$ErrorActionPreference = 'Stop'
|
||||||
$androidRoot = $PSScriptRoot
|
$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'
|
$zipAlign = Join-Path $androidRoot '.tools/android-sdk/build-tools/37.0.0/zipalign.exe'
|
||||||
$keyStore = Join-Path $androidRoot '.tools/signing/ucvl-zonghengjia-release.jks'
|
$keyStore = Join-Path $androidRoot '.tools/signing/ucvl-zonghengjia-release.jks'
|
||||||
try {
|
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')
|
$inputs = @(Get-ChildItem -LiteralPath (Join-Path $androidRoot 'V2rayNG/app/build/outputs/apk/androidTest/playstore/release') -Filter '*.apk')
|
||||||
} else {
|
} else {
|
||||||
$inputs = @(Get-ChildItem -LiteralPath (Join-Path $androidRoot 'V2rayNG/app/build/outputs/apk/playstore/release') -Filter "UCVL-Zonghengjia-$Version-*.apk")
|
$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.' }
|
if (-not $inputs.Count) { throw 'No candidate APKs were built.' }
|
||||||
foreach ($inputFile in $inputs) {
|
foreach ($inputFile in $inputs) {
|
||||||
$stageFile = Join-Path $androidRoot ('.tools/signing/aligned-' + $inputFile.Name)
|
$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
|
& $zipAlign -f -p 4 $inputFile.FullName $stageFile
|
||||||
if ($LASTEXITCODE) { throw 'APK alignment failed.' }
|
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
|
& $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"
|
applicationId = "cn.toplc.zonghengjia"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 10002
|
versionCode = 10003
|
||||||
versionName = "1.0.2"
|
versionName = "1.0.3"
|
||||||
multiDexEnabled = true
|
multiDexEnabled = true
|
||||||
|
|
||||||
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
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.Assert.*
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
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. */
|
/** Run only in the dedicated disposable Android acceptance emulator. No production accounts. */
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class UpgradeSmokeTest {
|
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() {
|
@Test fun seedUpgrade() {
|
||||||
|
requireEmulator()
|
||||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
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.saveToken("ucvl-upgrade-fixture-not-a-real-token")
|
||||||
UcvlSecureStore.saveLastLogin("ucvl-upgrade-fixture")
|
UcvlSecureStore.saveLastLogin("ucvl-upgrade-fixture")
|
||||||
MmkvManager.encodeSettings("ucvl_test_expected_device_id", UcvlSecureStore.deviceId())
|
MmkvManager.encodeSettings("ucvl_test_expected_device_id", UcvlSecureStore.deviceId())
|
||||||
@@ -25,8 +34,10 @@ class UpgradeSmokeTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test fun verifyUpgrade() {
|
@Test fun verifyUpgrade() {
|
||||||
|
requireEmulator()
|
||||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
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("ucvl-upgrade-fixture", UcvlSecureStore.lastLogin())
|
||||||
assertEquals(MmkvManager.decodeSettingsString("ucvl_test_expected_device_id"), UcvlSecureStore.deviceId())
|
assertEquals(MmkvManager.decodeSettingsString("ucvl_test_expected_device_id"), UcvlSecureStore.deviceId())
|
||||||
assertEquals("ucvl-upgrade-fixture-not-a-real-token", UcvlSecureStore.loadToken())
|
assertEquals("ucvl-upgrade-fixture-not-a-real-token", UcvlSecureStore.loadToken())
|
||||||
@@ -35,6 +46,7 @@ class UpgradeSmokeTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test fun loginVisibleAndEmptyFormIsSafe() {
|
@Test fun loginVisibleAndEmptyFormIsSafe() {
|
||||||
|
requireEmulator()
|
||||||
UcvlSecureStore.clearToken()
|
UcvlSecureStore.clearToken()
|
||||||
val inst = InstrumentationRegistry.getInstrumentation()
|
val inst = InstrumentationRegistry.getInstrumentation()
|
||||||
val activity = inst.startActivitySync(Intent(inst.targetContext, UcvlMainActivity::class.java)
|
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("请输入"))
|
assertTrue(activity.findViewById<TextView>(R.id.loginError).text.contains("请输入"))
|
||||||
}
|
}
|
||||||
inst.waitForIdleSync()
|
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 {
|
java.io.File(inst.targetContext.filesDir, "acceptance-login.png").outputStream().use {
|
||||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)
|
bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)
|
||||||
}
|
}
|
||||||
|
assertTrue("Login frame is still blank after rendering deadline", painted(bitmap))
|
||||||
}
|
}
|
||||||
inst.runOnMainSync { activity.finish() }
|
inst.runOnMainSync { activity.finish() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ import java.util.Locale
|
|||||||
|
|
||||||
class UcvlMainActivity : AppCompatActivity() {
|
class UcvlMainActivity : AppCompatActivity() {
|
||||||
private val api = UcvlApiClient()
|
private val api = UcvlApiClient()
|
||||||
|
private val releases = UcvlReleaseChecker()
|
||||||
|
private var checkingUpdates = false
|
||||||
|
private var availableUpdate: UcvlUpdateOffer? = null
|
||||||
private val mainViewModel: MainViewModel by viewModels()
|
private val mainViewModel: MainViewModel by viewModels()
|
||||||
private var profile: UcvlProfile? = null
|
private var profile: UcvlProfile? = null
|
||||||
private var busy = false
|
private var busy = false
|
||||||
@@ -131,6 +134,7 @@ class UcvlMainActivity : AppCompatActivity() {
|
|||||||
showDashboardLoading()
|
showDashboardLoading()
|
||||||
refreshProfile(token, initial = true)
|
refreshProfile(token, initial = true)
|
||||||
}
|
}
|
||||||
|
checkUpdates(manual = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun bindViews() {
|
private fun bindViews() {
|
||||||
@@ -161,6 +165,14 @@ class UcvlMainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setupActions() {
|
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 {
|
findViewById<View>(R.id.registerButton).setOnClickListener {
|
||||||
openWeb("https://vpn.toplc.cn/register/")
|
openWeb("https://vpn.toplc.cn/register/")
|
||||||
}
|
}
|
||||||
@@ -468,6 +480,30 @@ class UcvlMainActivity : AppCompatActivity() {
|
|||||||
else -> exception.message?.takeIf { it.isNotBlank() } ?: "网络连接失败,请检查当前网络。"
|
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) {
|
private fun openWeb(url: String) {
|
||||||
try { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }
|
try { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }
|
||||||
catch (_: android.content.ActivityNotFoundException) {
|
catch (_: android.content.ActivityNotFoundException) {
|
||||||
@@ -491,6 +527,7 @@ class UcvlMainActivity : AppCompatActivity() {
|
|||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
unregisterReceiver(trafficReceiver)
|
unregisterReceiver(trafficReceiver)
|
||||||
api.close()
|
api.close()
|
||||||
|
releases.close()
|
||||||
super.onDestroy()
|
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:textColor="@color/ucvl_muted"
|
||||||
android:textSize="12sp" />
|
android:textSize="12sp" />
|
||||||
</LinearLayout>
|
</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
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/openSourceButton"
|
android:id="@+id/openSourceButton"
|
||||||
style="@style/Widget.MaterialComponents.Button.TextButton"
|
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,状态复核于 2026-09-09。未发布 1.0.3 安装包、未通知员工;对应 GPL 源码和验收记录已同步公司公共 Git 的 `codex/overnight-20260908` 维护分支,尚未合并正式分支。源码同步不代表真机 VPN 验收完成。
|
||||||
|
|
||||||
|
- 公司 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