feat(ci): Gitea Actions 构建完整版 APK 并发布 Release;App 内更新检查
Build APK / build (push) Canceled after 7m12s

- .gitea/workflows/build.yml:打 tag 触发构建。代码取当前 ref,素材目录单独从 full-assets 分支叠加
  (main 保持 ~8MB 不追踪 assets_hq/assets_small/assets_net,缺它们构建出来的包宠物是空白的)。
  用 secret 注入的固定 keystore 签 release 包,构建后发布 APK + version.json 到 Gitea Release。
- app/build.gradle.kts:签名改为从环境变量读取(KEYSTORE_PATH / KEYSTORE_PASSWORD / KEY_ALIAS /
  KEY_PASSWORD),本地无 keystore 时回退 debug 签名;versionCode 支持 CI 注入,保证单调递增
  (更新机制要求新包 versionCode 严格大于已装版本,否则系统拒绝覆盖安装)。
- UpdateChecker.kt:读 Gitea Release API 按 versionCode 判版本 → 下载并校验 SHA256 → 经自带
  ApkProvider(content://)拉起系统安装器。项目零第三方依赖,用不了 androidx FileProvider,
  故自带一个只读 ContentProvider(只暴露 cacheDir/update 下的 .apk,路径取 basename 防穿越)。
- MainActivity 设置页新增「关于 / 检查更新」入口,下载前先检查「安装未知来源应用」授权。
- AndroidManifest 新增 REQUEST_INSTALL_PACKAGES 与 ApkProvider 声明。
- .gitignore 增加 *.jks / *.keystore / keystore.properties,防止签名文件误入库。
This commit is contained in:
jkooe committed 2026-11-09 17:16:41 +08:00
1 parent 2853b1bb1d
commit 43275ae7a0
6 files changed
+697 -2

No files matched your search

+174
View File
@@ -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"
+5
View File
@@ -12,6 +12,11 @@ local.properties
.DS_Store
captures/
# ==== 签名与密钥(绝不入库)====
*.jks
*.keystore
keystore.properties
# ==== 日志与临时 ====
*.log
/tmp/
+25 -2
View File
@@ -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")
}
}
}
+12
View File
@@ -10,6 +10,8 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- WebView 加载 http://127.0.0.1 需要网络权限 -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- 应用内更新:下载新版本后拉起系统安装器(对应 UpdateChecker + ApkProvider) -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:label="@string/app_name"
@@ -39,5 +41,15 @@
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="floating-desktop-pet" />
</service>
<!-- 应用内更新:只读地把自己下载的 APK 交给系统安装器。
Android 7+ 不允许 file:// 传给别的应用,而本项目零第三方依赖(无 androidx
FileProvider),所以自带一个最小只读 provider。exported=false + 临时授予
读权限,安装完成后系统即释放。 -->
<provider
android:name=".ApkProvider"
android:authorities="${applicationId}.apk"
android:exported="false"
android:grantUriPermissions="true" />
</application>
</manifest>
@@ -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 =
@@ -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<String>?, selection: String?,
selectionArgs: Array<String>?, sortOrder: String?
): Cursor? = null
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int = 0
override fun update(
uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>?
): Int = 0
}