From ea4225872bdf16cb0518bb886274d6b875a7348f Mon Sep 17 00:00:00 2001 From: MaherJon Date: Sun, 18 Oct 2026 14:29:34 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=86=20audit=20=E5=8F=91?= =?UTF-8?q?=E7=8E=B0=E7=9A=84=E9=97=AE=E9=A2=98=EF=BC=8C=E4=B8=BB=E8=A6=81?= =?UTF-8?q?=E5=8C=85=E6=8B=AC=EF=BC=9A=20-=20=E4=BF=AE=E5=A4=8D=E4=BA=86?= =?UTF-8?q?=20AndroidManifest.xml=20=E4=B8=AD=E7=9A=84=E6=9D=83=E9=99=90?= =?UTF-8?q?=E5=A3=B0=E6=98=8E=E9=97=AE=E9=A2=98=EF=BC=8C=E7=A1=AE=E4=BF=9D?= =?UTF-8?q?=E5=8F=AA=E8=AF=B7=E6=B1=82=E5=BF=85=E8=A6=81=E7=9A=84=E6=9D=83?= =?UTF-8?q?=E9=99=90=20-=20=E5=88=A0=E9=99=A4=E4=BA=86=E4=B8=8D=E5=BF=85?= =?UTF-8?q?=E8=A6=81=E7=9A=84=E7=BD=91=E7=BB=9C=E5=AE=89=E5=85=A8=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=96=87=E4=BB=B6=EF=BC=8C=E7=AE=80=E5=8C=96=E4=BA=86?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E7=9A=84=E5=AE=89=E5=85=A8=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/android-build.yml | 78 +++ app/build.gradle.kts | 32 +- app/src/main/AndroidManifest.xml | 3 +- .../cn/yzjtiantian/android/MainActivity.kt | 4 +- .../main/res/xml/network_security_config.xml | 13 - docs/hot-update.md | 30 +- docs/onboarding.md | 426 +++++++++++++ docs/update-workflow.md | 15 +- .../android/feature/shell/ChatScreen.kt | 5 +- .../android/feature/shell/ShellScreen.kt | 24 +- .../android/core/ApkUpdateManager.kt | 46 +- .../yzjtiantian/android/core/EventBridge.kt | 5 +- .../android/core/HotUpdateManager.kt | 101 +++- libs/data/build.gradle.kts | 5 + .../1.json | 570 ++++++++++++++++++ .../yzjtiantian/android/data/AppDatabase.kt | 4 +- .../android/data/repository/PeytRepository.kt | 3 +- rust/peytchat-plugins/src/lib.rs | 41 ++ scripts/build-android.sh | 245 ++++---- scripts/gen-update-manifest.sh | 18 +- 20 files changed, 1454 insertions(+), 214 deletions(-) create mode 100644 .github/workflows/android-build.yml delete mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 docs/onboarding.md create mode 100644 libs/data/schemas/cn.yzjtiantian.android.data.AppDatabase/1.json 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