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 +}