feat(ci): Gitea Actions 构建完整版 APK 并发布 Release;App 内更新检查
Build APK / build (push) Canceled after 7m12s
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:
1 parent
2853b1bb1d
commit
43275ae7a0
6 files changed
+697
-2
No files matched your search
@@ -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"
|
||||
@@ -12,6 +12,11 @@ local.properties
|
||||
.DS_Store
|
||||
captures/
|
||||
|
||||
# ==== 签名与密钥(绝不入库)====
|
||||
*.jks
|
||||
*.keystore
|
||||
keystore.properties
|
||||
|
||||
# ==== 日志与临时 ====
|
||||
*.log
|
||||
/tmp/
|
||||
|
||||
+25
-2
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user