build(android): release 改用正式签名,并禁止静默降级

改的是什么:client/android/app/build.gradle.kts 此前是 Flutter 模板原样 ——
release 一直用 signingConfigs.getByName("debug"),即公开的 AOSP 测试密钥。
三条后果且都不报错:
  ① 任何人都能签一个同签名的包冒充升级
  ② 无法上架(Play 拒绝 debug 签名)
  ③ 换正式密钥后,已装设备无法覆盖升级(不可逆)

怎么改:
- 新增 signingConfigs.release,凭据从 client/android/key.properties 读
  (该文件已在 .gitignore 中,确认 check-ignore 命中)
- buildTypes.release 指向它
- task-graph 守卫:release 构建缺 key.properties 时**明确失败**并打印修复指引
  刻意不学模板「回落 debug 签名」—— 那是一条「看着构建成功、其实签的是公开密钥」
  的静默失败路径。本仓库多次栽在静默失败上(迁移从不执行、首次部署路径无人测),
  故这里宁可让它红。

验证:
- 变异:移走 key.properties → release 构建 2 秒内失败并给出指引
- 放回 → 构建成功;apksigner 确认 DN=CN=InkWatch,指纹与 keystore 一致
  SHA-256 0d58f8b5749b6a60c8042f6f4333a0c847192bf37d44e56cdd4756f3970ad679

密钥:~/keystores/inkwatch-release.jks(仓库外,PKCS12/RSA2048,有效期至 2054),
凭据记录在同目录一个 600 权限的文件里。
⚠️ 这是本仓库唯一不可再生的东西 —— 丢了就永远无法更新已发布的应用。

新增 docs/release-and-signing.md:密钥信息、key.properties 契约、出包命令、
两个包名的分工、换签名的后果与恢复步骤。
This commit is contained in:
PMon Dev committed 2026-09-16 21:41:45 +08:00
1 parent 158ae7577e
commit 0777e55cf5
2 files changed
+170 -4

No files matched your search

+61 -4
View File
@@ -1,9 +1,28 @@
import java.io.FileInputStream
import java.util.Properties
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
// ── release 签名:正式密钥,且**禁止静默降级** ──────────────────────
// 口令等敏感值放在 `client/android/key.properties`(已被 .gitignore 排除)。
//
// 背景(2026-09-16 修的):本文件此前一直是 Flutter 模板的原样 ——
// buildTypes.release { signingConfig = signingConfigs.getByName("debug") }
// 那等于用**公开的 AOSP 测试密钥**签名:谁都能签一个同签名的包冒充升级,
// 而且无法上架。更糟的是它**不报错**,构建看起来一切正常。
// 故这里改成:走正式密钥;缺配置时 release 构建**明确失败**(见文件末尾的守卫),
// 而不是像模板那样回落 debug —— 静默失败比失败本身更贵。
val keystorePropsFile = rootProject.file("key.properties")
val keystoreProps = Properties().apply {
if (keystorePropsFile.exists()) {
FileInputStream(keystorePropsFile).use { load(it) }
}
}
android {
namespace = "com.pmon.pmon_client"
compileSdk = flutter.compileSdkVersion
@@ -14,8 +33,21 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}
signingConfigs {
create("release") {
if (keystorePropsFile.exists()) {
storeFile = file(keystoreProps.getProperty("storeFile"))
storePassword = keystoreProps.getProperty("storePassword")
keyAlias = keystoreProps.getProperty("keyAlias")
keyPassword = keystoreProps.getProperty("keyPassword")
}
}
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
// ⚠️ 这是**主干(通用版)**的包名,已装机不可更改语义,见 docs/rename-inkwatch.md。
// 水墨屏专属版(feature/eink 分支)刻意用**另一个** applicationId,
// 否则两个版本在同一台手机上会互相覆盖(且 versionCode 小的会被当成降级)。
applicationId = "com.pmon.pmon_client"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
@@ -31,13 +63,38 @@ android {
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
signingConfig = signingConfigs.getByName("release")
}
}
}
// release 构建缺 key.properties 时**明确失败**。
//
// 为什么不改回"回落到 debug 签名":Flutter 模板默认那么干,结果是
// "看着构建成功、其实签的是公开测试密钥" —— 一条谁也发现不了的静默失败路径。
// 这里宁可让它红。
gradle.taskGraph.whenReady {
val needsSigning = allTasks.any {
it.name == "assembleRelease" || it.name == "bundleRelease"
}
if (needsSigning && !keystorePropsFile.exists()) {
throw GradleException(
"""
|release 构建需要签名配置,但 ${keystorePropsFile.path} 不存在。
|
|请创建该文件(已被 .gitignore 排除,**不要提交**):
| storeFile=/绝对路径/inkwatch-release.jks
| storePassword=...
| keyAlias=...
| keyPassword=...
|
|注意:不要为了"先跑起来"而回落到 debug 签名 —— 那是公开密钥,
|任何人都能签一个同签名的包冒充你的应用升级。
""".trimMargin()
)
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
+109
View File
@@ -0,0 +1,109 @@
# 发布与签名(墨观 InkWatch · Android)
> 2026-09-16 立。此前 release 一直用 Flutter 模板的默认行为 —— **debug 签名**。
## ⚠️ 为什么签名这件事必须先说
`client/android/app/build.gradle.kts` 里原本是模板原样:
```kotlin
buildTypes { release { signingConfig = signingConfigs.getByName("debug") } }
```
后果有三条,且**都不报错**:
1. **debug 密钥是公开的**(AOSP 测试密钥,人人都有)→ 任何人都能签一个
"同签名"的包,冒充你的应用升级;
2. **无法上架**(Google Play 拒绝 debug 签名的包);
3. 一旦改用正式密钥,**已装的 debug 签名版本无法覆盖升级** —— Android 只认签名一致的包。
第 3 条是不可逆的:**越早锁正式签名越好**。故本次一并改造。
## 一、密钥(keystore)
| 项 | 值 |
|---|---|
| 路径 | `~/keystores/inkwatch-release.jks`(**仓库外**) |
| 别名 | `inkwatch` |
| 类型 / 长度 | PKCS12 · RSA 2048 |
| 有效期 | 至 **2054-02-01** |
| SHA-256 指纹 | `0D:58:F8:B5:74:9B:6A:60:C8:04:2F:6F:43:33:A0:C8:47:19:2B:F3:7D:44:E5:6C:DD:47:56:F3:97:0A:D6:79` |
| 凭据记录 | `~/keystores/inkwatch-release-CREDENTIALS.txt`(权限 600) |
> ⚠️⚠️ **keystore 与口令必须永久备份(离线副本、异地各一份)。**
> 它们丢了就**永远无法更新已发布的应用** —— Android 的签名校验不可绕过,
> 只能换包名重新上架、老用户全部流失。这是本仓库**唯一不可再生**的东西。
对外只公布**指纹**,绝不公布 keystore 与口令。
## 二、`key.properties` 契约
release 构建从 `client/android/key.properties` 读凭据(**已被 `.gitignore` 排除**):
```properties
storeFile=/绝对路径/inkwatch-release.jks
storePassword=...
keyAlias=inkwatch
keyPassword=...
```
**缺这个文件时,release 构建会明确失败**(`build.gradle.kts` 末尾的 task-graph 守卫),
报错信息里带着修复指引。
> 为什么不学 Flutter 模板"回落到 debug 签名":那会造出一条
> **"看着构建成功、其实签的是公开密钥"** 的静默失败路径。
> 本仓库多次栽在静默失败上(迁移从不执行、首次部署路径无人测),故这里宁可让它红。
变异验证:移走 `key.properties` → `flutter build apk --release` 在 **2 秒内失败**并打印指引;
放回 → 构建成功。签名指纹确认为上表值(`apksigner verify --print-certs`)。
## 三、出包
```bash
cd client
# 通用版(主干 main)
git checkout main
flutter build apk --release --target-platform android-arm64 \
--dart-define=PMON_DEFAULT_SERVER=https://<你的服务器>
cp build/app/outputs/flutter-apk/app-release.apk \
../dist/inkwatch-arm64-$(grep -E '^version:' pubspec.yaml | cut -d' ' -f2 | cut -d+ -f1).apk
# 水墨屏版(分支 feature/eink)—— 包名不同,产出也另起名
git checkout feature/eink
flutter build apk --release --target-platform android-arm64 ...
cp build/app/outputs/flutter-apk/app-release.apk \
../dist/inkwatch-eink-arm64-$(...).apk
```
**验证签名**(每次发版都要做一次,5 秒):
```bash
~/Library/Android/sdk/build-tools/*/apksigner verify --print-certs dist/<包>.apk | grep -E "DN:|SHA-256"
# 期望 DN: CN=InkWatch, OU=Mobile, O=InkWatch, L=Shenzhen, ST=Guangdong, C=CN
```
## 四、两个包名的分工(**刻意不同**)
| 版本 | 分支 | applicationId | 说明 |
|---|---|---|---|
| 通用版 | `main` | `com.pmon.pmon_client` | 已装机的包名,**语义上不可更改**(改了等于另一个应用) |
| 水墨屏版 | `feature/eink` | `com.inkwatch.eink` | 刻意独立 → 两个版本**可在同一台手机上共存**、各自升级 |
**为什么必须分开**:两条分支的 `versionCode` 各自递增(1.0.9+9 vs 1.1.2+12)。
若共用一个包名,它们会互相覆盖 —— 且 versionCode 小的那个会被系统当成**降级**而拒绝安装。
两版并行的产品线(通用版给用户、水墨屏版自用/专用)必须各占一个包名。
> 注:`namespace`(Kotlin 包名)两版仍是 `com.pmon.pmon_client` —— 它只影响
> R 类与源码包声明,与"装成哪个应用"无关,故不动以免大改 Kotlin 源码。
## 五、换签名的后果(一次性)
本次换正式签名后,此前用 debug 密钥装的设备(如 F50)**无法覆盖升级**,需要:
```bash
adb uninstall com.pmon.pmon_client # 或水墨屏版对应包名
adb install dist/inkwatch-arm64-<版本>.apk
```
**数据不会丢**:`device_id` 自 1.0.9 起只由硬件标识(ANDROID_ID)派生,
且服务端支持设备归并 —— 重装后重新登录并认领即可,历史帧仍在原设备名下。