更新下载服务增加 Gitee 并设为优先下载源

- AppUpdateManager 由单源(Gitea)改为多源: SOURCES = gitee → gitea
  · 检查更新: 两个源都查, 取版本更高的那个(避免某一边滞后漏更新)
  · 下载: 按 Gitee → Gitea 顺序尝试, 失败自动换源(换源时进度归零)
  · Release 新增 mirrors: 同版本其它源地址作备用镜像
- 附件筛选收紧为只认 name.endsWith(".apk"):
  Gitee 的 assets 里带自动生成的源码包(v1.5.5.zip/.tar.gz), 宽松判断会下错东西
- 新增远程日志: UPDATE 检查/下载成功/下载失败/换源下载成功(含源与原因)
- 版本号按规则 3 占用一个 patch: 1.5.5(37) → 1.5.6(38)

实测(本机): Gitee Release API 免令牌可读; 附件直链 302→302→foruda.gitee.com CDN,
用 App UA 跟随跳转下载 22,236,768 字节、md5 8f16d48ca185e7109db9821286831e8b(与本地一致)、
文件头 PK、耗时约 11.9s。Gitea 侧同为 v1.5.5 + 同名附件。

本次仅编译检查 + 单元测试(9 例全过), 按要求**未打包**(未执行 assembleRelease)。
This commit is contained in:
zijunchiang
2026-10-07 19:21:47 +08:00
parent 39e3c8d617
commit e280afe3a3
3 changed files with 177 additions and 68 deletions
+11 -2
View File
@@ -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` 直接失败。
+2 -2
View File
@@ -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,否则
@@ -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/<tag>/<文件>`)。
*/
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<String> = emptyList(),
)
fun check(currentVersion: String, result: (Result<Release?>) -> 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<Release>()
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<String>()
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" },
)
}
/**