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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user