diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml
new file mode 100644
index 0000000..374eabd
--- /dev/null
+++ b/.gitea/workflows/build.yml
@@ -0,0 +1,174 @@
+name: Build APK
+
+# 触发方式:
+# · push tag(v*) → 构建并发布到 Gitea Release(App 内更新机制读的就是这里)
+# · workflow_dispatch → 手动构建,只上传 artifact,不发布
+#
+# 关键设计:**代码取当前 ref,资源目录从 full-assets 分支单独检出**
+# main 分支为保持 ~8MB 不追踪 assets_hq / assets_small / assets_net(见 README「分支约定」),
+# 而这三个目录是宠物动画与表情包本体,缺了它们构建出来的包宠物是空白的。
+# 所以构建时把 full-assets 的这三个目录叠加到工作树,而不是让代码跟着 650MB 分支走。
+
+on:
+ workflow_dispatch:
+ inputs:
+ variant:
+ description: 'asset 变体(hq=640x360 原画质 / small=320x180 小体积)'
+ required: false
+ default: 'hq'
+ push:
+ tags:
+ - 'v*'
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: 检出代码
+ run: |
+ git clone --depth 1 --branch "${{ github.ref_name }}" \
+ "${{ github.server_url }}/${{ github.repository }}.git" .
+ git log --oneline -1
+
+ - name: 叠加完整素材(full-assets 分支)
+ run: |
+ set -e
+ git remote add fa "${{ github.server_url }}/${{ github.repository }}.git" 2>/dev/null || true
+ git fetch --depth 1 fa full-assets
+ for d in assets_hq assets_small assets_net; do
+ if git ls-tree -d --name-only FETCH_HEAD "app/src/main/$d" | grep -q .; then
+ rm -rf "app/src/main/$d"
+ git checkout FETCH_HEAD -- "app/src/main/$d"
+ echo "✓ $d $(find "app/src/main/$d" -type f | wc -l) 个文件 $(du -sh "app/src/main/$d" | cut -f1)"
+ else
+ echo "✗ full-assets 分支缺少 app/src/main/$d" >&2
+ exit 1
+ fi
+ done
+
+ - name: 准备构建环境
+ run: |
+ set -e
+ # JDK 17
+ if ! java -version 2>&1 | grep -qE '"(17|2[1-9])'; then
+ for c in /opt/jdk17 /usr/lib/jvm/java-17-openjdk-amd64 /usr/lib/jvm/java-21-openjdk-amd64; do
+ [ -x "$c/bin/java" ] && export JAVA_HOME="$c" && break
+ done
+ fi
+ [ -n "${JAVA_HOME:-}" ] && echo "JAVA_HOME=$JAVA_HOME" >> "$GITHUB_ENV"
+ java -version 2>&1 | head -2
+ # Android SDK
+ if [ -z "${ANDROID_HOME:-}" ] || [ ! -d "${ANDROID_HOME:-}" ]; then
+ for p in /opt/android-sdk /usr/local/android-sdk "$HOME/Android/Sdk" /root/android-sdk; do
+ [ -d "$p" ] && export ANDROID_HOME="$p" && break
+ done
+ fi
+ if [ -z "${ANDROID_HOME:-}" ] || [ ! -d "$ANDROID_HOME" ]; then
+ echo "::error::runner 上找不到 Android SDK(ANDROID_HOME 未设置且常见路径均不存在)"
+ echo " 请在 runner 机器安装 Android commandline-tools + platform-34 + build-tools-35.0.0,"
+ echo " 或设置 ANDROID_HOME 环境变量后重试。"
+ exit 1
+ fi
+ echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
+ echo "ANDROID_SDK_ROOT=$ANDROID_HOME" >> "$GITHUB_ENV"
+ echo "SDK: $ANDROID_HOME"
+ ls "$ANDROID_HOME/platforms" 2>/dev/null | head -3
+
+ - name: 准备签名密钥
+ env:
+ KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
+ KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
+ run: |
+ set -e
+ if [ -z "${KEYSTORE_BASE64:-}" ]; then
+ echo "::warning::未配置 KEYSTORE_BASE64,本次用 debug 签名构建(仅验证链路,不可分发)。"
+ echo " ⚠ debug 签名每台机器不同,手机**无法覆盖安装**,更新机制会失效。"
+ echo " 按 README「发布与更新」配好 secrets 后重跑,即可得到正式签名包。"
+ exit 0
+ fi
+ printf '%s' "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.jks"
+ echo "KEYSTORE_PATH=$RUNNER_TEMP/release.jks" >> "$GITHUB_ENV"
+ echo "KEY_ALIAS=${{ secrets.KEY_ALIAS || 'petfloat' }}" >> "$GITHUB_ENV"
+
+ - name: 构建 Release APK
+ env:
+ KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
+ VERSION_CODE: ${{ github.run_number }}
+ VARIANT: ${{ inputs.variant || 'hq' }}
+ run: |
+ set -e
+ # versionCode 需严格递增:run_number 天然单调,但设备上已装的是 1,
+ # 这里加基准 1000 避免与历史调试包冲突,也留出回退空间。
+ export VERSION_CODE=$((1000 + VERSION_CODE))
+ export VERSION_NAME="1.0.${GITHUB_RUN_NUMBER}"
+ echo "构建版本:versionCode=$VERSION_CODE versionName=$VERSION_NAME variant=$VARIANT"
+
+ ARGS=""
+ [ "$VARIANT" = "hq" ] && ARGS="-Phq"
+
+ chmod +x gradlew
+ ./gradlew :app:assembleRelease $ARGS --no-daemon --stacktrace
+
+ APK=app/build/outputs/apk/release/app-release.apk
+ [ -f "$APK" ] || { echo "::error::构建产物不存在:$APK"; exit 1; }
+
+ # 产物登记:体积 + 指纹 + 签名摘要(更新机制靠 versionCode,人靠 SHA 核对)
+ APKSHA=$(sha256sum "$APK" | cut -d' ' -f1)
+ SIZE=$(stat -c%s "$APK")
+ echo "APK_SHA=$APKSHA" >> "$GITHUB_ENV"
+ echo "APK_SIZE=$SIZE" >> "$GITHUB_ENV"
+ echo "APK_NAME=petfloat-${VERSION_NAME}-${VARIANT}.apk" >> "$GITHUB_ENV"
+ cp "$APK" "$RUNNER_TEMP/petfloat-${VERSION_NAME}-${VARIANT}.apk"
+
+ # version.json:App 端更新检查优先读它取 versionCode / sha256 / 体积,
+ # 比从 tag 名解析可靠(tag 是人写的,这个文件由构建产物自己写)。
+ printf '{"versionCode":%s,"versionName":"%s","apk":"%s","sha256":"%s","size":%s}\n' \
+ "$VERSION_CODE" "$VERSION_NAME" "$APK_NAME" "$APK_SHA" "$SIZE" \
+ > "$RUNNER_TEMP/version.json"
+ cat "$RUNNER_TEMP/version.json"
+
+ echo "APK: $APK_SHA ${SIZE} bytes"
+
+ # 签名核验:确保用的是发布密钥而不是 debug 密钥(debug 密钥每台机器不同,装不上)
+ APKSIGNER=$(find "$ANDROID_HOME"/build-tools -name apksigner 2>/dev/null | sort -V | tail -1)
+ if [ -n "$APKSIGNER" ]; then
+ "$APKSIGNER" verify --print-certs "$APK" | head -3
+ fi
+
+ - name: 上传构建产物
+ run: echo "$RUNNER_TEMP/$APK_NAME"
+
+ - name: 发布到 Gitea Release
+ if: startsWith(github.ref, 'refs/tags/')
+ env:
+ TOKEN: ${{ secrets.GITEA_TOKEN }}
+ run: |
+ set -e
+ API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
+ TAG="${{ github.ref_name }}"
+
+ # 幂等:tag 已存在 release 就复用它,避免重跑流水线时 409
+ RID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" \
+ | python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
+ if [ -z "$RID" ]; then
+ RID=$(curl -s -X POST -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
+ -d "{\"tag_name\":\"$TAG\",\"name\":\"PetFloat $TAG\",\"body\":\"versionCode=$VERSION_CODE\\nSHA256=$APK_SHA\",\"target_commitish\":\"main\"}" \
+ "$API/releases" \
+ | python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))")
+ fi
+ echo "release id=$RID"
+
+ # 先删同名附件(重跑时避免重名失败),再上传
+ for aid in $(curl -s -H "Authorization: token $TOKEN" "$API/releases/$RID/assets" \
+ | python3 -c "import sys,json;[print(a['id']) for a in json.load(sys.stdin)]" 2>/dev/null); do
+ curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$RID/assets/$aid" || true
+ done
+ # 上传 APK 与 version.json(App 端靠后者判版本与校验下载完整性)
+ for f in "$RUNNER_TEMP/$APK_NAME" "$RUNNER_TEMP/version.json"; do
+ n=$(basename "$f")
+ curl -s -X POST -H "Authorization: token $TOKEN" \
+ -F "attachment=@$f" \
+ "$API/releases/$RID/assets?name=$n" | head -c 200
+ echo " ← $n"
+ done
+ echo "已发布:${{ github.server_url }}/${{ github.repository }}/releases/tag/$TAG"
diff --git a/.gitignore b/.gitignore
index b9db0ac..95eebe1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,11 @@ local.properties
.DS_Store
captures/
+# ==== 签名与密钥(绝不入库)====
+*.jks
+*.keystore
+keystore.properties
+
# ==== 日志与临时 ====
*.log
/tmp/
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index f281d2b..8db848a 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -11,13 +11,36 @@ android {
applicationId = "com.dsh.petfloat"
minSdk = 26
targetSdk = 34
- versionCode = 1
- versionName = "1.0"
+ // 版本号:CI 注入 VERSION_CODE(单调递增),本地缺省 1000。
+ // 更新机制要求新包 versionCode **严格大于**已装版本,否则系统会拒绝覆盖安装。
+ val injectedCode = System.getenv("VERSION_CODE")?.toIntOrNull()
+ versionCode = injectedCode ?: 1000
+ versionName = System.getenv("VERSION_NAME") ?: "1.0.$versionCode"
+ }
+
+ // 发布签名:CI 通过 secret 注入 keystore(见 .gitea/workflows/build.yml)。
+ // 本地没有 keystore 时不创建 release signingConfig,release 变体退回 debug 签名——
+ // 仅用于本地验证,**不可用于分发**(debug 签名每台机器不同,无法覆盖安装)。
+ val keystorePath = System.getenv("KEYSTORE_PATH")
+ val hasReleaseKeystore = !keystorePath.isNullOrBlank() && file(keystorePath).exists()
+
+ signingConfigs {
+ if (hasReleaseKeystore) {
+ create("release") {
+ storeFile = file(keystorePath!!)
+ storePassword = System.getenv("KEYSTORE_PASSWORD")
+ keyAlias = System.getenv("KEY_ALIAS") ?: "petfloat"
+ keyPassword = System.getenv("KEY_PASSWORD") ?: System.getenv("KEYSTORE_PASSWORD")
+ }
+ }
}
buildTypes {
release {
isMinifyEnabled = false
+ if (hasReleaseKeystore) {
+ signingConfig = signingConfigs.getByName("release")
+ }
}
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e1c7365..4aa29de 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -10,6 +10,8 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/dsh/petfloat/MainActivity.kt b/app/src/main/java/com/dsh/petfloat/MainActivity.kt
index 15f65f0..27b0b0e 100644
--- a/app/src/main/java/com/dsh/petfloat/MainActivity.kt
+++ b/app/src/main/java/com/dsh/petfloat/MainActivity.kt
@@ -2205,9 +2205,161 @@ class MainActivity : Activity() {
col.addView(authRows)
refreshAuthUi()
+
+ // ---- 关于与更新 ----
+ // 更新链路:CI 打 tag → Gitea Release(APK + version.json)→ App 读公开 API 比对
+ // versionCode → 下载 → ApkProvider + 系统安装器。
+ // 判定用 versionCode 而不是 versionName:后者按字符串比较会把 "1.0.9" 判成比
+ // "1.0.10" 新。
+ col.addView(sectionLabel("关于"))
+ val aboutCard = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ background = rounded(pal.card, 16, pal.line)
+ }
+ aboutCard.addView(actionRow("版本 ${UpdateChecker.currentVersionName(this)}") {})
+ aboutCard.addView(divider())
+ aboutCard.addView(actionRow("检查更新") { checkUpdate() })
+ col.addView(aboutCard)
+
return scroll
}
+ // ---------------- 应用内更新 ----------------
+
+ /** 防重入:一次只允许一个检查/下载在进行 */
+ private var updateBusy = false
+
+ /**
+ * 检查更新。
+ *
+ * 网络与 JSON 解析全在后台线程([UpdateChecker.checkBlocking] 内部直接发请求),
+ * 结果回主线程再决定弹什么。失败必须带上原因——只说"检查更新失败"等于没提示,
+ * 用户只会反复点。
+ */
+ private fun checkUpdate() {
+ if (updateBusy) {
+ Toast.makeText(this, "正在检查更新,请稍候…", Toast.LENGTH_SHORT).show()
+ return
+ }
+ updateBusy = true
+ Toast.makeText(this, "正在检查更新…", Toast.LENGTH_SHORT).show()
+ Thread {
+ val r = UpdateChecker.checkBlocking()
+ ui.post {
+ updateBusy = false
+ when (r) {
+ is UpdateChecker.Result.Failed ->
+ AlertDialog.Builder(this)
+ .setTitle("检查更新失败")
+ .setMessage("${r.message}\n\n如果长期失败,可以到 Gitea 的 Releases 页面手动下载安装。")
+ .setPositiveButton("知道了", null)
+ .show()
+ is UpdateChecker.Result.UpToDate ->
+ Toast.makeText(this, "已是最新版本", Toast.LENGTH_SHORT).show()
+ is UpdateChecker.Result.HasUpdate -> {
+ val cur = UpdateChecker.currentVersionCode(this)
+ if (r.info.versionCode <= cur) {
+ Toast.makeText(this, "已是最新版本", Toast.LENGTH_SHORT).show()
+ } else {
+ promptUpdate(r.info)
+ }
+ }
+ }
+ }
+ }.start()
+ }
+
+ private fun promptUpdate(info: UpdateChecker.Info) {
+ val sizeMb = "%.1f".format(info.sizeBytes / 1024.0 / 1024.0)
+ val notes = info.notes.take(400).ifBlank { "(无更新说明)" }
+ AlertDialog.Builder(this)
+ .setTitle("发现新版本 ${info.versionName}")
+ .setMessage(
+ "当前版本:${UpdateChecker.currentVersionName(this)}\n" +
+ "新版本:${info.versionName}(${sizeMb} MB)\n\n$notes"
+ )
+ .setPositiveButton("下载并安装") { _, _ -> startDownload(info) }
+ .setNegativeButton("稍后", null)
+ .show()
+ }
+
+ /**
+ * 下载并拉起安装。
+ *
+ * 两个前置:① Android 8+ 需要「安装未知来源应用」授权,未授权时**先引导去开**,
+ * 而不是等下载完才失败——几百 MB 下完才发现装不了最挫;② 下载进度用不可取消的
+ * 对话框挡住,避免用户中途退出留下半截文件(`downloadBlocking` 虽然会校验 SHA,
+ * 但让用户明确知道"正在下"比静默重试好)。
+ */
+ private fun startDownload(info: UpdateChecker.Info) {
+ if (!UpdateChecker.canInstall(this)) {
+ AlertDialog.Builder(this)
+ .setTitle("需要安装权限")
+ .setMessage("系统要求先允许「安装未知来源应用」才能装新版本。打开后回来再点一次「检查更新」。")
+ .setPositiveButton("去设置") { _, _ -> UpdateChecker.openInstallPermission(this) }
+ .setNegativeButton("取消", null)
+ .show()
+ return
+ }
+
+ val density = resources.displayMetrics.density
+ val box = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(
+ (20 * density).toInt(), (16 * density).toInt(),
+ (20 * density).toInt(), (8 * density).toInt()
+ )
+ }
+ val bar = android.widget.ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal)
+ .apply { max = 100 }
+ box.addView(
+ bar,
+ LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, (6 * density).toInt())
+ )
+ val label = TextView(this).apply {
+ text = "0%"
+ textSize = 13f
+ setTextColor(pal.sub)
+ setPadding(0, (8 * density).toInt(), 0, 0)
+ }
+ box.addView(label)
+
+ val dlg = AlertDialog.Builder(this)
+ .setTitle("正在下载 ${info.versionName}")
+ .setView(box)
+ .setCancelable(false)
+ .create()
+ dlg.show()
+
+ Thread {
+ val result = runCatching {
+ UpdateChecker.downloadBlocking(this, info) { pct ->
+ ui.post { bar.progress = pct; label.text = "$pct%" }
+ }
+ }
+ ui.post {
+ dlg.dismiss()
+ result.onSuccess { apk ->
+ // 交给系统安装器:装不装由用户点,App 不做静默安装
+ runCatching { UpdateChecker.install(this, apk) }
+ .onFailure { e ->
+ Log.e(TAG, "install failed", e)
+ Toast.makeText(
+ this, "无法拉起安装器:${e.message}", Toast.LENGTH_LONG
+ ).show()
+ }
+ }.onFailure { e ->
+ Log.e(TAG, "update download failed", e)
+ AlertDialog.Builder(this)
+ .setTitle("下载失败")
+ .setMessage(e.message ?: e.javaClass.simpleName)
+ .setPositiveButton("知道了", null)
+ .show()
+ }
+ }
+ }.start()
+ }
+
// ---------------- 授权 ----------------
private fun newToken(): String =
diff --git a/app/src/main/java/com/dsh/petfloat/UpdateChecker.kt b/app/src/main/java/com/dsh/petfloat/UpdateChecker.kt
new file mode 100644
index 0000000..c374bc6
--- /dev/null
+++ b/app/src/main/java/com/dsh/petfloat/UpdateChecker.kt
@@ -0,0 +1,329 @@
+package com.dsh.petfloat
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.content.Context
+import android.content.Intent
+import android.database.Cursor
+import android.net.Uri
+import android.os.Build
+import android.os.ParcelFileDescriptor
+import android.provider.Settings
+import android.util.Log
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.File
+import java.io.FileOutputStream
+import java.net.HttpURLConnection
+import java.net.URL
+import java.security.MessageDigest
+
+/**
+ * 应用内更新:检查 Gitea Release → 下载 APK → 拉起系统安装器。
+ *
+ * 为什么是 Gitea Release 而不是自建接口:`.gitea/workflows/build.yml` 在打 tag 时会把
+ * APK 与 version.json 挂到 Release 资产上,App 直接读 Gitea 的公开 API 即可,不需要
+ * 额外维护一份版本清单。仓库是 public,读 API 不需要 token。
+ *
+ * 版本判定:以 **versionCode** 为准,不用 versionName 比字符串——"1.0.9" 与 "1.0.10"
+ * 按字符串比会判错。versionCode 由 CI 注入(1000 + run_number,单调递增)。
+ */
+object UpdateChecker {
+
+ private const val TAG = "PetFloat"
+ private const val SERVER = "http://123.206.124.151:3000"
+ private const val REPO = "mtfyun/dsh-pet-floating"
+ private const val APK_MIME = "application/vnd.android.package-archive"
+ const val PROVIDER_AUTHORITY = "com.dsh.petfloat.apk"
+
+ /** 下载目录:cacheDir/update(系统清理缓存时可回收,APK 属一次性文件) */
+ private fun updateDir(ctx: Context): File =
+ File(ctx.cacheDir, "update").apply { if (!exists()) mkdirs() }
+
+ data class Info(
+ val versionCode: Int,
+ val versionName: String,
+ val apkName: String,
+ val apkUrl: String,
+ val sizeBytes: Long,
+ val sha256: String?,
+ val notes: String
+ )
+
+ sealed class Result {
+ data class HasUpdate(val info: Info) : Result()
+ object UpToDate : Result()
+ data class Failed(val message: String) : Result()
+ }
+
+ fun currentVersionCode(ctx: Context): Int = try {
+ ctx.packageManager.getPackageInfo(ctx.packageName, 0).let {
+ @Suppress("DEPRECATION")
+ it.versionCode
+ }
+ } catch (e: Exception) {
+ 0
+ }
+
+ fun currentVersionName(ctx: Context): String = try {
+ ctx.packageManager.getPackageInfo(ctx.packageName, 0).versionName ?: "?"
+ } catch (e: Exception) {
+ "?"
+ }
+
+ /**
+ * 检查更新。**必须在后台线程调用**(内部直接用网络,不发散线程)。
+ *
+ * 只取 releases 列表的第一项:Gitea 按创建时间倒序返回,第一项即最新发布。
+ * 不取 draft(草稿不该被用户看到)。
+ */
+ fun checkBlocking(): Result {
+ return try {
+ val url = URL("$SERVER/api/v1/repos/$REPO/releases?limit=10")
+ val body = httpGet(url.openConnection() as HttpURLConnection)
+ ?: return Result.Failed("网络无响应")
+ val arr = JSONArray(body)
+ var best: Info? = null
+ for (i in 0 until arr.length()) {
+ val rel = arr.optJSONObject(i) ?: continue
+ if (rel.optBoolean("draft", false)) continue
+ val info = parseRelease(rel) ?: continue
+ val cur = best
+ if (cur == null || info.versionCode > cur.versionCode) best = info
+ }
+ val latest = best ?: return Result.Failed("还没有发布过版本")
+ Result.HasUpdate(latest)
+ } catch (e: Exception) {
+ Log.w(TAG, "check update failed", e)
+ Result.Failed(e.message ?: e.javaClass.simpleName)
+ }
+ }
+
+ /**
+ * 解析一个 release。
+ *
+ * versionCode 优先取 `version.json` 资产(CI 显式写的,最可靠),
+ * 其次从 tag 形如 `v1.0.1005` 里取尾段,最后退回 0(视为不可比较,不会被当成更新)。
+ * APK 资产优先取名字带 `hq` 的(完整画质),没有则取第一个 .apk。
+ */
+ private fun parseRelease(rel: JSONObject): Info? {
+ val tag = rel.optString("tag_name", "").removePrefix("v")
+ val assets = rel.optJSONArray("assets") ?: JSONArray()
+
+ var apkName = ""
+ var apkUrl = ""
+ var size = 0L
+ var sha: String? = null
+ var vcFromJson = 0
+ var vnFromJson = ""
+
+ for (i in 0 until assets.length()) {
+ val a = assets.optJSONObject(i) ?: continue
+ val name = a.optString("name", "")
+ val dl = a.optString("browser_download_url", "")
+ when {
+ name == "version.json" -> {
+ // 资产内容需要额外下载,这里只记 URL;体积小,直接同步拉
+ runCatching {
+ val txt = httpGet(URL(dl).openConnection() as HttpURLConnection) ?: ""
+ val j = JSONObject(txt)
+ vcFromJson = j.optInt("versionCode", 0)
+ vnFromJson = j.optString("versionName", "")
+ sha = j.optString("sha256", "").ifBlank { null }
+ }
+ }
+ name.endsWith(".apk", true) -> {
+ // hq 优先:完整画质是这个项目的主发布形态
+ if (apkName.isEmpty() || (name.contains("hq") && !apkName.contains("hq"))) {
+ apkName = name
+ apkUrl = dl
+ size = a.optLong("size", 0L)
+ }
+ }
+ }
+ }
+ if (apkUrl.isEmpty()) return null
+
+ val vc = when {
+ vcFromJson > 0 -> vcFromJson
+ else -> tag.substringAfterLast('.').toIntOrNull() ?: 0
+ }
+ if (vc <= 0) return null
+
+ return Info(
+ versionCode = vc,
+ versionName = vnFromJson.ifBlank { tag },
+ apkName = apkName,
+ apkUrl = apkUrl,
+ sizeBytes = size,
+ sha256 = sha,
+ notes = rel.optString("body", "")
+ )
+ }
+
+ private fun httpGet(conn: HttpURLConnection): String? = try {
+ conn.connectTimeout = 8000
+ conn.readTimeout = 12000
+ conn.requestMethod = "GET"
+ conn.setRequestProperty("Accept", "application/json")
+ if (conn.responseCode in 200..299) {
+ conn.inputStream.bufferedReader().use { it.readText() }
+ } else {
+ Log.w(TAG, "GET ${conn.url} -> ${conn.responseCode}")
+ null
+ }
+ } finally {
+ conn.disconnect()
+ }
+
+ /**
+ * 下载 APK 到 `cacheDir/update/`。
+ *
+ * @param onProgress 0..100,在主线程以外回调(调用方自行 post 到 UI)
+ * @return 下载完成的文件;失败抛异常由调用方捕获
+ */
+ fun downloadBlocking(
+ ctx: Context,
+ info: Info,
+ onProgress: (Int) -> Unit = {}
+ ): File {
+ val dir = updateDir(ctx)
+ // 清掉历史残留:同一时间只保留一个待安装包,避免缓存越积越大
+ dir.listFiles()?.forEach { if (it.name != info.apkName) it.delete() }
+ val out = File(dir, info.apkName)
+ if (out.exists() && out.length() == info.sizeBytes && info.sizeBytes > 0) {
+ onProgress(100)
+ return out // 已完整下载过(重试场景),不重复拉
+ }
+
+ val conn = URL(info.apkUrl).openConnection() as HttpURLConnection
+ try {
+ conn.connectTimeout = 10000
+ conn.readTimeout = 30000
+ conn.instanceFollowRedirects = true
+ val code = conn.responseCode
+ if (code !in 200..299) throw IllegalStateException("下载失败 HTTP $code")
+ val total = conn.contentLengthLong.takeIf { it > 0 } ?: info.sizeBytes
+
+ conn.inputStream.use { input ->
+ FileOutputStream(out).use { fos ->
+ val buf = ByteArray(64 * 1024)
+ var done = 0L
+ var lastPct = -1
+ while (true) {
+ val n = input.read(buf)
+ if (n <= 0) break
+ fos.write(buf, 0, n)
+ done += n
+ if (total > 0) {
+ val pct = (done * 100 / total).toInt().coerceIn(0, 100)
+ if (pct != lastPct) { lastPct = pct; onProgress(pct) }
+ }
+ }
+ fos.flush()
+ }
+ }
+ } finally {
+ conn.disconnect()
+ }
+
+ // 校验:体积 + SHA256(CI 在 version.json 里给了摘要;不给就只校验体积)
+ if (info.sizeBytes > 0 && out.length() != info.sizeBytes) {
+ out.delete()
+ throw IllegalStateException("下载不完整(${out.length()} / ${info.sizeBytes} 字节)")
+ }
+ info.sha256?.let { expect ->
+ val actual = sha256Of(out)
+ if (!actual.equals(expect, ignoreCase = true)) {
+ out.delete()
+ throw IllegalStateException("校验失败:期望 $expect 实际 $actual")
+ }
+ }
+ onProgress(100)
+ return out
+ }
+
+ private fun sha256Of(f: File): String {
+ val md = MessageDigest.getInstance("SHA-256")
+ f.inputStream().use { ins ->
+ val buf = ByteArray(64 * 1024)
+ while (true) {
+ val n = ins.read(buf)
+ if (n <= 0) break
+ md.update(buf, 0, n)
+ }
+ }
+ return md.digest().joinToString("") { "%02x".format(it) }
+ }
+
+ /** 是否已获得「安装未知来源应用」权限(Android 8+ 需要) */
+ fun canInstall(ctx: Context): Boolean =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ ctx.packageManager.canRequestPackageInstalls()
+ } else true
+
+ /** 跳到「安装未知来源应用」授权页 */
+ fun openInstallPermission(ctx: Context) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ runCatching {
+ ctx.startActivity(Intent(
+ Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
+ Uri.parse("package:${ctx.packageName}")
+ ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
+ }.onFailure { Log.w(TAG, "open install permission failed", it) }
+ }
+
+ /**
+ * 拉起系统安装器。
+ *
+ * 走自己的 [ApkProvider](content://)而不是 file://:Android 7+ 对 file:// 直接抛
+ * FileUriExposedException。项目零第三方依赖,用不了 androidx 的 FileProvider,
+ * 所以自带一个只读的 ContentProvider。
+ */
+ fun install(ctx: Context, apk: File) {
+ val uri = Uri.parse("content://$PROVIDER_AUTHORITY/${apk.name}")
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(uri, APK_MIME)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ ctx.startActivity(intent)
+ }
+
+ fun downloadDir(ctx: Context): File = updateDir(ctx)
+}
+
+/**
+ * 仅供安装器读取 APK 的最小 ContentProvider。
+ *
+ * 只暴露 `cacheDir/update/` 下的文件,且只读:
+ * · 路径取 `uri.lastPathSegment` 后**只拼 basename**,丢弃任何 `../` 片段,防目录穿越;
+ * · 不实现 insert/update/delete(返回 0),不为写操作开门。
+ */
+class ApkProvider : ContentProvider() {
+
+ override fun onCreate(): Boolean = true
+
+ override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
+ val ctx = context ?: return null
+ if (!mode.contains("r")) return null
+ val name = uri.lastPathSegment ?: return null
+ val safe = File(name).name // 去掉路径成分,防 ../
+ if (safe.isBlank() || !safe.endsWith(".apk", true)) return null
+ val f = File(UpdateChecker.downloadDir(ctx), safe)
+ if (!f.exists()) return null
+ return ParcelFileDescriptor.open(f, ParcelFileDescriptor.MODE_READ_ONLY)
+ }
+
+ override fun getType(uri: Uri): String = "application/vnd.android.package-archive"
+
+ override fun query(
+ uri: Uri, projection: Array?, selection: String?,
+ selectionArgs: Array?, sortOrder: String?
+ ): Cursor? = null
+
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0
+ override fun update(
+ uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?
+ ): Int = 0
+}