4 Commits

Author SHA1 Message Date
song 648c63bfbc refactor: use plaintext Gitea URL instead of XOR+Base64 scramble
- Gitea URL hardcoded as GITEA_API constant (no more release-notes discovery)
- Removed parseUpdateSource / unscramble / SCRAMBLE_KEY
- Release notes no longer need update-source comment
- version 1.9.4 -> 1.9.5 (code 33 -> 34)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 21:44:37 +08:00
song 00bdbe2d1e chore: bump to 1.9.4 + Gitea URL -> gitea.songer.eu.org
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:47:54 +08:00
song a3f67a0a7c fix: download timeout + file validation + source indicator + stale APK
Root cause of "install doesn't update": gradle cached old APK, release
uploaded stale version. Fixes:
- Dedicated download OkHttpClient with 120s readTimeout (was 30s)
- 64KB buffer (was 8KB) for faster large-file download
- Post-download file size validation (Content-Length / release declared)
- Reject files < 1MB (likely HTML error pages)
- downloadSource StateFlow shows "Gitea" or "GitHub" during download
- UpdateDialog shows "正在下载更新…(来自 Gitea)"
- version 1.9.2 -> 1.9.3 (code 31 -> 32)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 19:17:53 +08:00
song 6e29c292ec fix: download fallback URL when primary fails
- Gitea URL used as primary (same-version tie-break), GitHub as fallback
- download() tries base url first; if it fails, automatically retries
  the fallback URL
