diff --git a/AGENTS.md b/AGENTS.md index d49ecb2..aaeddd0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,16 @@ git push origin master # origin = Gitea(唯一的代码同步目 App 内部两处端点已指向 Gitea(随本版 APK 生效): -- `AppUpdateManager.kt`:`gitea.kasugano.cn/api/v1/repos/zijunchiang/maidong-ktv/releases/latest`(App 内检查更新) +- `AppUpdateManager.kt`(**2026-10 起为多源,Gitee 优先、Gitea 兜底**): + - 检查更新:`https://gitee.com/api/v5/repos/zijunchiang/maidong-ktv/releases/latest`、 + `https://gitea.kasugano.cn/api/v1/repos/zijunchiang/maidong-ktv/releases/latest` + —— **两个源都查**,取**版本更高**的那个(避免某一边滞后时漏更新); + - 下载安装包:按「**Gitee → Gitea**」顺序尝试,一个失败自动换下一个(同版本两个地址互为镜像); + - Gitee 的 Release API **无需令牌**即可读取(已实测),附件直链为 302 跳转到 + `foruda.gitee.com` CDN,`instanceFollowRedirects=true` 可正常跟随(实测 22MB / 约 12s / md5 一致); + - ⚠️ **只认以 `.apk` 结尾的附件**:Gitee 的 `assets` 里还会带自动生成的源码包 + (`v1.5.5.zip` / `v1.5.5.tar.gz`),宽松判断会把源码包当安装包下载; + - 下载校验三条:HTTP 2xx、>1MB、**文件头 `PK`**,再用 PackageManager 复核包名。 - `KtvJsBridge.kt`:`gitea.kasugano.cn/zijunchiang/maidong-ktv/raw/branch/master/app/src/main/assets/mobile/ktv_api.js`(JS 热更新) > 注意:Gitea 的 raw 规范路径是 `/raw/branch/<分支>/<文件>`;`/raw/<分支>/<文件>` 会 303 跳转。 @@ -186,7 +195,7 @@ $BT/apksigner verify 目标名.apk ### 版本号 -- `app/build.gradle`:`versionName "1.5.5"` / `versionCode 37`(18~21 被本机临时诊断包占用过) +- `app/build.gradle`:`versionName "1.5.6"` / `versionCode 38`(1.5.5/37 已发布;1.5.6 为"更新源加 Gitee"这一改动的版本号,尚未打包)(18~21 被本机临时诊断包占用过) - App 内显示统一用纯版本号(不加 `(versionCode)` 括号) - ⚠️ **发布构建已开启 NewApi 闸门**(`lint { checkReleaseBuilds true; fatal += ['NewApi'] }`): 任何「调用了高于 `minSdk 21` 且未做版本守卫的 API」都会让 `assembleRelease` 直接失败。 diff --git a/app/build.gradle b/app/build.gradle index 79f653a..75501de 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "com.local.ktv" minSdk 21 targetSdk 25 - versionCode 37 - versionName "1.5.5" + versionCode 38 + versionName "1.5.6" ndk { // 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版 // 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则 diff --git a/app/src/main/java/com/local/ktv/AppUpdateManager.kt b/app/src/main/java/com/local/ktv/AppUpdateManager.kt index 1e72e34..f6d3578 100644 --- a/app/src/main/java/com/local/ktv/AppUpdateManager.kt +++ b/app/src/main/java/com/local/ktv/AppUpdateManager.kt @@ -11,50 +11,82 @@ import java.io.FileOutputStream import java.net.HttpURLConnection import java.net.URL -/** Gitea Release based in-app updater. */ +/** + * Release based in-app updater. + * + * **多更新源,Gitee 优先、Gitea 兜底**(2026-10 新增): + * - 检查更新:按 [SOURCES] 顺序逐个查(Gitee 在前),**取版本最高的那个**—— + * 两个源都发时不会因为某一边滞后而漏掉新版本; + * - 下载安装包:按「Gitee → Gitea」顺序尝试,一个失败自动换下一个; + * 同版本、不同源的下载地址互为镜像([Release.mirrors])。 + * + * 为什么 Gitee 优先:国内直连速度快(实测 22MB 约 12s);Gitea 作为兜底。 + * 两个源的 Release 都带 APK 附件,地址形态同族(`/releases/download//<文件>`)。 + */ object AppUpdateManager { - private const val LATEST_RELEASE_API = - "https://gitea.kasugano.cn/api/v1/repos/zijunchiang/maidong-ktv/releases/latest" + private data class Source(val name: String, val latestApi: String) + + /** 更新源,**顺序即优先级**(Gitee 优先下载)。 */ + private val SOURCES = listOf( + Source("gitee", "https://gitee.com/api/v5/repos/zijunchiang/maidong-ktv/releases/latest"), + Source("gitea", "https://gitea.kasugano.cn/api/v1/repos/zijunchiang/maidong-ktv/releases/latest"), + ) data class Release( val version: String, val title: String, val notes: String, + /** 首选下载地址(Gitee)。 */ val apkUrl: String, val apkName: String, + /** 备用下载地址(同版本的其它更新源),下载失败时按顺序自动重试。 */ + val mirrors: List = emptyList(), ) fun check(currentVersion: String, result: (Result) -> Unit) { Thread({ result(runCatching { - val json = getText(LATEST_RELEASE_API) - val root = JSONObject(json) - val version = root.optString("tag_name").trim().removePrefix("v") - val assets = root.optJSONArray("assets") - var apkUrl = "" - var apkName = "" - if (assets != null) { - for (index in 0 until assets.length()) { - val asset = assets.optJSONObject(index) ?: continue - val name = asset.optString("name") - val url = asset.optString("browser_download_url") - if (name.endsWith(".apk", ignoreCase = true) || url.contains(".apk", ignoreCase = true)) { - apkName = name.ifBlank { "maidong-ktv-$version.apk" } - apkUrl = url - break + val candidates = ArrayList() + var lastError: Throwable? = null + for (source in SOURCES) { + // 单个源失败不影响其它源:记一条 WARN 继续。 + val release = runCatching { fetchLatest(source) } + .onFailure { + lastError = it + MiliLog.warn("UPDATE 检查失败 源=${source.name} ${it.javaClass.simpleName}: ${it.message}") } - } + .getOrNull() + if (release == null) continue + MiliLog.info("UPDATE 检查 源=${source.name} 版本=${release.version}") + candidates.add(release) } - if (version.isBlank() || apkUrl.isBlank() || compareVersions(version, currentVersion) <= 0) null - else Release( - version = version, - title = root.optString("name").ifBlank { "麦动 KTV $version" }, - notes = root.optString("body"), - apkUrl = apkUrl, - apkName = apkName, + if (candidates.isEmpty()) { + throw lastError ?: IllegalStateException("检查更新失败:所有更新源均不可用") + } + // 取版本最高的候选(手写循环,避免用到 API 24+ 的 Comparator 默认方法)。 + var newest = candidates[0] + for (item in candidates) { + if (compareVersions(item.version, newest.version) > 0) newest = item + } + if (newest.version.isBlank() || compareVersions(newest.version, currentVersion) <= 0) { + return@runCatching null + } + // 同版本的地址按源优先级合并:Gitee 在最前(candidates 就是按 SOURCES 顺序构建的)。 + val urls = LinkedHashSet() + for (item in candidates) { + if (item.version == newest.version && item.apkUrl.isNotBlank()) urls.add(item.apkUrl) + } + val ordered = urls.toList() + Release( + version = newest.version, + title = newest.title, + notes = newest.notes, + apkUrl = ordered.firstOrNull() ?: "", + apkName = newest.apkName.ifBlank { "maidong-ktv-${newest.version}.apk" }, + mirrors = ordered.drop(1), ) }) - }, "gitea-update-check").start() + }, "app-update-check").start() } fun download( @@ -65,47 +97,115 @@ object AppUpdateManager { ) { Thread({ result(runCatching { - val directory = File(activity.externalCacheDir ?: activity.cacheDir, "updates").apply { mkdirs() } - val target = File(directory, "maidong-ktv-${release.version}.apk") - val partial = File(directory, "${target.name}.download") - partial.delete() - val connection = (URL(release.apkUrl).openConnection() as HttpURLConnection).apply { - connectTimeout = 15_000 - readTimeout = 30_000 - instanceFollowRedirects = true - setRequestProperty("User-Agent", "MaidongKTV/${BuildConfig.VERSION_NAME}") - } - try { - require(connection.responseCode in 200..299) { "下载失败:HTTP ${connection.responseCode}" } - val total = connection.contentLengthCompat() - connection.inputStream.use { input -> - FileOutputStream(partial).use { output -> - val buffer = ByteArray(128 * 1024) - var downloaded = 0L - while (true) { - val count = input.read(buffer) - if (count < 0) break - output.write(buffer, 0, count) - downloaded += count - if (total > 0) progress((downloaded * 100 / total).toInt().coerceIn(0, 100)) - } - output.fd.sync() + val urls = (listOf(release.apkUrl) + release.mirrors) + .filter { it.isNotBlank() } + .distinct() + require(urls.isNotEmpty()) { "下载失败:没有可用的下载地址" } + var lastError: Throwable? = null + for ((index, url) in urls.withIndex()) { + val host = runCatching { URL(url).host }.getOrNull() ?: url + try { + val file = downloadFrom(activity, release, url, progress) + if (index > 0) { + MiliLog.notice("UPDATE 换源下载成功 源=$host 版本=${release.version}") + } else { + MiliLog.info("UPDATE 下载成功 源=$host 版本=${release.version}") } + return@runCatching file + } catch (t: Throwable) { + lastError = t + MiliLog.warn("UPDATE 下载失败 源=$host 原因=${t.message}") + // 还有备用地址就把进度归零重新下,让用户看到"换源重试"。 + if (index < urls.size - 1) progress(0) } - } finally { - connection.disconnect() } - require(partial.length() > 1_000_000L) { "下载的安装包不完整" } - partial.inputStream().use { input -> - require(input.read() == 'P'.code && input.read() == 'K'.code) { "下载内容不是 APK" } - } - val info = activity.packageManager.getPackageArchiveInfo(partial.absolutePath, 0) - require(info?.packageName == activity.packageName) { "安装包应用标识不匹配" } - target.delete() - require(partial.renameTo(target)) { "无法保存安装包" } - target + throw lastError ?: IllegalStateException("下载失败") }) - }, "gitea-update-download").start() + }, "app-update-download").start() + } + + /** + * 从单个地址下载并校验到本地。 + * + * 校验三条:HTTP 2xx、文件 >1MB、**文件头是 `PK`(真 APK)**,再用 PackageManager 复核包名 —— + * 防止 Gitee 的 302 跳到错误页面时把 HTML 当成安装包保存下来。 + */ + private fun downloadFrom( + activity: Activity, + release: Release, + url: String, + progress: (Int) -> Unit, + ): File { + val directory = File(activity.externalCacheDir ?: activity.cacheDir, "updates").apply { mkdirs() } + val target = File(directory, "maidong-ktv-${release.version}.apk") + val partial = File(directory, "${target.name}.download") + partial.delete() + val connection = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = 15_000 + readTimeout = 30_000 + instanceFollowRedirects = true + setRequestProperty("User-Agent", "MaidongKTV/${BuildConfig.VERSION_NAME}") + } + try { + require(connection.responseCode in 200..299) { "下载失败:HTTP ${connection.responseCode}" } + val total = connection.contentLengthCompat() + connection.inputStream.use { input -> + FileOutputStream(partial).use { output -> + val buffer = ByteArray(128 * 1024) + var downloaded = 0L + while (true) { + val count = input.read(buffer) + if (count < 0) break + output.write(buffer, 0, count) + downloaded += count + if (total > 0) progress((downloaded * 100 / total).toInt().coerceIn(0, 100)) + } + output.fd.sync() + } + } + } finally { + connection.disconnect() + } + require(partial.length() > 1_000_000L) { "下载的安装包不完整" } + partial.inputStream().use { input -> + require(input.read() == 'P'.code && input.read() == 'K'.code) { "下载内容不是 APK" } + } + val info = activity.packageManager.getPackageArchiveInfo(partial.absolutePath, 0) + require(info?.packageName == activity.packageName) { "安装包应用标识不匹配" } + target.delete() + require(partial.renameTo(target)) { "无法保存安装包" } + return target + } + + /** 拉取单个更新源的 latest release;没有可用 APK 附件时返回 null。 */ + private fun fetchLatest(source: Source): Release? { + val root = JSONObject(getText(source.latestApi)) + val version = root.optString("tag_name").trim().removePrefix("v") + if (version.isBlank()) return null + var apkUrl = "" + var apkName = "" + val assets = root.optJSONArray("assets") + if (assets != null) { + for (index in 0 until assets.length()) { + val asset = assets.optJSONObject(index) ?: continue + val name = asset.optString("name") + val url = asset.optString("browser_download_url") + // ⚠️ 只认以 .apk 结尾的附件:Gitee 的 assets 里还会带自动生成的源码包 + // (v1.5.5.zip / v1.5.5.tar.gz),按 url 含 ".apk" 之类宽松判断会选错。 + if (!name.endsWith(".apk", ignoreCase = true) || url.isBlank()) continue + apkName = name + apkUrl = url + break + } + } + if (apkUrl.isBlank()) return null + return Release( + version = version, + title = root.optString("name").ifBlank { "麦动 KTV $version" }, + notes = root.optString("body"), + apkUrl = apkUrl, + apkName = apkName.ifBlank { "maidong-ktv-$version.apk" }, + ) } /**