diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml
new file mode 100644
index 0000000..e60e71c
--- /dev/null
+++ b/.github/workflows/android-build.yml
@@ -0,0 +1,78 @@
+name: Android Build
+
+# 目的:把「必须构建 Rust native 库」这件事固化到 CI,杜绝
+# 「只跑 gradlew assemble → 得到能装必崩的包」这类问题。
+#
+# 说明:
+# - 构建前会先校验 patches/core/*.patch 能干净应用(上游 core 变更会导致失败)。
+# - 需要 cargo-ndk 与 lib-patcher;若 lib-patcher 不在 crates.io,请改为从源码安装。
+
+on:
+ push:
+ branches: [ main, 'fix/**' ]
+ pull_request:
+
+jobs:
+ core-patch-check:
+ name: core 子模块补丁可干净应用
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - name: 校验 patches/core/*.patch
+ run: |
+ shopt -s nullglob
+ status=0
+ count=0
+ for p in patches/core/*.patch; do
+ count=$((count + 1))
+ if git -C core apply --reverse --check "$p" 2>/dev/null; then
+ echo "已应用: $p"
+ elif git -C core apply --check "$p"; then
+ echo "可应用: $p"
+ else
+ echo "::error::core 补丁无法干净应用: $p(上游 core 可能已变更,需要 rebase)"
+ status=1
+ fi
+ done
+ echo "共检查 $count 个补丁"
+ exit $status
+
+ build-apk:
+ name: 构建 native 库 + APK (arm64-v8a)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: '17'
+
+ - uses: android-actions/setup-android@v3
+
+ - name: 安装 NDK 与 platform-tools
+ run: sdkmanager --install "ndk;27.0.12077973" "platform-tools"
+
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: aarch64-linux-android
+
+ - name: 安装 cargo-ndk 与 lib-patcher
+ run: |
+ cargo install cargo-ndk
+ cargo install lib-patcher
+
+ - name: 构建(Rust + APK)
+ run: |
+ export ANDROID_NDK_HOME="${ANDROID_SDK_ROOT:-$ANDROID_HOME}/ndk/27.0.12077973"
+ ./scripts/build-android.sh debug arm64-v8a
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: app-debug-apk
+ path: app/build/outputs/apk/debug/app-debug.apk
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 7201d59..f4c9188 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,4 +1,5 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+import java.io.File
import java.util.Properties
plugins {
@@ -23,8 +24,16 @@ android {
applicationId = "cn.yzjtiantian.android"
minSdk = 26
targetSdk = 35
- versionCode = 1
- versionName = "1.0.0"
+ versionCode = 2
+ versionName = "1.1.0"
+
+ val builtAbis = file("src/main/jniLibs")
+ .listFiles { f -> f.isDirectory && File(f, "libpeytchat_bridge.so").exists() }
+ ?.map { it.name }
+ .orEmpty()
+ if (builtAbis.isNotEmpty()) {
+ ndk { abiFilters += builtAbis }
+ }
}
signingConfigs {
create("release") {
@@ -62,6 +71,25 @@ kotlin {
}
}
+// 构建前校验 native 桥接库已就位:缺失时直接报可操作的错误,
+// 而不是产出一个「能装、一启动就 NoClassDefFoundError」的包。
+val verifyNativeLibs = tasks.register("verifyNativeLibs") {
+ val jniDir = file("src/main/jniLibs")
+ doFirst {
+ val abis = jniDir.listFiles { f -> f.isDirectory && File(f, "libpeytchat_bridge.so").exists() }
+ ?.map { it.name }
+ .orEmpty()
+ if (abis.isEmpty()) {
+ throw GradleException(
+ "app/src/main/jniLibs/ 下没有 libpeytchat_bridge.so,构建出的 APK 会在启动时崩溃。\n" +
+ "请先生成 native 库,例如:\n" +
+ " ./scripts/build-android.sh debug arm64-v8a,x86_64"
+ )
+ }
+ }
+}
+tasks.matching { it.name == "preBuild" }.configureEach { dependsOn(verifyNativeLibs) }
+
dependencies {
implementation(project(":core"))
implementation(project(":data"))
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0b60aed..7a86e40 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -13,10 +13,9 @@
-
-
-
-
-
- 192.168.0.103
-
-
diff --git a/docs/hot-update.md b/docs/hot-update.md
index b33d63e..9929e21 100644
--- a/docs/hot-update.md
+++ b/docs/hot-update.md
@@ -1,7 +1,7 @@
# 热更新(Hot Update)说明
-自托管 DEX 补丁热更新:客户端从服务端拉取**更新清单**,下载补丁 `.dex` 文件并做
-MD5 校验,记录后**下次启动时**通过 `DexClassLoader` 加载注册。
+自托管 DEX 补丁热更新:客户端从服务端拉取**更新清单**(**仅支持 https**),下载补丁
+`.dex` 文件并做 SHA-256 校验,记录后**下次启动时**通过 `DexClassLoader` 加载注册。
## 模块结构
@@ -31,13 +31,13 @@ MD5 校验,记录后**下次启动时**通过 `DexClassLoader` 加载注册。
"module": "shell",
"version": "1.0.1",
"url": "https://your-server/peytchat/patches/shell_1.0.1.dex",
- "md5": "d41d8cd98f00b204e9800998ecf8427e"
+ "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
{
"module": "login",
"version": "1.0.2",
"url": "https://your-server/peytchat/patches/login_1.0.2.dex",
- "md5": "9e107d9d372bb6826bd81d3542a419d6"
+ "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
}
]
}
@@ -47,9 +47,11 @@ MD5 校验,记录后**下次启动时**通过 `DexClassLoader` 加载注册。
- `appVersion`:可选,App 版本(当前仅展示用,不做强制校验)。
- `patches[].module`:模块名,取值 `shell` / `login` / `chat` / `account`。
-- `patches[].version`:补丁版本号(字符串比较;与本地已记录版本不同即视为需要更新)。
-- `patches[].url`:补丁 dex 的下载地址。
-- `patches[].md5`:补丁 dex 的 MD5(小写十六进制),客户端下载后校验,不通过则丢弃。
+- `patches[].version`:补丁版本号(语义化比较;仅当**严格大于**本地已记录版本时才更新,
+ 相等或更低一律跳过,防止被下发旧版补丁做降级攻击)。
+- `patches[].url`:补丁 dex 的下载地址(必须是 `https://`,否则拒绝)。
+- `patches[].sha256`:补丁 dex 的 SHA-256(64 位小写十六进制),客户端下载后校验,不通过则丢弃;
+ 为兼容旧清单仍接受 `md5`(32 位)。
## 部署到 GitHub(免费静态托管)
@@ -109,15 +111,15 @@ https://cdn.jsdelivr.net/gh/<用户名>/<仓库名>@main/patches/shell_1.0.1.dex
updates/patches/login_1.0.2.dex
```
-脚本自动计算每个文件的 MD5 并生成 `update.json`,然后提交推送即可。
+脚本自动计算每个文件的 SHA-256 并生成 `update.json`,然后提交推送即可。
## 客户端流程
1. 设置页 →「检查更新」→ 弹出对话框(默认清单地址
- `https://peyt.org/peytchat-android-update/update.json`,可修改并持久化)。
+ `https://peyt.org/peytchat-android-update/update.json`,可修改并持久化;**仅接受 https**)。
2. 点击「立即检查」→ `HotUpdateManager.checkForUpdates(url)`:
- 拉取清单,逐条对比本地已记录版本;
- - 新版本 → 下载 dex → MD5 校验 → 写入
+ - 新版本(严格递增)→ 下载 dex → SHA-256 校验 → 写入
`SharedPreferences`(`_patch_path` / `_version`,`loaded=false`)。
3. 下次进程启动时 `EventBridge.start()` 调用 `HotUpdateManager.loadPendingPatches()`:
- 重置各模块 `loaded=false`(DexClassLoader 不跨进程保留,需重新加载);
@@ -132,8 +134,8 @@ https://cdn.jsdelivr.net/gh/<用户名>/<仓库名>@main/patches/shell_1.0.1.dex
cd /tmp/update && python3 -m http.server 8000
```
-3. 手机与电脑同一网络,清单地址填
- `http://<电脑IP>:8000/update.json`。
+3. 手机与电脑同一网络。**客户端只接受 https 清单地址**,明文 `http://` 会被直接拒绝;
+ 本地联调可自签证书或走内网穿透(如 ngrok)提供 https。
4. 设置页 →「检查更新」→「立即检查」,观察下载/校验结果提示。
### 构造一个测试补丁 dex
@@ -152,8 +154,8 @@ EOF
javac Patch.java
d8 --output . Patch.class # 生成 classes.dex,改名为 shell_1.0.1.dex
-# 3. 计算 md5 并填进清单
-md5sum shell_1.0.1.dex
+# 3. 计算 sha256 并填进清单
+sha256sum shell_1.0.1.dex
```
## 补丁类怎么写
diff --git a/docs/onboarding.md b/docs/onboarding.md
new file mode 100644
index 0000000..bb49074
--- /dev/null
+++ b/docs/onboarding.md
@@ -0,0 +1,426 @@
+# 新人上手指南(Onboarding)
+
+> 面向**刚接手本项目**的开发者。读完本文你应该能:把项目跑起来、看懂代码放在哪、知道改完东西怎么发出去、以及避开前人踩过的坑。
+>
+> 本文是总览。机制细节见 [hot-update.md](./hot-update.md),发版操作见 [update-workflow.md](./update-workflow.md)。
+
+## 文档索引
+
+| 文档 | 内容 |
+| --- | --- |
+| `docs/onboarding.md` | 本文:环境、构建、架构、约定、排错 |
+| `docs/hot-update.md` | 热更新**机制**说明(清单格式、补丁类约定、ClassLoader、扩展点) |
+| `docs/update-workflow.md` | 热更新**发布操作手册**(改完代码怎么推到用户端) |
+| `.github/PULL_REQUEST_TEMPLATE.md` | PR 提交检查清单 |
+
+---
+
+## 一、项目是什么
+
+**PEYT Chat** 是一款**自托管即时通讯 App**:
+
+- 没有自建的推送服务器,消息收发走 **IMAP/SMTP**(协议层由 Rust 的 [chatmail/core](https://github.com/chatmail/core) 负责),保活靠 Android 前台服务。
+- 采用**两段式更新**体系(类似手游):
+ - **小改动 → DEX 热更新补丁**:不用重新装包,用户「检查更新」后杀进程重启即生效;
+ - **大改动 → APK 大版本更新**:App 内下载新 APK 并调系统安装器覆盖安装。
+- 除聊天本身,还有「工作区 / 频道 / 角色 / 卡片」等协作概念(Room 本地库 + PEYT 信封协议)。
+
+**当前版本**:`versionCode = 1`、`versionName = "0.1.1"`、`applicationId = cn.yzjtiantian.android`、`minSdk 26` / `targetSdk 35`。
+
+---
+
+## 二、技术栈
+
+| 层 | 技术 |
+| --- | --- |
+| 语言 | Kotlin 2.1.20、Rust(core 内核 + JNI 桥接) |
+| UI | Jetpack Compose(BOM 2025.05.01)、Material3、Navigation Compose 2.9.8 |
+| 构建 | Gradle **8.13**(wrapper)、AGP **8.9.1**、KSP `2.1.20-2.0.1` |
+| 持久化 | Room 2.7.2(数据库 `peytchat.db`) |
+| 协程 | kotlinx-coroutines 1.8.1 |
+| 原生 | NDK + `cargo-ndk`,ABI **仅 arm64-v8a** |
+| 依赖管理 | 版本集中在 `gradle/libs.versions.toml` |
+
+版本号统一放 [gradle/libs.versions.toml](../gradle/libs.versions.toml),不要在各模块里硬编码。
+
+---
+
+## 三、仓库结构与模块职责
+
+### 3.1 先记住这个坑:两个 `core`
+
+| 路径 | 是什么 | 参与 Gradle 构建? |
+| --- | --- | --- |
+| `core/` | **Rust 子模块**(git submodule → `chatmail/core`),消息协议内核 | ❌ 不参与 |
+| `libs/core/` | **Kotlin 模块** `:core`,包名 `cn.yzjtiantian.android.core.*` | ✅ 参与 |
+
+[settings.gradle.kts](../settings.gradle.kts) 里显式把 `:core/:data/:theme/:patch-api` 的源码目录重定向到 `libs/` 下,就是为了避开这个重名。
+
+### 3.2 目录树(只看源码)
+
+```
+peytchat-android/
+├── app/ # 入口:MainActivity、常驻服务、资源、jniLibs
+├── libs/
+│ ├── core/ # :core RPC / 事件桥 / 热更新 / 账号 / 补丁注册表
+│ ├── data/ # :data Room 数据库 + Repository + 信封解析
+│ ├── theme/ # :theme 颜色 / 图标 / 主题 / 主题对话框
+│ └── patch-api/ # :patch-api 补丁稳定契约(ChatUiProvider)
+├── feature/
+│ ├── shell/ # :feature:shell 登录后主界面(消息/协作/通知/设置)
+│ ├── login/ # :feature:login 登录页
+│ └── chat/ # :feature:chat 预留,目前只有壳
+├── patch/
+│ ├── data/ # :patch:data 数据层补丁示例
+│ └── notification/ # :patch:notification 通知行为补丁示例
+├── rust/ # Rust workspace(见 3.3)
+├── core/ # 【Rust 子模块】chatmail/core
+├── patches/core/ # 对 core 子模块的本地 patch 文件
+├── scripts/ # 构建与发版脚本
+└── docs/ # 本文档所在目录
+```
+
+### 3.3 模块职责与依赖方向
+
+```
+ ┌───────────────────────────────┐
+ │ :app │ 入口/资源/常驻服务
+ └───────┬───────────────┬───────┘
+ │ │
+ ┌───────────────────┴───┐ ┌─────┴──────────────┐
+ │ feature:* │ │ libs (基础层) │
+ │ shell / login / chat │────▶│ core / data / theme │
+ └───────────────────┬───┘ │ patch-api │
+ │ └────────────────────┘
+ └──────────────▶(同上)
+ patch:data / patch:notification ──▶ 通过 :patch-api 的契约与基座交互
+```
+
+| 模块 | 职责 | 关键文件 |
+| --- | --- | --- |
+| `:app` | 进程入口、导航、常驻收消息服务、通知、资源、native 库 | `MainActivity.kt`、`MessageNotificationService.kt`、`BootReceiver.kt`、`NotificationHelper.kt` |
+| `:core` | 底层能力:JSON-RPC、事件桥、会话、账号、热更新、补丁注册表 | `PeytBridge.kt`、`Rpc.kt`、`EventBridge.kt`、`Session.kt`、`CoreRuntime.kt`、`AccountManager.kt`、`HotUpdateManager.kt`、`ApkUpdateManager.kt`、`ModuleManager.kt` |
+| `:data` | Room 持久化与业务仓库、信封编解码 | `AppDatabase.kt`、`repository/PeytRepository.kt`、`envelope/Envelope.kt`、`entity/*`、`dao/*` |
+| `:theme` | 主题色板、图标、对话框样式 | `ui/theme/Theme.kt`、`Color.kt`、`TdesignIcons.kt`、`ThemeDialog.kt` |
+| `:patch-api` | 补丁**稳定契约**,发布后不可随意改签名 | `patchapi/ChatUiProvider.kt` |
+| `:feature:shell` | 登录后主容器(底部导航)与各业务页面 | `ShellScreen.kt`、`ChatScreen.kt`、`InboxScreen.kt`、`WorkScreen.kt`、`AboutUser.kt`、`AddDialogs.kt`、`UpdateDialog.kt` |
+| `:feature:login` | 登录 / Chatmail 账号创建 | `LoginScreen.kt` |
+| `:feature:chat` | 预留模块,暂无业务代码 | — |
+| `:patch:data` | 数据层热更新示例(`TextSendHook`) | `DataPatch`、`DataTextHook`,`patchVersion = 0.0.2` |
+| `:patch:notification` | 通知热更新示例(`MessageNotificationHook`) | `NotificationPatch`、`NotificationTextHook`,`patchVersion = 0.0.1` |
+
+**依赖方向**:`feature:* → libs/*`,`app → 全部`,无环。**libs 不要反向依赖 feature。**
+
+### 3.4 Rust 部分(`rust/`)
+
+`rust/` 是一个 Cargo workspace,三个 crate:
+
+| crate | 作用 |
+| --- | --- |
+| `peyt-envelope` | PEYT 信封协议编解码(`build_envelope` 等) |
+| `peytchat-bridge` | **JNI 出口**:产出 `libpeytchat_bridge.so`,被 Kotlin 的 `PeytBridge` 加载 |
+| `peytchat-plugins` | 插件(JS)注册表拉取 / 安装 / 列表 |
+
+Kotlin 侧调用入口是 [PeytBridge.kt](../libs/core/src/main/java/cn/yzjtiantian/android/core/PeytBridge.kt):
+
+```kotlin
+System.loadLibrary("peytchat_bridge")
+external fun nativeInit(dir: String): Long
+external fun nativeJsonrpcCall(request: String): String
+external fun nativeBuildEnvelope(type: String, payload: String): String
+// ... nativePlugins* 系列
+```
+
+`PeytBridge` 是 **synchronous / 阻塞** 的,上层用 `Rpc.kt`、`PeytEventLoop.kt` 包装并发与事件分发。**不要在 UI 线程直接调 native。**
+
+---
+
+## 四、环境准备
+
+### 4.1 必备工具
+
+| 工具 | 用途 | 备注 |
+| --- | --- | --- |
+| **JDK 17** | Gradle / AGP | 项目 `sourceCompatibility = 17` |
+| **Android SDK** | 编译与打包 | 需 `platforms;android-35`、`build-tools`(含 `d8`、`apksigner`、`aapt2`) |
+| **Android NDK** | 编译 Rust 交叉产物 | 脚本会探测 `ANDROID_NDK_HOME` / `ANDROID_NDK_ROOT` / `NDK_HOME` 或常见路径 |
+| **Rust 工具链** | 编 core 与 bridge | 需要 `cargo`、`cargo-ndk`、**`lib-patcher`** |
+| `adb` | 装包/看日志 | `scripts/build-android.sh` 会检查 |
+| `python3` | 生成更新清单 | `gen-update-manifest.sh` 依赖 |
+| `zip` / `unzip` | 校验 APK 内容 | 缺了只是少个校验,非致命 |
+
+Rust 相关依赖安装:
+
+```bash
+rustup target add aarch64-linux-android
+cargo install cargo-ndk
+cargo install lib-patcher # 缺它会在「给 libdeltachat.a 打补丁」这一步失败
+```
+
+### 4.2 必须自己创建的两个文件(都被 gitignore)
+
+| 文件 | 内容 | 何时需要 |
+| --- | --- | --- |
+| `local.properties` | `sdk.dir=/path/to/Android/Sdk` | **每次都要**(Gradle 找 SDK) |
+| `keystore.properties` | `storeFile` / `storePassword` / `keyAlias` / `keyPassword` | 打 **release** 包才需要 |
+
+参考 [app/build.gradle.kts](../app/build.gradle.kts):`keystore.properties` 不存在时 `signingConfigs.release` 不会填充,**release 包会签名失败**——新人第一次打 release 十有八九栽在这。
+
+### 4.3 初始化 Rust 子模块
+
+`core/` 是 submodule,**clone 后默认是空的**,必须初始化,否则构建脚本第 1 步就报错:
+
+```bash
+git submodule update --init --recursive
+```
+
+### 4.4 Gradle 发行包
+
+[gradle-wrapper.properties](../gradle/wrapper/gradle-wrapper.properties) 指向**阿里云镜像**的 `gradle-8.13-bin.zip`。若下载卡住/超时,可临时换回官方源:
+
+```
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
+```
+
+---
+
+## 五、构建与运行
+
+### 5.1 关键前提:native 库必须先就位
+
+`app/src/main/jniLibs/` 被 [.gitignore](../.gitignore) 忽略,**新克隆的仓库里没有 `libpeytchat_bridge.so`**。
+
+它不会被 Gradle 自动构建——必须由 `scripts/build-android.sh` 先编 Rust 再复制进去。**没有它,App 一起来就 `UnsatisfiedLinkError`。**
+
+当前仓库只产出 **arm64-v8a** 一个 ABI,即**只支持 arm64 设备/模拟器**。
+
+### 5.2 方式 A:一把梭(推荐,含 Rust)
+
+```bash
+./scripts/build-android.sh debug # 或 release
+```
+
+脚本依次做 5 步:
+
+1. 检查 `core/` 子模块 → 应用 `patches/core/*.patch`(幂等)→ `cargo ndk -t arm64-v8a build -p deltachat_ffi`
+2. 生成 `libdeltachat_patched.a`(用 `lib-patcher`,给 `dc_` 前缀符号打补丁)
+3. 编译 `rust/` 下的 `peytchat-bridge`
+4. 把 `libpeytchat_bridge.so` 复制到 `app/src/main/jniLibs/arm64-v8a/`
+5. `./gradlew assemble`,并校验 APK 内确实含 `.so`;有设备则自动 `adb install -r` 并拉起
+
+产物:`app/build/outputs/apk/{debug,release}/app-{debug,release}.apk`
+
+> `patches/core/0001-dns-preload-yzjtiantian-cn.patch` 是对上游 core 的定制(修 DNS 预载导致的登录超时)。改动 core 相关行为时**要改这个 patch 文件**,而不是直接改子模块里的代码——否则下次构建会被重置。
+
+### 5.3 方式 B:只改 Kotlin/Compose(快)
+
+`jniLibs` 里已有 `.so` 时,纯 Kotlin 改动不需要重编 Rust:
+
+```bash
+./gradlew :app:assembleDebug
+adb install -r app/build/outputs/apk/debug/app-debug.apk
+```
+
+单模块编译可加速:`./gradlew :feature:shell:assembleDebug`。
+
+### 5.4 看日志
+
+```bash
+adb logcat | grep -E "cn.yzjtiantian.android|deltachat|rust"
+```
+
+日志 tag 以 `PEYT` 开头(如 `ApkUpdateManager` 用 `PEYT`、`NotifyLog` 用 `PEYT-notify`)。
+
+---
+
+## 六、运行期架构速览
+
+搞清这几条链路,读代码就不会迷路:
+
+### 6.1 启动链路
+
+```
+MainActivity
+ └─ AppRoot
+ ├─ 初始化核心事件循环(PeytEventLoop,进程单例)
+ ├─ 判断登录态(Session.currentAccountId)
+ │ ├─ 未登录 → LoginScreen → AccountManager 建号/登录 → start_io
+ │ └─ 已登录 → ShellScreen
+ └─ 启动 MessageNotificationService(前台服务,持有事件循环)
+```
+
+- `CoreRuntime`:native **一次性初始化** + 账号引导;
+- `Session`:全局当前账号(所有 RPC 都基于 `Session.currentAccountId`)。
+
+### 6.2 通信链路
+
+- **请求**:业务代码 → `Rpc.kt` → `PeytBridge.nativeJsonrpcCall(...)` → Rust core → IMAP/SMTP;
+- **事件**:core 推送事件 → `EventBridge` 分发(如 `IncomingMsg`)→ UI 刷新 / 弹通知。
+
+### 6.3 数据链路
+
+- **本地**:Room(`peytchat.db`)存工作区/频道/角色/卡片/收件箱等;
+- **仓库**:`PeytRepository` 同时管 Room 和 RPC,是 UI 的主要数据入口;
+- **信封**:消息体走 PEYT 信封协议(`data/envelope/Envelope.kt` + Rust `peyt-envelope`),`card.*` / `project.invite` 这类信封会触发本地副作用,并用 `(fromId, envelope.id)` 做**幂等去重**。
+
+### 6.4 常驻收消息
+
+| 组件 | 作用 |
+| --- | --- |
+| `MessageNotificationService` | 前台服务,持有事件循环,退后台/被杀后仍收消息并弹通知 |
+| `BootReceiver` | 监听 `BOOT_COMPLETED` / `MY_PACKAGE_REPLACED`,有账号就拉起上面这个服务 |
+| `NotificationGate` | 标记「当前正在看的会话」,该会话不弹通知 |
+| 通知点击 | 深链 `peytchat://chat/` → ShellScreen 打开对应会话 |
+
+> **平台限制(重要)**:Android 15 / targetSdk 35 下 `dataSync` 型前台服务**不能**从开机广播启动且**6 小时/天超时**,所以消息服务用 `specialUse` 类型。厂商省电策略仍可能杀后台——这是无推送服务器的自托管架构的固有限制,`specialUse` 已是 Android 允许范围内最接近的解法。
+
+---
+
+## 七、两套更新体系(最需要理解的部分)
+
+### 7.1 为什么要分成两套
+
+| 改动类型 | 发布方式 | 用户生效方式 |
+| --- | --- | --- |
+| 配置 / 文案 / 行为 / 颜色 | DEX 补丁 | 检查更新 + **杀进程重启** |
+| 数据层行为(如发送内容加工) | DEX 补丁(`TextSendHook`) | 同上 |
+| 通知行为 | DEX 补丁(`MessageNotificationHook`) | 同上 |
+| 聊天界面结构 | DEX 补丁(`ChatUiProvider`) | 同上 |
+| 其他 UI 结构(ShellScreen / 登录页) | **发新 APK** | 重新安装 |
+| 任何涉及基座类签名变更的重构 | **发新 APK** | 重新安装 |
+
+### 7.2 热更新:清单 + 补丁 dex
+
+服务端只需托管静态文件:`update.json` + 若干 `.dex`。客户端「设置 → 检查更新」拉清单 → 下载 dex → SHA-256 校验 → 记录到 SharedPreferences → **下次进程启动**时用 `DexClassLoader` 加载并注册。清单地址**必须是 `https://`**(明文 http 会被直接拒绝)。
+
+补丁约定(客户端靠反射按约定加载):
+
+| 项 | 约定 |
+| --- | --- |
+| 包名 | `cn.yzjtiantian.android.patch` |
+| 类名 | `cn.yzjtiantian.android.patch.Patch`(shell/login/chat/account) |
+| 文件名 | `_.dex`(版本号**单调递增**) |
+| 可选方法 | `module()` / `version()` / `description()` / `boolean apply(Context)` |
+
+三个稳定**扩展点**(补丁注册、基座消费):
+
+| 扩展点 | 契约位置 | 注册键 | 基座消费点 |
+| --- | --- | --- | --- |
+| 聊天界面 | `libs/patch-api/.../ChatUiProvider.kt` | `getUiProvider("chat")` | `ShellScreen` 打开频道时优先用补丁界面,否则回退 `ChatScreen` |
+| 发送文本加工 | `libs/core/.../TextSendHook.kt` | `text_send_hook` | `PeytRepository.sendMessage` |
+| 通知定制 | `libs/core/.../MessageNotificationHook.kt` | `message_notification_hook` | `MessageNotificationService.postIncomingNotification` |
+
+**硬限制(parent-first 类加载)**:`DexClassLoader` 的 parent 是应用类加载器,**补丁里与基座同名的类不会生效**(解析到基座版本)。所以补丁**只能新增类 + 通过 `ModuleManager` 注册**,无法覆盖基座已有类。要「整体替换同名 UI 类」得用更重的方案(Tinker 式 classloader 替换 / Play Core 动态交付)。
+
+`ModuleManager` 是补丁注册表:`registerModule` / `registerPatch` / `registerUiProvider` / `registerPatchService`,查询用对应的 `get*`。
+
+### 7.3 APK 大版本更新(应用内安装)
+
+清单顶层多一个 `apk` 字段:
+
+```json
+{
+ "appVersion": "0.2.0",
+ "apk": {
+ "versionCode": 2,
+ "versionName": "0.2.0",
+ "url": "https://.../apk/peytchat-0.2.0.apk",
+ "sha256": "…",
+ "size": 12582912,
+ "releaseNotes": "1. …\n2. …",
+ "forceUpdate": false,
+ "minSupportedVersion": 1
+ },
+ "patches": [ … ]
+}
+```
+
+关键实现([ApkUpdateManager.kt](../libs/core/src/main/java/cn/yzjtiantian/android/core/ApkUpdateManager.kt)):
+
+| 能力 | 说明 |
+| --- | --- |
+| `checkForApkUpdate()` | 拉清单 → 解析 `apk` → `versionCode` **严格大于**当前(或低于 `minSupportedVersion`)才算有更新 |
+| `getCurrentVersionCode()` | 用 `PackageManager` 读取(library 模块拿不到 app 的 `BuildConfig`) |
+| `downloadApk()` | 流式下载到 `filesDir/apks/`(文件名用本地 `versionCode`,不信任远端 `versionName`),带进度回调、支持 `cancelDownload()`、完成后 SHA-256 校验;下载失败/哈希不符即删文件 |
+| `installApk()` | `FileProvider` content URI + `ACTION_INSTALL_PACKAGE`;`canRequestInstall()` 判断「安装未知应用」是否已授权,未授权则引导到系统设置页 |
+| 安装后补补丁 | 下载成功后写 `PREF_APK_JUST_UPDATED`(`apk_just_updated`);`EventBridge.start()` 启动时消费该标志,静默跑一次 `checkForUpdates` |
+
+UI 在 [UpdateDialog.kt](../feature/shell/src/main/java/cn/yzjtiantian/android/feature/shell/UpdateDialog.kt):打开即检查 → 有新版本显示版本号 + 更新说明 + 「立即更新 / 稍后再说」(`forceUpdate=true` 时隐藏后者)→ 下载进度 → 自动调起安装;下载/安装中禁止关闭对话框。
+
+**发布流程**(详见 [update-workflow.md](./update-workflow.md)):
+
+```bash
+# 1. 递增 versionCode(必须!否则客户端不会提示)
+# 改 app/build.gradle.kts 的 defaultConfig.versionCode
+# 2. 打 release 包(签名必须与线上一致,否则覆盖安装失败)
+./gradlew :app:assembleRelease
+# 3. 生成清单(自动算 sha256 / size / url)
+./scripts/gen-update-manifest.sh \
+ --apk app/build/outputs/apk/release/app-release.apk \
+ --apk-version-code 2 \
+ --apk-version-name 0.2.0 \
+ --apk-release-notes release-notes.txt \
+ https://peyt.org/peytchat-android-update/ \
+ updates/patches/shell_1.0.1.dex
+# 4. 上传 APK + update.json + dex 到静态服务器
+```
+
+### 7.4 本地联调(几个必须注意的点)
+
+```bash
+# 客户端只接受 https 清单地址,明文 http:// 会被直接拒绝。
+# 本地联调需提供 https:用自签证书的静态服务器,或走内网穿透(如 ngrok)。
+```
+
+1. **清单/补丁/APK 地址必须是 `https://`**:`HotUpdateManager` / `ApkUpdateManager`
+ 会校验协议,非 https 一律抛错拒绝(防止中间人篡改更新内容)。
+2. **手机上的旧包必须也能访问该地址**:更新检查是**旧包**发起的,所以旧包也得能连通该 https 地址。
+3. **清单里的 path 要对**:服务器根目录、`apk/`、`patches/` 子目录、`sha256`、`size` 必须和实际文件一致;`url` 少一层目录就是 404。
+4. **哈希无身份认证能力**:客户端仅校验 `sha256` 与文件一致,不校验服务端身份;
+ 真正的信任链(清单签名验签)尚未实现,不要把它当安全边界。
+
+---
+
+## 八、开发约定
+
+1. **模块边界**:`libs/*` 是基础层,不得依赖 `feature/*`;新页面放对应 `feature:*`;跨模块共享的底层能力放 `:core`。
+2. **包名**:`cn.yzjtiantian.android.<模块语义>`(`core` / `data` / `ui.theme` / `feature.` / `patch`)。
+3. **版本集中管理**:依赖版本写进 `gradle/libs.versions.toml`,用 `alias(libs.plugins.*)` / `libs.*` 引用。
+4. **补丁版本递增**:改 `patch/*` 时同步递增其 `build.gradle.kts` 里的 `patchVersion`,**不要覆盖旧 dex 文件**(便于回滚、避免 CDN 缓存)。
+5. **稳定契约不要乱改**:`ChatUiProvider` / `TextSendHook` / `MessageNotificationHook` 的签名一旦发布,旧基座/旧补丁会依赖它,改了要评估兼容性。
+6. **发 APK 时 versionCode 必须 +1**,且补丁版本建议同步递增(避免老补丁在新基座上加载失败)。
+7. **UI 线程纪律**:native 调用/RPC 是阻塞的,不要在 UI 线程直接调。
+8. **PR 规范**:按 [.github/PULL_REQUEST_TEMPLATE.md](../.github/PULL_REQUEST_TEMPLATE.md),要求本地构建通过、无调试代码、无遗留 `TODO/FIXME`、文档同步更新。
+9. **仓库目前没有 CI**(`.github/` 下只有 PR 模板),构建与测试靠本地执行,**PR 前请自己跑通构建**。
+
+---
+
+## 九、常见问题排查(FAQ)
+
+| 现象 | 原因 / 解决 |
+| --- | --- |
+| `UnsatisfiedLinkError: dlopen failed: library "libpeytchat_bridge.so" not found` | `jniLibs` 里没有 `.so`(被 gitignore 了)。先跑 `./scripts/build-android.sh debug` |
+| `core 目录不存在` / `core/Cargo.toml 不存在` | 子模块没初始化:`git submodule update --init --recursive` |
+| 构建卡在 `lib-patcher 未安装` | `cargo install lib-patcher` |
+| `cargo ndk: command not found` | `cargo install cargo-ndk`,并 `rustup target add aarch64-linux-android` |
+| Gradle 发行包下载超时 | 阿里云镜像不稳时换回 `services.gradle.org`(见 4.4) |
+| release 包签名失败 | 缺 `keystore.properties`(见 4.2) |
+| 检查更新报「仅支持 https 清单地址」 | 清单地址不是 `https://`,客户端主动拒绝(见 7.4) |
+| 覆盖安装报 `INSTALL_FAILED_UPDATE_INCOMPATIBLE`(签名冲突) | 新旧包签名证书不一致。用 `apksigner verify --print-certs` 对比 SHA-256;不一致只能卸载重装(丢数据) |
+| 检查更新提示失败 / 提示「失败 1 个」 | `sha256` 与文件不符、dex 损坏、或 URL 404;`sha256sum` 核对后重新生成清单 |
+| 补丁下载了但重启后不生效 | ① 入口类名必须是 `cn.yzjtiantian.android.patch.Patch`(大小写敏感);② 文件名/清单 `module` 与类名要对应;③ **补丁类与基座类重名了**——parent-first 下会被基座类遮蔽,改成新类名 |
+| 桌面开发机上找不到 `patch-sample/`、`updates/` | 这两个目录被 gitignore 且当前仓库里没有。写新补丁请以 `patch/data` 或 `patch/notification` 作为构建骨架 |
+| 杀进程重启后补丁才生效? | 设计如此:补丁在**下次进程启动**时加载,改完必须完整杀掉进程再启动 |
+
+---
+
+## 十、新人第一步建议
+
+1. **通读本文** + 浏览 `docs/hot-update.md`(机制)、`docs/update-workflow.md`(操作)。
+2. **把项目跑起来**:装好 4.1 的工具 → 建 `local.properties` → `git submodule update --init --recursive` → `./scripts/build-android.sh debug`。
+3. **顺着启动链路读代码**:`MainActivity` → `AppRoot` → `ShellScreen` → `PeytRepository`。
+4. **理解信封协议**:`data/envelope/Envelope.kt` 与 Rust 的 `peyt-envelope`。
+5. **动手试一次热更新**:按 `docs/update-workflow.md` 造一个补丁 dex,本地 `python3 -m http.server` 联调,观察「下载 → 校验 → 重启加载」全链路。
+6. **动手试一次 APK 更新**:按 7.3 生成带 `apk` 字段的清单,走一遍「下载 → 安装 → 重启自动补补丁」。
+
+> 任何与文档不符的地方,以**代码**为准,并顺手更新文档——文档过期比没有文档更危险。
diff --git a/docs/update-workflow.md b/docs/update-workflow.md
index bfc8258..59cf84b 100644
--- a/docs/update-workflow.md
+++ b/docs/update-workflow.md
@@ -54,7 +54,7 @@ OUT=updates/update.json ./scripts/gen-update-manifest.sh \
updates/chat_1.0.2.dex
```
-脚本自动算 MD5、拼下载 URL。多个补丁可一次传入:`... update.json updates/shell_1.0.2.dex updates/login_1.0.2.dex`
+脚本自动算 SHA-256、拼下载 URL。多个补丁可一次传入:`... update.json updates/shell_1.0.2.dex updates/login_1.0.2.dex`
### 1.4 上传服务器
@@ -68,7 +68,7 @@ https://peyt.org/peytchat-android-update/chat_1.0.2.dex
上传后验证:
```bash
-curl https://peyt.org/peytchat-android-update/update.json # 应返回 JSON,且 md5 与文件一致
+curl https://peyt.org/peytchat-android-update/update.json # 应返回 JSON,且 sha256 与文件一致
```
### 1.5 客户端生效(两步)
@@ -184,8 +184,8 @@ OUT=updates/update.json ./scripts/gen-update-manifest.sh \
## 4. 常用命令速查
```bash
-# 计算 dex 的 md5(应与清单一致)
-md5sum updates/chat_1.0.2.dex
+# 计算 dex 的 sha256(应与清单一致)
+sha256sum updates/chat_1.0.2.dex
# 本地联调(手机与电脑同一网络)
cd updates && python3 -m http.server 8000
@@ -195,13 +195,14 @@ cd updates && python3 -m http.server 8000
# 验证服务器文件可访问
curl -I https://peyt.org/peytchat-android-update/shell_1.0.1.dex
-# 回滚:把清单里的版本改回旧版本号(如 1.0.1),用户再次检查更新即会下载旧补丁
+# 回滚:客户端对补丁做「单调递增」校验,改回旧版本号**不会**生效;
+# 需要回滚请发布一个版本号更大的「修正版」补丁
```
## 5. 常见问题
-- **检查更新提示「失败 1 个」**:多半是 md5 不匹配或 dex 损坏,`md5sum` 核对后重新生成清单。
+- **检查更新提示「失败 1 个」**:多半是 sha256 不匹配或 dex 损坏,`sha256sum` 核对后重新生成清单。
- **下载了但重启后没有「已加载补丁」**:确认 dex 里入口类名是
`cn.yzjtiantian.android.patch.Patch`(大小写敏感),且文件名/清单 module 一致。
- **补丁不生效(老功能还在)**:补丁类与基座类重名了。补丁只能新增类,不能覆盖基座类。
-- **用户版本混乱**:补丁版本号必须单调递增;回滚请改清单版本而非覆盖旧 dex。
+- **用户版本混乱**:补丁版本号必须单调递增;客户端会拒绝相等/更低的版本,回滚请发布版本号更大的修正版。
diff --git a/feature/shell/src/main/java/cn/yzjtiantian/android/feature/shell/ChatScreen.kt b/feature/shell/src/main/java/cn/yzjtiantian/android/feature/shell/ChatScreen.kt
index b182ec4..1c01ca4 100644
--- a/feature/shell/src/main/java/cn/yzjtiantian/android/feature/shell/ChatScreen.kt
+++ b/feature/shell/src/main/java/cn/yzjtiantian/android/feature/shell/ChatScreen.kt
@@ -112,9 +112,10 @@ fun ChatScreen(
scope.launch(Dispatchers.IO) {
try {
repository.sendMessage(channel.chatId, text)
- android.util.Log.d("PEYT", "[send] ok chatId=${channel.chatId} text=$text")
+ // 不打印消息正文(PII),仅记录会话与长度
+ android.util.Log.d("PEYT", "[send] ok chatId=${channel.chatId} len=${text.length}")
} catch (e: Exception) {
- android.util.Log.e("PEYT", "[send] FAILED chatId=${channel.chatId} text=$text", e)
+ android.util.Log.e("PEYT", "[send] FAILED chatId=${channel.chatId} len=${text.length}", e)
Handler(Looper.getMainLooper()).post {
android.widget.Toast.makeText(context, "发送失败: ${e.message}", android.widget.Toast.LENGTH_LONG).show()
}
diff --git a/feature/shell/src/main/java/cn/yzjtiantian/android/feature/shell/ShellScreen.kt b/feature/shell/src/main/java/cn/yzjtiantian/android/feature/shell/ShellScreen.kt
index e883e60..2923911 100644
--- a/feature/shell/src/main/java/cn/yzjtiantian/android/feature/shell/ShellScreen.kt
+++ b/feature/shell/src/main/java/cn/yzjtiantian/android/feature/shell/ShellScreen.kt
@@ -162,19 +162,37 @@ fun ShellScreen(
}
}
+ /** 外部来源字符串仅接受「邀请链接」形态,避免任意串被外部 App/网页触发加好友。 */
+ fun isInviteLink(raw: String): Boolean {
+ val s = raw.trim()
+ if (s.isEmpty() || s.length > 512) return false
+ // deltachat 邀请链接:http(s) URL 且带 `#` 片段
+ if ((s.startsWith("http://") || s.startsWith("https://")) && s.contains('#')) return true
+ // 指纹直连邀请
+ return s.startsWith("OPENPGP4FPR:", ignoreCase = true)
+ }
+
/** 处理深链(`peytchat://` 等):`peytchat://chat/` 直接打开会话,其余建单聊/securejoin 后跳转。 */
fun openDeepLink(raw: String) {
+ val link = raw.trim()
val chatPrefix = "peytchat://chat/"
- if (raw.startsWith(chatPrefix)) {
- val id = raw.substring(chatPrefix.length).toLongOrNull()
+ if (link.startsWith(chatPrefix, ignoreCase = true)) {
+ val id = link.substring(chatPrefix.length).toLongOrNull()
if (id != null) {
openChatById(id)
return
}
+ error = "无效的会话链接"
+ return
+ }
+ // 深链入口对外导出且 BROWSABLE:非会话链接必须是合法邀请,其余一律拒绝
+ if (!isInviteLink(link)) {
+ error = "不支持的链接"
+ return
}
scope.launch {
val chatId = withContext(Dispatchers.IO) {
- runCatching { repository.addFriend(raw) }
+ runCatching { repository.addFriend(link) }
.onFailure { error = it.message }
.getOrNull()
}
diff --git a/libs/core/src/main/java/cn/yzjtiantian/android/core/ApkUpdateManager.kt b/libs/core/src/main/java/cn/yzjtiantian/android/core/ApkUpdateManager.kt
index 18152f5..df0bc9d 100644
--- a/libs/core/src/main/java/cn/yzjtiantian/android/core/ApkUpdateManager.kt
+++ b/libs/core/src/main/java/cn/yzjtiantian/android/core/ApkUpdateManager.kt
@@ -20,7 +20,7 @@ import java.util.concurrent.atomic.AtomicBoolean
* 链路:
* 1. [checkForApkUpdate] 拉取更新清单,解析顶层 `apk` 字段(可选,缺省表示无 APK 更新);
* 2. 清单 `apk.versionCode` 严格大于当前 [getCurrentVersionCode] 时提示更新;
- * 3. [downloadApk] 流式下载到 `filesDir/apks/`,边下边回调进度,完成后校验 MD5;
+ * 3. [downloadApk] 流式下载到 `filesDir/apks/`,边下边回调进度,完成后校验哈希(sha256/md5);
* 4. [installApk] 通过 FileProvider content URI 调系统安装器(用户确认后安装)。
*
* 清单 `apk` 字段格式:
@@ -31,7 +31,7 @@ import java.util.concurrent.atomic.AtomicBoolean
* "versionCode": 2,
* "versionName": "0.2.0",
* "url": "https://peyt.org/peytchat-android-update/apk/peytchat-0.2.0.apk",
- * "md5": "a1b2c3d4e5f6...",
+ * "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
* "size": 12582912,
* "releaseNotes": "1. 修复邀请链接 bug\n2. ...",
* "forceUpdate": false,
@@ -62,7 +62,7 @@ class ApkUpdateManager(private val context: Context) {
val versionCode: Int,
val versionName: String,
val url: String,
- val md5: String,
+ val hash: String, // sha256(64 位 hex) 或 md5(32 位 hex)
val size: Long, // 字节数,-1 表示清单未提供
val releaseNotes: String,
val forceUpdate: Boolean,
@@ -154,7 +154,9 @@ class ApkUpdateManager(private val context: Context) {
}
private fun fetchManifest(url: String): String {
- val conn = URL(url).openConnection() as HttpURLConnection
+ val conn = (HotUpdateManager.httpsOnly(url)
+ ?: throw IllegalArgumentException("清单地址必须为 https: $url"))
+ .openConnection() as HttpURLConnection
conn.connectTimeout = 30_000
conn.readTimeout = 30_000
conn.requestMethod = "GET"
@@ -172,7 +174,7 @@ class ApkUpdateManager(private val context: Context) {
versionCode = versionCode,
versionName = versionName,
url = url,
- md5 = apk.optString("md5"),
+ hash = apk.optString("sha256").ifEmpty { apk.optString("md5") },
size = apk.optLong("size", -1L),
releaseNotes = apk.optString("releaseNotes"),
forceUpdate = apk.optBoolean("forceUpdate", false),
@@ -188,16 +190,19 @@ class ApkUpdateManager(private val context: Context) {
}
/**
- * 下载 APK 到 `filesDir/apks/peytchat-.apk`,边下边回调进度,
- * 完成后校验 MD5。成功时写入 [PREF_APK_JUST_UPDATED] 标志(供下次启动补装补丁)。
+ * 下载 APK 到 `filesDir/apks/peytchat-.apk`,边下边回调进度,
+ * 完成后校验哈希(sha256/md5)。成功时写入 [PREF_APK_JUST_UPDATED] 标志(供下次启动补装补丁)。
* 回调(进度与结果)在工作线程执行,UI 层需自行切回主线程。
*/
fun downloadApk(info: ApkInfo, onProgress: ProgressCallback?, callback: DownloadCallback) {
cancelled.set(false)
Thread {
- val dest = File(apkDir, "peytchat-${info.versionName}.apk")
+ // 文件名不用远端 versionName(避免其含路径分隔符越出 apks/ 目录)
+ val dest = File(apkDir, "peytchat-${info.versionCode}.apk")
try {
- val conn = URL(info.url).openConnection() as HttpURLConnection
+ val conn = (HotUpdateManager.httpsOnly(info.url)
+ ?: throw IllegalArgumentException("APK 地址必须为 https: ${info.url}"))
+ .openConnection() as HttpURLConnection
conn.connectTimeout = 30_000
conn.readTimeout = 60_000
// 禁用 gzip,保证 Content-Length 是原始大小、进度条百分比准确
@@ -233,10 +238,10 @@ class ApkUpdateManager(private val context: Context) {
conn.disconnect()
onProgress?.onProgress(downloaded, if (total > 0) total else downloaded)
- if (info.md5.isNotEmpty() && !verifyMd5(dest, info.md5)) {
- Log.e(TAG, "APK MD5 校验失败: ${dest.name}")
+ if (info.hash.isNotEmpty() && !verifyHash(dest, info.hash)) {
+ Log.e(TAG, "APK 哈希校验失败: ${dest.name}")
dest.delete()
- callback.onResult(false, null, "MD5 校验失败,已丢弃下载文件")
+ callback.onResult(false, null, "哈希校验失败,已丢弃下载文件")
return@Thread
}
@@ -254,13 +259,22 @@ class ApkUpdateManager(private val context: Context) {
}.start()
}
- private fun verifyMd5(file: File, expectedMd5: String): Boolean {
+ /**
+ * 校验文件哈希。算法按 `expected` 长度自动判定:64 位 hex → SHA-256,32 位 → MD5。
+ * 注意:哈希只能防「内容损坏」,不能防「被同时替换」(无身份认证能力)。
+ */
+ private fun verifyHash(file: File, expected: String): Boolean {
return try {
- val digest = MessageDigest.getInstance("MD5").digest(file.readBytes())
+ val algo = when (expected.trim().length) {
+ 64 -> "SHA-256"
+ 32 -> "MD5"
+ else -> return false
+ }
+ val digest = MessageDigest.getInstance(algo).digest(file.readBytes())
val actual = digest.joinToString("") { "%02x".format(it) }
- actual.equals(expectedMd5, ignoreCase = true)
+ actual.equals(expected.trim(), ignoreCase = true)
} catch (e: Exception) {
- Log.e(TAG, "MD5 校验异常", e)
+ Log.e(TAG, "哈希校验异常", e)
false
}
}
diff --git a/libs/core/src/main/java/cn/yzjtiantian/android/core/EventBridge.kt b/libs/core/src/main/java/cn/yzjtiantian/android/core/EventBridge.kt
index 718bd27..6e6fae8 100644
--- a/libs/core/src/main/java/cn/yzjtiantian/android/core/EventBridge.kt
+++ b/libs/core/src/main/java/cn/yzjtiantian/android/core/EventBridge.kt
@@ -130,7 +130,8 @@ class EventBridge(
val patchUrl = event.payload.optString("patchUrl")
val version = event.payload.optString("version")
val module = event.payload.optString("module")
- val md5 = event.payload.optString("md5")
+ // 推荐 sha256;兼容旧事件的 md5
+ val hash = event.payload.optString("sha256").ifEmpty { event.payload.optString("md5") }
android.util.Log.d("PEYT", "热更新可用: module=$module, version=$version")
@@ -139,7 +140,7 @@ class EventBridge(
url = patchUrl,
module = module,
version = version,
- md5 = md5
+ hash = hash
)
}
"ModuleUpdate" -> {
diff --git a/libs/core/src/main/java/cn/yzjtiantian/android/core/HotUpdateManager.kt b/libs/core/src/main/java/cn/yzjtiantian/android/core/HotUpdateManager.kt
index b773218..56f3146 100644
--- a/libs/core/src/main/java/cn/yzjtiantian/android/core/HotUpdateManager.kt
+++ b/libs/core/src/main/java/cn/yzjtiantian/android/core/HotUpdateManager.kt
@@ -13,8 +13,8 @@ import java.security.MessageDigest
* 自托管 DEX 补丁热更新管理器。
*
* 完整链路:
- * 1. [checkForUpdates] 拉取更新清单(JSON),与本地已记录版本对比;
- * 2. 对每个新版本补丁下载 .dex 文件并做 MD5 校验;
+ * 1. [checkForUpdates] 拉取更新清单(JSON,**仅允许 https**),与本地已记录版本对比;
+ * 2. 仅当清单版本「严格大于」本地版本时,下载 .dex 并做哈希校验(sha256 / md5);
* 3. 校验通过后记录到 SharedPreferences(loaded=false);
* 4. 下次进程启动时由 [loadPendingPatches] 通过 DexClassLoader 加载,
* 并把 ClassLoader 注册到 [ModuleManager]。
@@ -28,11 +28,16 @@ import java.security.MessageDigest
* "module": "shell",
* "version": "1.0.1",
* "url": "https://peyt.org/peytchat-android-update/shell_1.0.1.dex",
- * "md5": "d41d8cd98f00b204e9800998ecf8427e"
+ * "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
* }
* ]
* }
* ```
+ * `sha256` 为推荐字段(64 位 hex);为兼容旧清单仍接受 `md5`(32 位 hex)。
+ *
+ * ⚠️ 安全边界:哈希只能防「传输损坏」,**不具备身份认证能力**(哈希与文件同源,
+ * 可被一同替换)。故本类强制 https;若要防「被控服务器下发恶意补丁」,需再对
+ * 清单做非对称签名 + 客户端内置公钥验签(尚未实现)。
*
* 已知限制:Android 类加载是 parent-first 的,若补丁类名与基座 APK 内已有类
* 完全同名,[DexClassLoader] 会解析到基座版本,补丁不生效。要让补丁真正生效,
@@ -60,6 +65,38 @@ class HotUpdateManager(private val context: Context) {
/** 所有可热更新模块。 */
val ALL_MODULES = listOf(MODULE_SHELL, MODULE_LOGIN, MODULE_CHAT, MODULE_ACCOUNT, MODULE_DATA)
+
+ /**
+ * 校验并构造 URL:**只允许 https**。返回 null 表示非法。
+ * 更新清单/补丁走明文 HTTP 时可被中间人替换为恶意内容,故一律拒绝。
+ */
+ internal fun httpsOnly(raw: String): java.net.URL? {
+ return try {
+ val url = java.net.URL(raw.trim())
+ if (url.protocol.equals("https", ignoreCase = true)) url else null
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ /** 语义化版本比较:a > b 返回正数,a == b 返回 0,a < b 返回负数。 */
+ internal fun compareVersion(a: String, b: String): Int {
+ val pa = a.split('.', '-', '+')
+ val pb = b.split('.', '-', '+')
+ for (i in 0 until maxOf(pa.size, pb.size)) {
+ val sa = pa.getOrNull(i) ?: "0"
+ val sb = pb.getOrNull(i) ?: "0"
+ val ia = sa.toIntOrNull()
+ val ib = sb.toIntOrNull()
+ val cmp = if (ia != null && ib != null) ia.compareTo(ib) else sa.compareTo(sb)
+ if (cmp != 0) return cmp
+ }
+ return 0
+ }
+
+ /** 把来自远端的字符串限制为安全的文件名片段,去掉路径分隔符等。 */
+ internal fun safeName(raw: String): String =
+ raw.replace(Regex("[^A-Za-z0-9._-]"), "_").ifEmpty { "_" }
}
/** 一次检查更新的结果,回调在工作线程执行。 */
@@ -77,12 +114,12 @@ class HotUpdateManager(private val context: Context) {
val downloaded: Boolean,
)
- /** 更新清单中的单个补丁描述。 */
+ /** 更新清单中的单个补丁描述。`hash` 支持 sha256(64 位 hex)或 md5(32 位 hex)。 */
data class PatchInfo(
val module: String,
val version: String,
val url: String,
- val md5: String,
+ val hash: String,
)
fun interface UpdateCallback {
@@ -138,7 +175,9 @@ class HotUpdateManager(private val context: Context) {
val updated = mutableListOf()
for (patch in patches) {
val current = getInstalledVersion(patch.module)
- if (patch.version == current) continue // 已是该版本,跳过
+ // 单调递增:仅当清单版本「严格大于」本地版本时才更新;
+ // 相等或更低一律跳过,防止被下发旧版补丁做降级攻击。
+ if (current != null && compareVersion(patch.version, current) <= 0) continue
val downloaded = downloadAndVerify(patch)
updated += ModuleUpdate(patch.module, current, patch.version, downloaded)
}
@@ -168,9 +207,10 @@ class HotUpdateManager(private val context: Context) {
}.start()
}
- /** 拉取更新清单 JSON 文本。 */
+ /** 拉取更新清单 JSON 文本(仅允许 https)。 */
private fun fetchManifest(url: String): String {
- val conn = java.net.URL(url).openConnection() as java.net.HttpURLConnection
+ val conn = (httpsOnly(url) ?: throw IllegalArgumentException("清单地址必须为 https: $url"))
+ .openConnection() as java.net.HttpURLConnection
conn.connectTimeout = 30_000
conn.readTimeout = 30_000
conn.requestMethod = "GET"
@@ -187,9 +227,10 @@ class HotUpdateManager(private val context: Context) {
val module = p.optString("module")
val version = p.optString("version")
val url = p.optString("url")
- val md5 = p.optString("md5")
- if (module.isNotEmpty() && version.isNotEmpty() && url.isNotEmpty()) {
- add(PatchInfo(module, version, url, md5))
+ // 推荐 sha256(64 hex);兼容旧清单的 md5(32 hex)
+ val hash = p.optString("sha256").ifEmpty { p.optString("md5") }
+ if (module.isNotEmpty() && version.isNotEmpty() && url.isNotEmpty() && hash.isNotEmpty()) {
+ add(PatchInfo(module, version, url, hash))
}
}
}
@@ -198,15 +239,15 @@ class HotUpdateManager(private val context: Context) {
// ===== 下载与校验 =====
/**
- * 下载补丁并校验 MD5;通过则记录(下次启动由 [loadPendingPatches] 应用)。
+ * 下载补丁并校验哈希(sha256/md5);通过则记录(下次启动由 [loadPendingPatches] 应用)。
* 返回是否成功。
*/
private fun downloadAndVerify(patch: PatchInfo): Boolean {
return try {
val file = downloadPatch(patch.url, patch.module, patch.version)
?: return false
- if (!verifyMd5(file, patch.md5)) {
- Log.e(TAG, "MD5 校验失败: module=${patch.module}")
+ if (!verifyHash(file, patch.hash)) {
+ Log.e(TAG, "哈希校验失败: module=${patch.module}")
file.delete()
return false
}
@@ -252,7 +293,7 @@ class HotUpdateManager(private val context: Context) {
url: String,
module: String,
version: String,
- md5: String,
+ hash: String,
) {
Thread {
try {
@@ -261,8 +302,8 @@ class HotUpdateManager(private val context: Context) {
Log.e(TAG, "下载补丁失败: $module")
return@Thread
}
- if (!verifyMd5(patchFile, md5)) {
- Log.e(TAG, "MD5 校验失败: $module")
+ if (!verifyHash(patchFile, hash)) {
+ Log.e(TAG, "哈希校验失败: $module")
patchFile.delete()
return@Thread
}
@@ -334,11 +375,13 @@ class HotUpdateManager(private val context: Context) {
private fun downloadPatch(url: String, module: String, version: String): File? {
return try {
- val connection = java.net.URL(url).openConnection() as java.net.HttpURLConnection
+ val connection = (httpsOnly(url) ?: throw IllegalArgumentException("补丁地址必须为 https: $url"))
+ .openConnection() as java.net.HttpURLConnection
connection.connectTimeout = 30_000
connection.readTimeout = 30_000
- val patchFile = File(patchDir, "${module}_${version}.dex")
+ // module/version 来自远端清单,禁止其影响落地路径
+ val patchFile = File(patchDir, "${safeName(module)}_${safeName(version)}.dex")
connection.inputStream.use { input ->
patchFile.outputStream().use { output ->
input.copyTo(output)
@@ -353,14 +396,22 @@ class HotUpdateManager(private val context: Context) {
}
}
- private fun verifyMd5(file: File, expectedMd5: String): Boolean {
+ /**
+ * 校验文件哈希。算法按 `expected` 长度自动判定:64 位 hex → SHA-256,32 位 → MD5。
+ * 注意:哈希只能防「内容损坏」,不能防「被同时替换」(无身份认证能力)。
+ */
+ private fun verifyHash(file: File, expected: String): Boolean {
return try {
- val md5 = MessageDigest.getInstance("MD5")
- val digest = md5.digest(file.readBytes())
- val actualMd5 = digest.joinToString("") { "%02x".format(it) }
- actualMd5.equals(expectedMd5, ignoreCase = true)
+ val algo = when (expected.trim().length) {
+ 64 -> "SHA-256"
+ 32 -> "MD5"
+ else -> return false
+ }
+ val digest = MessageDigest.getInstance(algo).digest(file.readBytes())
+ val actual = digest.joinToString("") { "%02x".format(it) }
+ actual.equals(expected.trim(), ignoreCase = true)
} catch (e: Exception) {
- Log.e(TAG, "MD5 校验失败", e)
+ Log.e(TAG, "哈希校验失败", e)
false
}
}
diff --git a/libs/data/build.gradle.kts b/libs/data/build.gradle.kts
index 7d53604..623b7ef 100644
--- a/libs/data/build.gradle.kts
+++ b/libs/data/build.gradle.kts
@@ -22,6 +22,11 @@ android {
}
}
+ksp {
+ // Room 导出的 schema 基线目录(需纳入版本控制,用于迁移测试)
+ arg("room.schemaLocation", "$projectDir/schemas")
+}
+
dependencies {
implementation(project(":core"))
diff --git a/libs/data/schemas/cn.yzjtiantian.android.data.AppDatabase/1.json b/libs/data/schemas/cn.yzjtiantian.android.data.AppDatabase/1.json
new file mode 100644
index 0000000..747d6b0
--- /dev/null
+++ b/libs/data/schemas/cn.yzjtiantian.android.data.AppDatabase/1.json
@@ -0,0 +1,570 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 1,
+ "identityHash": "7bf2833a7cfd4057c0113fd26f27429a",
+ "entities": [
+ {
+ "tableName": "workspaces",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `master_chat_id` INTEGER NOT NULL, `icon` TEXT, `created_at` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "masterChatId",
+ "columnName": "master_chat_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "icon",
+ "columnName": "icon",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "channels",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `workspace_id` INTEGER NOT NULL, `chat_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `category` TEXT NOT NULL, `position` INTEGER NOT NULL, `topic` TEXT, `space_type` TEXT NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "workspaceId",
+ "columnName": "workspace_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "chatId",
+ "columnName": "chat_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "category",
+ "columnName": "category",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "topic",
+ "columnName": "topic",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "spaceType",
+ "columnName": "space_type",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_channels_workspace_id_chat_id",
+ "unique": true,
+ "columnNames": [
+ "workspace_id",
+ "chat_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_channels_workspace_id_chat_id` ON `${TABLE_NAME}` (`workspace_id`, `chat_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "roles",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `workspace_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `color` TEXT)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "workspaceId",
+ "columnName": "workspace_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "color",
+ "columnName": "color",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "contact_roles",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_id` INTEGER NOT NULL, `role_id` INTEGER NOT NULL, `workspace_id` INTEGER NOT NULL, PRIMARY KEY(`contact_id`, `role_id`))",
+ "fields": [
+ {
+ "fieldPath": "contactId",
+ "columnName": "contact_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "roleId",
+ "columnName": "role_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "workspaceId",
+ "columnName": "workspace_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "contact_id",
+ "role_id"
+ ]
+ }
+ },
+ {
+ "tableName": "pins",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `workspace_id` INTEGER NOT NULL, `channel_chat_id` INTEGER NOT NULL, `msg_id` INTEGER NOT NULL, `pinned_by` INTEGER NOT NULL, `pinned_at` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "workspaceId",
+ "columnName": "workspace_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "channelChatId",
+ "columnName": "channel_chat_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "msgId",
+ "columnName": "msg_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pinnedBy",
+ "columnName": "pinned_by",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pinnedAt",
+ "columnName": "pinned_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_pins_channel_chat_id_msg_id",
+ "unique": true,
+ "columnNames": [
+ "channel_chat_id",
+ "msg_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_pins_channel_chat_id_msg_id` ON `${TABLE_NAME}` (`channel_chat_id`, `msg_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "cards",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `workspace_id` INTEGER NOT NULL, `channel_chat_id` INTEGER NOT NULL, `msg_id` INTEGER, `type` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `status` TEXT NOT NULL, `assignee_contact_id` INTEGER, `due_date` INTEGER, `created_by` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `position` INTEGER NOT NULL, `source_msg_id` INTEGER)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "workspaceId",
+ "columnName": "workspace_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "channelChatId",
+ "columnName": "channel_chat_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "msgId",
+ "columnName": "msg_id",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "assigneeContactId",
+ "columnName": "assignee_contact_id",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "dueDate",
+ "columnName": "due_date",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "createdBy",
+ "columnName": "created_by",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sourceMsgId",
+ "columnName": "source_msg_id",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_cards_workspace_id_channel_chat_id",
+ "unique": false,
+ "columnNames": [
+ "workspace_id",
+ "channel_chat_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_cards_workspace_id_channel_chat_id` ON `${TABLE_NAME}` (`workspace_id`, `channel_chat_id`)"
+ },
+ {
+ "name": "index_cards_status",
+ "unique": false,
+ "columnNames": [
+ "status"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_cards_status` ON `${TABLE_NAME}` (`status`)"
+ },
+ {
+ "name": "index_cards_assignee_contact_id",
+ "unique": false,
+ "columnNames": [
+ "assignee_contact_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_cards_assignee_contact_id` ON `${TABLE_NAME}` (`assignee_contact_id`)"
+ },
+ {
+ "name": "index_cards_msg_id",
+ "unique": false,
+ "columnNames": [
+ "msg_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_cards_msg_id` ON `${TABLE_NAME}` (`msg_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "inbox_events",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `workspace_id` INTEGER NOT NULL, `type` TEXT NOT NULL, `source_chat_id` INTEGER NOT NULL, `msg_id` INTEGER, `actor_id` INTEGER NOT NULL, `actor_name` TEXT NOT NULL, `summary` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `read_at` INTEGER)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "workspaceId",
+ "columnName": "workspace_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sourceChatId",
+ "columnName": "source_chat_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "msgId",
+ "columnName": "msg_id",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "actorId",
+ "columnName": "actor_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "actorName",
+ "columnName": "actor_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "summary",
+ "columnName": "summary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "readAt",
+ "columnName": "read_at",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_inbox_events_workspace_id_read_at",
+ "unique": false,
+ "columnNames": [
+ "workspace_id",
+ "read_at"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_inbox_events_workspace_id_read_at` ON `${TABLE_NAME}` (`workspace_id`, `read_at`)"
+ },
+ {
+ "name": "index_inbox_events_created_at",
+ "unique": false,
+ "columnNames": [
+ "created_at"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_inbox_events_created_at` ON `${TABLE_NAME}` (`created_at`)"
+ }
+ ]
+ },
+ {
+ "tableName": "activities",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `workspace_id` INTEGER NOT NULL, `channel_chat_id` INTEGER, `actor_id` INTEGER NOT NULL, `actor_name` TEXT NOT NULL, `action` TEXT NOT NULL, `target_type` TEXT NOT NULL, `target_id` INTEGER NOT NULL, `payload` TEXT, `created_at` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "workspaceId",
+ "columnName": "workspace_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "channelChatId",
+ "columnName": "channel_chat_id",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "actorId",
+ "columnName": "actor_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "actorName",
+ "columnName": "actor_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "action",
+ "columnName": "action",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "targetType",
+ "columnName": "target_type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "targetId",
+ "columnName": "target_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "payload",
+ "columnName": "payload",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "createdAt",
+ "columnName": "created_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_activities_workspace_id_created_at",
+ "unique": false,
+ "columnNames": [
+ "workspace_id",
+ "created_at"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_activities_workspace_id_created_at` ON `${TABLE_NAME}` (`workspace_id`, `created_at`)"
+ },
+ {
+ "name": "index_activities_channel_chat_id_created_at",
+ "unique": false,
+ "columnNames": [
+ "channel_chat_id",
+ "created_at"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_activities_channel_chat_id_created_at` ON `${TABLE_NAME}` (`channel_chat_id`, `created_at`)"
+ }
+ ]
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7bf2833a7cfd4057c0113fd26f27429a')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/libs/data/src/main/java/cn/yzjtiantian/android/data/AppDatabase.kt b/libs/data/src/main/java/cn/yzjtiantian/android/data/AppDatabase.kt
index 90b5979..d646d82 100644
--- a/libs/data/src/main/java/cn/yzjtiantian/android/data/AppDatabase.kt
+++ b/libs/data/src/main/java/cn/yzjtiantian/android/data/AppDatabase.kt
@@ -32,7 +32,9 @@ import cn.yzjtiantian.android.data.entity.WorkspaceEntity
ActivityEntity::class,
],
version = 1,
- exportSchema = false,
+ // 导出 schema 基线(生成到 libs/data/schemas/),供后续版本做迁移校验;
+ // 改 schema 时必须同步 version 与 Migration,否则升级会抛 IllegalStateException。
+ exportSchema = true,
)
abstract class AppDatabase : RoomDatabase() {
abstract fun workspaceDao(): WorkspaceDao
diff --git a/libs/data/src/main/java/cn/yzjtiantian/android/data/repository/PeytRepository.kt b/libs/data/src/main/java/cn/yzjtiantian/android/data/repository/PeytRepository.kt
index 0b21fee..196ed94 100644
--- a/libs/data/src/main/java/cn/yzjtiantian/android/data/repository/PeytRepository.kt
+++ b/libs/data/src/main/java/cn/yzjtiantian/android/data/repository/PeytRepository.kt
@@ -339,7 +339,8 @@ class PeytRepository(
"https://peyt.yzjtiantian.cn/",
"https://i.delta.chat/",
)
- android.util.Log.d("PEYT", "[secure_join] in=$qr -> out=$normalized")
+ // 不打印邀请串(含加入凭据),仅记录是否发生了域名规范化
+ android.util.Log.d("PEYT", "[secure_join] normalized=${normalized != qr}")
val result = rpc.callRaw("secure_join", JSONArray().put(accountId()).put(normalized)) as? Number
?: throw RpcException("secure_join returned no id")
return result.toLong()
diff --git a/rust/peytchat-plugins/src/lib.rs b/rust/peytchat-plugins/src/lib.rs
index 4c811d1..e583258 100644
--- a/rust/peytchat-plugins/src/lib.rs
+++ b/rust/peytchat-plugins/src/lib.rs
@@ -23,6 +23,26 @@ pub enum PluginError {
pub type Result = std::result::Result;
+/// 校验插件名:仅允许 ASCII 字母/数字与 `-` `_` `.`,且不得为 `.` / `..`。
+/// 防止 `base_dir.join(name)` 或拼 URL 时被 `..`/路径分隔符污染。
+fn is_safe_name(name: &str) -> bool {
+ !name.is_empty()
+ && name != "."
+ && name != ".."
+ && name
+ .chars()
+ .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
+}
+
+/// 校验 ZIP 条目相对路径:拒绝绝对路径、盘符、反斜杠与 `.` / `..` 段。
+fn is_safe_rel_path(rel: &str) -> bool {
+ !rel.is_empty()
+ && !rel.starts_with('/')
+ && !rel.contains(':')
+ && !rel.contains('\\')
+ && rel.split('/').all(|seg| !seg.is_empty() && seg != "." && seg != "..")
+}
+
/// A plugin entry from the GitHub registry.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistryPlugin {
@@ -95,6 +115,9 @@ impl PluginManager {
/// Install a plugin by name from the registry.
/// Plugin files live at plugins///.
pub fn install_plugin(&self, name: &str) -> Result {
+ if !is_safe_name(name) {
+ return Err(PluginError::Plugin(format!("非法插件名: {name}")));
+ }
let author = self.find_author(name)?;
let manifest_url = format!("{}/{}/{}/plugin.json", self.raw_base, author, name);
let resp = reqwest::blocking::get(&manifest_url)
@@ -106,6 +129,10 @@ impl PluginManager {
.json()
.map_err(|e| PluginError::Json(format!("解析插件 {name} 清单失败: {e}")))?;
+ if !is_safe_rel_path(&plugin.entry) {
+ return Err(PluginError::Plugin(format!("非法入口路径: {}", plugin.entry)));
+ }
+
let dir = self.base_dir.join(name);
fs::create_dir_all(&dir).map_err(|e| PluginError::Io(e.to_string()))?;
@@ -176,6 +203,10 @@ impl PluginManager {
let plugin: RegistryPlugin = serde_json::from_str(&manifest_str)
.map_err(|e| PluginError::Json(format!("解析 plugin.json 失败: {e}")))?;
+ if !is_safe_name(&plugin.name) {
+ return Err(PluginError::Plugin(format!("非法插件名: {}", plugin.name)));
+ }
+
let dst = self.base_dir.join(&plugin.name);
if dst.exists() {
fs::remove_dir_all(&dst).map_err(|e| PluginError::Io(e.to_string()))?;
@@ -194,7 +225,14 @@ impl PluginManager {
if rel_path.is_empty() {
continue;
}
+ // 防路径穿越:拒绝 `..` / 绝对路径 / 盘符,并二次确认落点在 dst 之内
+ if !is_safe_rel_path(&rel_path) {
+ return Err(PluginError::Plugin(format!("ZIP 包含非法路径: {name}")));
+ }
let target = dst.join(&rel_path);
+ if !target.starts_with(&dst) {
+ return Err(PluginError::Plugin(format!("ZIP 路径越界: {name}")));
+ }
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|e| PluginError::Io(e.to_string()))?;
}
@@ -210,6 +248,9 @@ impl PluginManager {
/// Remove a plugin directory.
pub fn uninstall_plugin(&self, name: &str) -> Result<()> {
+ if !is_safe_name(name) {
+ return Err(PluginError::Plugin(format!("非法插件名: {name}")));
+ }
let dir = self.base_dir.join(name);
if !dir.exists() {
return Err(PluginError::Plugin(format!("插件 {name} 未安装")));
diff --git a/scripts/build-android.sh b/scripts/build-android.sh
index 366ea96..e5319ae 100755
--- a/scripts/build-android.sh
+++ b/scripts/build-android.sh
@@ -1,8 +1,20 @@
#!/usr/bin/env bash
#
# 用法:
-# ./scripts/build-android.sh [release|debug]
-# release: 构建 release 版本 (默认 debug)
+# ./scripts/build-android.sh [release|debug] [abi1,abi2,...]
+# release|debug 构建类型,默认 debug
+# abi1,abi2,... 逗号分隔的目标 ABI,默认 arm64-v8a
+# 可选: arm64-v8a | armeabi-v7a | x86 | x86_64
+#
+# 示例:
+# ./scripts/build-android.sh release arm64-v8a,x86_64 # 真机 + x86_64 模拟器
+# PEYT_ABIS=x86_64 ./scripts/build-android.sh debug # 仅 x86_64 模拟器
+#
+# 说明:
+# 对每个 ABI 依次执行:编译 deltachat_ffi → 用 lib-patcher 生成 patched 静态库
+# → 编译 peytchat-bridge → 复制 libpeytchat_bridge.so 到 jniLibs//。
+# app/build.gradle.kts 会按 jniLibs 中实际存在的 ABI 限定 abiFilters,二者需一致;
+# 否则 APK 会声明未构建的架构,导致对应设备上「安装成功但启动即崩」。
set -euo pipefail
@@ -36,14 +48,24 @@ check_command() {
}
PROFILE="${1:-debug}"
-NDK_ABI="arm64-v8a"
+ABI_LIST="${2:-${PEYT_ABIS:-arm64-v8a}}"
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
CARGO_ARGS=()
+IFS=',' read -r -a ABIS <<< "$ABI_LIST"
+if [[ "${#ABIS[@]}" -eq 0 || -z "${ABIS[0]}" ]]; then
+ error "目标 ABI 列表为空"
+ exit 1
+fi
+
info "检查必要的工具..."
check_command "cargo"
-check_command "adb"
-check_command "zip" || warn "zip 命令未安装,部分验证功能可能不可用"
+if ! command -v adb &> /dev/null; then
+ warn "adb 未安装,将跳过安装到设备(CI 环境正常)"
+fi
+if ! command -v zip &> /dev/null; then
+ warn "zip 命令未安装,部分验证功能可能不可用"
+fi
if [[ "$PROFILE" == "release" ]]; then
CARGO_ARGS+=(--release)
@@ -112,13 +134,11 @@ fi
CORE_DIR="$PROJECT_ROOT/core"
RUST_DIR="$PROJECT_ROOT/rust"
-JNI_LIBS_DIR="$PROJECT_ROOT/app/src/main/jniLibs/$NDK_ABI"
-TARGET_DIR="$PROJECT_ROOT/rust/target/aarch64-linux-android/$RUST_PROFILE"
info "项目根目录: $PROJECT_ROOT"
-info "NDK ABI: $NDK_ABI"
+info "目标 ABI: ${ABIS[*]}"
-info "步骤 1/5: 检查 deltachat_ffi..."
+info "步骤 1/3: 检查 deltachat_ffi 与 core 子模块..."
if [[ ! -d "$CORE_DIR" ]]; then
error "core 目录不存在: $CORE_DIR"
@@ -126,8 +146,7 @@ if [[ ! -d "$CORE_DIR" ]]; then
exit 1
fi
-cd "$CORE_DIR"
-if [[ ! -f "Cargo.toml" ]]; then
+if [[ ! -f "$CORE_DIR/Cargo.toml" ]]; then
error "core/Cargo.toml 不存在"
exit 1
fi
@@ -154,126 +173,122 @@ if [[ -d "$PATCH_DIR" ]]; then
done
fi
-info "构建 deltachat_ffi ($RUST_PROFILE)..."
-if cargo ndk -t "$NDK_ABI" build "${CARGO_ARGS[@]}" -p deltachat_ffi; then
- success "deltachat_ffi 构建成功"
-else
- error "deltachat_ffi 构建失败"
- exit 1
-fi
+# ---------- ABI <-> Rust target triple ----------
+abi_to_triple() {
+ case "$1" in
+ arm64-v8a) echo "aarch64-linux-android" ;;
+ armeabi-v7a) echo "armv7-linux-androideabi" ;;
+ x86) echo "i686-linux-android" ;;
+ x86_64) echo "x86_64-linux-android" ;;
+ *) return 1 ;;
+ esac
+}
-# ---------- 检查并生成 patched 库 ----------
-CORE_LIB="$CORE_DIR/target/aarch64-linux-android/$RUST_PROFILE/libdeltachat.a"
-PATCHED_LIB="$CORE_DIR/target/aarch64-linux-android/$RUST_PROFILE/libdeltachat_patched.a"
+# ---------- 单个 ABI 的完整构建 ----------
+build_abi() {
+ local abi="$1"
+ local triple
+ if ! triple=$(abi_to_triple "$abi"); then
+ error "不支持的 ABI: $abi (可选: arm64-v8a, armeabi-v7a, x86, x86_64)"
+ return 1
+ fi
-if [[ ! -f "$CORE_LIB" ]]; then
- error "deltachat 静态库未找到: $CORE_LIB"
- exit 1
-fi
+ local core_lib="$CORE_DIR/target/$triple/$RUST_PROFILE/libdeltachat.a"
+ local patched_lib="$CORE_DIR/target/$triple/$RUST_PROFILE/libdeltachat_patched.a"
+ local jni_dir="$PROJECT_ROOT/app/src/main/jniLibs/$abi"
-success "deltachat 静态库: $CORE_LIB"
-ls -lh "$CORE_LIB"
+ info "========== ABI: $abi ($triple) =========="
-NEED_PATCH=0
-if [[ ! -f "$PATCHED_LIB" ]]; then
- info "patched 库不存在,需要生成"
- NEED_PATCH=1
-elif [[ "$CORE_LIB" -nt "$PATCHED_LIB" ]]; then
- info "libdeltachat.a 比 patched 库新,需要重新生成"
- NEED_PATCH=1
-else
- info "patched 库已是最新,跳过"
-fi
+ info "[$abi] 编译 deltachat_ffi ($RUST_PROFILE)..."
+ if ! ( cd "$CORE_DIR" && cargo ndk -t "$abi" build "${CARGO_ARGS[@]}" -p deltachat_ffi ); then
+ error "[$abi] deltachat_ffi 构建失败"
+ return 1
+ fi
+ success "[$abi] deltachat_ffi 构建成功"
-if [[ "$NEED_PATCH" -eq 1 ]]; then
- if ! command -v lib-patcher &> /dev/null; then
- error "lib-patcher 未安装。运行: cargo install lib-patcher"
- exit 1
+ if [[ ! -f "$core_lib" ]]; then
+ error "[$abi] 未找到静态库: $core_lib"
+ return 1
fi
- info "给 libdeltachat.a 打补丁..."
- if lib-patcher \
- --input "$CORE_LIB" \
- --output "$PATCHED_LIB" \
- --keep-prefix "dc_" \
- --triplet aarch64-linux-android; then
- success "补丁完成: $PATCHED_LIB"
- ls -lh "$PATCHED_LIB"
+ ls -lh "$core_lib"
+
+ # 生成 patched 静态库(目标不存在或比源旧时重新生成)
+ local need_patch=0
+ if [[ ! -f "$patched_lib" || "$core_lib" -nt "$patched_lib" ]]; then
+ need_patch=1
+ fi
+ if [[ "$need_patch" -eq 1 ]]; then
+ if ! command -v lib-patcher &> /dev/null; then
+ error "lib-patcher 未安装。运行: cargo install lib-patcher"
+ return 1
+ fi
+ info "[$abi] 用 lib-patcher 生成 patched 静态库..."
+ if lib-patcher \
+ --input "$core_lib" \
+ --output "$patched_lib" \
+ --keep-prefix "dc_" \
+ --triplet "$triple"; then
+ success "[$abi] 补丁完成: $patched_lib"
+ ls -lh "$patched_lib"
+ else
+ error "[$abi] lib-patcher 失败"
+ return 1
+ fi
else
- error "lib-patcher 失败"
- exit 1
+ info "[$abi] patched 库已是最新,跳过"
fi
-fi
-
-info "步骤 2/5: 构建 peytchat-bridge..."
-
-cd "$RUST_DIR"
-if [[ ! -f "Cargo.toml" ]]; then
- error "rust/Cargo.toml 不存在"
- exit 1
-fi
-
-info "构建 peytchat-bridge ($RUST_PROFILE)..."
-if cargo ndk -t "$NDK_ABI" build "${CARGO_ARGS[@]}" -p peytchat-bridge; then
- success "peytchat-bridge 构建成功"
-else
- error "peytchat-bridge 构建失败"
- exit 1
-fi
-
-info "步骤 3/5: 查找并复制 .so 文件..."
-SO_FILE=""
-POSSIBLE_PATHS=(
- "$RUST_DIR/target/aarch64-linux-android/$RUST_PROFILE/libpeytchat_bridge.so"
- "$RUST_DIR/target/$NDK_ABI/$RUST_PROFILE/libpeytchat_bridge.so"
- "/tmp/peytchat-jnilibs/$NDK_ABI/libpeytchat_bridge.so"
-)
-
-for path in "${POSSIBLE_PATHS[@]}"; do
- if [[ -f "$path" ]]; then
- SO_FILE="$path"
- info "找到 .so 文件: $path"
- break
+ info "[$abi] 编译 peytchat-bridge ($RUST_PROFILE)..."
+ if ! ( cd "$RUST_DIR" && cargo ndk -t "$abi" build "${CARGO_ARGS[@]}" -p peytchat-bridge ); then
+ error "[$abi] peytchat-bridge 构建失败"
+ return 1
fi
-done
+ success "[$abi] peytchat-bridge 构建成功"
-if [[ -z "$SO_FILE" ]]; then
- error "找不到 libpeytchat_bridge.so 文件"
- error "搜索路径:"
- for path in "${POSSIBLE_PATHS[@]}"; do
- echo " - $path"
+ local so_file=""
+ local candidates=(
+ "$RUST_DIR/target/$triple/$RUST_PROFILE/libpeytchat_bridge.so"
+ "$RUST_DIR/target/$abi/$RUST_PROFILE/libpeytchat_bridge.so"
+ "/tmp/peytchat-jnilibs/$abi/libpeytchat_bridge.so"
+ )
+ for path in "${candidates[@]}"; do
+ if [[ -f "$path" ]]; then
+ so_file="$path"
+ break
+ fi
done
- exit 1
-fi
-info "步骤 4/5: 复制到 jniLibs..."
+ if [[ -z "$so_file" ]]; then
+ error "[$abi] 找不到 libpeytchat_bridge.so,搜索路径:"
+ for path in "${candidates[@]}"; do
+ echo " - $path"
+ done
+ return 1
+ fi
-mkdir -p "$JNI_LIBS_DIR"
+ mkdir -p "$jni_dir"
+ if cp "$so_file" "$jni_dir/libpeytchat_bridge.so"; then
+ success "[$abi] .so 已复制: $so_file -> $jni_dir/"
+ ls -lh "$jni_dir/libpeytchat_bridge.so"
+ else
+ error "[$abi] 复制 .so 失败"
+ return 1
+ fi
+}
-if cp "$SO_FILE" "$JNI_LIBS_DIR/libpeytchat_bridge.so"; then
- success "复制成功: $SO_FILE -> $JNI_LIBS_DIR/"
- ls -lh "$JNI_LIBS_DIR/libpeytchat_bridge.so"
-else
- error "复制失败"
+if [[ ! -f "$RUST_DIR/Cargo.toml" ]]; then
+ error "rust/Cargo.toml 不存在"
exit 1
fi
-info "步骤 5/5: 验证构建..."
+info "步骤 2/3: 逐 ABI 构建 native 库..."
+for abi in "${ABIS[@]}"; do
+ build_abi "$abi" || exit 1
+done
+info "步骤 3/3: 打包 APK..."
cd "$PROJECT_ROOT"
-if [[ -f "$JNI_LIBS_DIR/libpeytchat_bridge.so" ]]; then
- SO_SIZE=$(du -h "$JNI_LIBS_DIR/libpeytchat_bridge.so" | cut -f1)
- success ".so 文件已就位 (大小: $SO_SIZE)"
-else
- error ".so 文件未在预期位置: $JNI_LIBS_DIR/"
- exit 1
-fi
-
-#info "清理旧的 APK..."
-#./gradlew clean
-
-info "构建 APK ($PROFILE)..."
if ./gradlew assemble${PROFILE^}; then
success "APK 构建成功"
else
@@ -324,13 +339,15 @@ success "构建完成!"
echo "=========================================="
echo "构建信息:"
echo " - 模式: $PROFILE"
-echo " - 架构: $NDK_ABI"
+echo " - 架构: ${ABIS[*]}"
echo " - NDK: $ANDROID_NDK_HOME"
-echo " - .so 文件: $JNI_LIBS_DIR/libpeytchat_bridge.so"
+for abi in "${ABIS[@]}"; do
+ echo " - .so 文件: $PROJECT_ROOT/app/src/main/jniLibs/$abi/libpeytchat_bridge.so"
+done
echo " - APK 文件: $APK_PATH"
echo ""
echo "下一步:"
echo " 1. 安装: adb install $APK_PATH"
echo " 2. 查看日志: adb logcat | grep -E \"cn.yzjtiantian.android|deltachat|rust\""
-echo " 3. 重新构建: ./scripts/build-android.sh [debug|release]"
-echo "=========================================="
\ No newline at end of file
+echo " 3. 重新构建: ./scripts/build-android.sh [debug|release] [abi1,abi2,...]"
+echo "=========================================="
diff --git a/scripts/gen-update-manifest.sh b/scripts/gen-update-manifest.sh
index f9cf3c2..d191adc 100755
--- a/scripts/gen-update-manifest.sh
+++ b/scripts/gen-update-manifest.sh
@@ -27,7 +27,7 @@
# 例如: shell_1.0.1.dex → { "module": "shell", "version": "1.0.1", ... }
#
# APK 说明:
-# --apk 指定 APK 文件后,会自动计算 MD5 和字节数写入顶层 `apk` 字段;
+# --apk 指定 APK 文件后,会自动计算 SHA-256 和字节数写入顶层 `apk` 字段;
# 没有 --apk 时不输出 `apk` 字段(向后兼容,客户端视为无 APK 更新)。
# versionCode 必须比线上已发布版本严格 +1,否则客户端不会提示更新。
#
@@ -88,7 +88,7 @@ if [ -n "$APK_PATH" ]; then
fi
if ! command -v python3 >/dev/null 2>&1; then
- echo "错误: 需要 python3 计算 md5 并生成 JSON" >&2
+ echo "错误: 需要 python3 计算 sha256 并生成 JSON" >&2
exit 1
fi
@@ -112,21 +112,21 @@ for f in files:
print(f"错误: 文件名必须是 _.dex,得到: {name}", file=sys.stderr)
sys.exit(1)
module, version = stem.rsplit("_", 1)
- md5 = hashlib.md5(open(f, "rb").read()).hexdigest()
+ sha256 = hashlib.sha256(open(f, "rb").read()).hexdigest()
url = base_url.rstrip("/") + "/" + name
- patches.append({"module": module, "version": version, "url": url, "md5": md5})
+ patches.append({"module": module, "version": version, "url": url, "sha256": sha256})
-manifest = {"appVersion": "0.1.0", "patches": patches}
+manifest = {"appVersion": apk_vname or "0.0.0", "patches": patches}
if apk_path:
apk_url = base_url.rstrip("/") + "/" + os.path.basename(apk_path)
- apk_md5 = hashlib.md5(open(apk_path, "rb").read()).hexdigest()
+ apk_sha256 = hashlib.sha256(open(apk_path, "rb").read()).hexdigest()
apk_size = os.path.getsize(apk_path)
apk = {
"versionCode": int(apk_vcode),
"versionName": apk_vname,
"url": apk_url,
- "md5": apk_md5,
+ "sha256": apk_sha256,
"size": apk_size,
}
if apk_notes_file:
@@ -147,10 +147,10 @@ if apk_path:
a = manifest["apk"]
print(f" apk : v{a['versionName']} (versionCode={a['versionCode']})")
print(f" url : {a['url']}")
- print(f" md5 : {a['md5']}")
+ print(f" sha256: {a['sha256']}")
print(f" size: {a['size']} bytes")
for p in patches:
print(f" {p['module']} v{p['version']}")
print(f" url : {p['url']}")
- print(f" md5 : {p['md5']}")
+ print(f" sha256: {p['sha256']}")
PY