更新下载服务增加 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:
@@ -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
@@ -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" },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user