- version 1.9.1 -> 1.9.2 (code 30 -> 31)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:42:51 +08:00
3 changed files with 68 additions and 38 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.a2i.forwarder" applicationId = "com.a2i.forwarder"
minSdk = 34 minSdk = 34
targetSdk = 36 targetSdk = 36
versionCode = 29 versionCode = 34
versionName = "1.9.0" versionName = "1.9.5"
} }
signingConfigs { signingConfigs {
@@ -26,6 +26,7 @@ data class UpdateInfo(
val apkSize: Long, val apkSize: Long,
val releaseNotes: String, val releaseNotes: String,
val releasePageUrl: String, val releasePageUrl: String,
val fallbackUrl: String? = null, // 主 URL 下载失败时尝试的备选链接
) )
/** /**
@@ -44,6 +45,7 @@ class UpdateChecker(private val context: Context) {
val updateInfo = MutableStateFlow<UpdateInfo?>(null) val updateInfo = MutableStateFlow<UpdateInfo?>(null)
val downloading = MutableStateFlow(false) val downloading = MutableStateFlow(false)
val downloadProgress = MutableStateFlow(0) val downloadProgress = MutableStateFlow(0)
val downloadSource = MutableStateFlow<String?>(null) // "Gitea" / "GitHub" / null
val lastError = MutableStateFlow<String?>(null) val lastError = MutableStateFlow<String?>(null)
private var lastNotifiedVersion: String? = null // 记录已推送的版本,避免重复通知 private var lastNotifiedVersion: String? = null // 记录已推送的版本,避免重复通知
@@ -68,16 +70,15 @@ class UpdateChecker(private val context: Context) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (throttle && now - settings.lastUpdateCheckTime.value < CHECK_INTERVAL_MS) return@runCatching updateInfo.value if (throttle && now - settings.lastUpdateCheckTime.value < CHECK_INTERVAL_MS) return@runCatching updateInfo.value
// 先查 GitHub(公开默认源),从其 release notes 动态发现额外源(如自建 Gitea),取最高版本 // 双源查询:Gitea(国内优先)+ GitHub(备用),取最高版本
// 同版本时 Gitea 优先(国内可达,GitHub 可能被墙导致下载失败) // 同版本时 Gitea 优先(国内可达,GitHub 可能被墙导致下载失败)
val infos = mutableListOf<UpdateInfo>() val infos = mutableListOf<UpdateInfo>()
val gh = runCatching { fetchRelease(GITHUB_API) }.getOrNull() var ghInfo: UpdateInfo? = null
if (gh != null) { var giteaInfo: UpdateInfo? = null
val giteaInfo = parseUpdateSource(gh.releaseNotes)?.let { runCatching { fetchRelease(it) }.getOrNull() } giteaInfo = runCatching { fetchRelease(GITEA_API) }.getOrNull()
// Gitea 放前面,同版本时优先用它下载(国内可达)
if (giteaInfo != null) infos.add(giteaInfo) if (giteaInfo != null) infos.add(giteaInfo)
infos.add(gh) ghInfo = runCatching { fetchRelease(GITHUB_API) }.getOrNull()
} if (ghInfo != null) infos.add(ghInfo)
settings.setLastUpdateCheck(now) settings.setLastUpdateCheck(now)
if (infos.isEmpty()) { if (infos.isEmpty()) {
lastError.value = "所有更新源不可达" lastError.value = "所有更新源不可达"
@@ -92,7 +93,9 @@ class UpdateChecker(private val context: Context) {
updateInfo.value = null updateInfo.value = null
lastNotifiedVersion = null lastNotifiedVersion = null
} else { } else {
updateInfo.value = best // 主 URL 下载失败时尝试另一个源的链接
val fallback = if (best == giteaInfo) ghInfo?.apkUrl else giteaInfo?.apkUrl
updateInfo.value = best.copy(fallbackUrl = fallback)
} }
lastError.value = null lastError.value = null
@@ -178,18 +181,49 @@ class UpdateChecker(private val context: Context) {
suspend fun download(info: UpdateInfo): File? = withContext(Dispatchers.IO) { suspend fun download(info: UpdateInfo): File? = withContext(Dispatchers.IO) {
downloading.value = true downloading.value = true
downloadProgress.value = 0 downloadProgress.value = 0
downloadSource.value = null
lastError.value = null lastError.value = null
try { // 主 URL (Gitea) 失败时自动尝试备选 URL (GitHub)
val req = Request.Builder().url(info.apkUrl).get().build() val sources = listOf(
http.newCall(req).execute().use { res -> info.apkUrl to sourceLabel(info.apkUrl),
if (!res.isSuccessful) { lastError.value = "HTTP ${res.code}"; return@use null } ) + (info.fallbackUrl?.let { listOf(it to sourceLabel(it)) } ?: emptyList())
val body = res.body ?: run { lastError.value = "空响应"; return@use null } for ((idx, pair) in sources.withIndex()) {
val (url, label) = pair
if (idx > 0) { downloadProgress.value = 0; downloadSource.value = null }
downloadSource.value = label
val file = tryDownload(url, info.latestVersion, info.apkSize)
if (file != null) {
downloading.value = false
downloadSource.value = null
return@withContext file
}
}
downloading.value = false
downloadSource.value = null
null
}
private fun sourceLabel(url: String): String =
if (url.contains("songer.eu.org") || url.contains("sunlunfan")) "Gitea" else "GitHub"
private suspend fun tryDownload(url: String, version: String, expectedSize: Long): File? {
// 专用下载 client:更长超时(120s),避免大文件慢网超时截断
val dlClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
return try {
val req = Request.Builder().url(url).get().build()
dlClient.newCall(req).execute().use { res ->
if (!res.isSuccessful) { lastError.value = "HTTP ${res.code}"; return null }
val body = res.body ?: run { lastError.value = "空响应"; return null }
val total = body.contentLength() val total = body.contentLength()
val dir = context.getExternalFilesDir(null) ?: context.cacheDir val dir = context.getExternalFilesDir(null) ?: context.cacheDir
val target = File(dir, "gotmsg-${info.latestVersion}.apk") val target = File(dir, "gotmsg-${version}.apk")
target.outputStream().use { sink -> target.outputStream().use { sink ->
val input = body.byteStream() val input = body.byteStream()
val buf = ByteArray(8 * 1024) val buf = ByteArray(64 * 1024) // 64KB buffer 提升大文件速度
var read = 0L var read = 0L
while (true) { while (true) {
val n = input.read(buf) val n = input.read(buf)
@@ -199,14 +233,25 @@ class UpdateChecker(private val context: Context) {
if (total > 0) downloadProgress.value = (read * 100 / total).toInt() if (total > 0) downloadProgress.value = (read * 100 / total).toInt()
} }
} }
// 校验文件大小:Content-Length 或 release 声明的大小
val actualSize = target.length()
val refSize = if (total > 0) total else expectedSize
if (refSize > 0 && actualSize < refSize) {
lastError.value = "文件不完整(${actualSize}/${refSize} 字节),请重试"
target.delete()
return null
}
if (actualSize < 1_000_000) {
lastError.value = "文件过小,可能下载了错误页面"
target.delete()
return null
}
downloadProgress.value = 100 downloadProgress.value = 100
target target
} }
} catch (e: Exception) { } catch (e: Exception) {
lastError.value = e.message ?: "下载失败" lastError.value = e.message ?: "下载失败"
null null
} finally {
downloading.value = false
} }
} }
@@ -254,26 +299,10 @@ class UpdateChecker(private val context: Context) {
return false return false
} }
/** 从 release notes 的 HTML 注释里解析额外更新源(加扰编码,兼容 Base64/明文)。 */
private fun parseUpdateSource(body: String): String? {
val raw = Regex("""update-source:\s*(\S+)""").find(body)?.groupValues?.getOrNull(1) ?: return null
// 加扰格式:Base64(反转(XOR(明文, key)))。尝试完整解扰,失败则依次尝试 Base64/明文。
val b64 = runCatching { java.util.Base64.getDecoder().decode(raw) }.getOrNull() ?: return raw
val unscrambled = unscramble(b64)
return unscrambled ?: runCatching { String(b64, Charsets.UTF_8) }.getOrNull() ?: raw
}
/** 解扰:反转字节 → XOR 每字节 → UTF-8 字符串。 */
private fun unscramble(data: ByteArray): String? = runCatching {
val key = SCRAMBLE_KEY
val reversed = data.reversedArray()
String(ByteArray(reversed.size) { i -> ((reversed[i].toInt() and 0xFF) xor (key[i % key.size].toInt() and 0xFF)).toByte() }, Charsets.UTF_8)
}.getOrNull()
companion object { companion object {
const val GITHUB_API = "https://api.github.com/repos/lsxf/a2i/releases/latest" const val GITHUB_API = "https://api.github.com/repos/lsxf/a2i/releases/latest"
const val GITEA_API = "https://gitea.songer.eu.org/api/v1/repos/song/a2i/releases/latest"
const val CHECK_INTERVAL_MS = 24L * 60 * 60 * 1000 const val CHECK_INTERVAL_MS = 24L * 60 * 60 * 1000
private val SCRAMBLE_KEY = byteArrayOf(0x4B, 0x6F, 0x72, 0x6E) // "Korn"
} }
fun formatSize(bytes: Long): String { fun formatSize(bytes: Long): String {
@@ -37,6 +37,7 @@ fun UpdateDialog(info: UpdateInfo, onDismiss: () -> Unit) {
val app = A2iApp.instance val app = A2iApp.instance
val downloading by app.updateChecker.downloading.collectAsState() val downloading by app.updateChecker.downloading.collectAsState()
val progress by app.updateChecker.downloadProgress.collectAsState() val progress by app.updateChecker.downloadProgress.collectAsState()
val downloadSource by app.updateChecker.downloadSource.collectAsState()
var downloadedFile by remember { mutableStateOf<File?>(null) } var downloadedFile by remember { mutableStateOf<File?>(null) }
AlertDialog( AlertDialog(
@@ -70,7 +71,7 @@ fun UpdateDialog(info: UpdateInfo, onDismiss: () -> Unit) {
) )
Spacer(Modifier.size(4.dp)) Spacer(Modifier.size(4.dp))
Text( Text(
"正在下载更新…", "正在下载更新…${downloadSource?.let { "(来自 $it" } ?: ""}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )