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
+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")
}
}