Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
name: CI

on:
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
protocol-test:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout without persistent credentials
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Temurin 25.0.4.1+1
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
with:
distribution: temurin
java-version: '25.0.4+1.1'
- name: Assert JDK version
shell: bash
run: |
set -euo pipefail
version="$(java -version 2>&1)"
printf '%s\n' "$version"
grep -F '25.0.4.1+1' <<<"$version"
- name: Compile and run ProtocolTest
shell: bash
run: |
set -euo pipefail
out="$RUNNER_TEMP/prototest"
javac --release 17 -encoding UTF-8 -Xlint:all -Werror -d "$out" \
ime/src/org/modavd/ime/HostProtocol.java ime/test/ProtocolTest.java
java -cp "$out" org.modavd.ime.ProtocolTest

apk-build:
runs-on: windows-2025
timeout-minutes: 20
steps:
- name: Checkout without persistent credentials
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Temurin 25.0.4.1+1
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
with:
distribution: temurin
java-version: '25.0.4+1.1'
- name: Assert JDK version
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$version = (& java -version 2>&1 | Out-String)
$version
if ($version -notmatch '25\.0\.4\.1\+1') { throw "需要 Temurin 25.0.4.1+1,实际是:$version" }
- name: Check android.jar and build APKs
shell: pwsh
env:
TZ: CET-1
run: |
$ErrorActionPreference = 'Stop'
$jar = Join-Path $env:ANDROID_HOME 'platforms\android-36\android.jar'
if (-not (Test-Path -LiteralPath $jar -PathType Leaf)) { throw "找不到 $jar" }
$hash = (Get-FileHash -LiteralPath $jar -Algorithm SHA256).Hash.ToLowerInvariant()
if ($hash -ne 'd9eb9da824d9e247a352f570f01e1169e725b2954bca9e283a71786c59b59f9a') {
throw "android.jar SHA-256 不符:$hash"
}
pwsh -NoProfile -File .\build.ps1 -BuildTools (Join-Path $env:ANDROID_HOME 'build-tools\36.1.0') -AndroidJar $jar

privacy:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout without persistent credentials
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Scan tracked files for private data
shell: bash
run: python3 -B -I -X utf8 scripts/privacy.py . --allowlist scripts/privacy-allowlist.json

authors:
runs-on: ubuntu-24.04
timeout-minutes: 10
env:
CHECK_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }}
CHECK_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
CHECK_MANUAL: ${{ github.event_name == 'workflow_dispatch' && '1' || '0' }}
steps:
- name: Checkout full history without persistent credentials
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Run repo checks unit tests
shell: bash
run: python3 -B -I -X utf8 scripts/test_repo_checks.py -v
- name: Check commit authors and trailers
shell: bash
run: python3 -B -I -X utf8 scripts/repo_checks.py
30 changes: 0 additions & 30 deletions .github/workflows/test.yml

This file was deleted.

131 changes: 131 additions & 0 deletions .github/workflows/verify-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
name: Verify release APK

on:
workflow_dispatch:
inputs:
tag:
description: Tag whose draft release should be checked (for example v1.0.0)
required: true
type: string
release:
types: [published]

permissions:
contents: read

jobs:
fetch-release:
if: github.event_name == 'workflow_dispatch' || github.event_name == 'release'
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: write
env:
TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name }}
steps:
- name: Validate tag input
shell: bash
run: |
set -euo pipefail
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "非法 tag:$TAG" >&2
exit 1
fi
- name: Download release assets
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/release-assets"
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
--pattern modavd-ime.apk --pattern SHA256SUMS.txt \
--dir "$RUNNER_TEMP/release-assets"
- name: Upload release assets for read-only job
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-assets
path: ${{ runner.temp }}/release-assets
if-no-files-found: error

verify:
needs: fetch-release
runs-on: windows-2025
timeout-minutes: 25
permissions:
contents: read
env:
TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name }}
steps:
- name: Validate tag input
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
if ($env:TAG -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+$') { throw "非法 tag:$env:TAG" }
- name: Checkout tag without persistent credentials
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ env.TAG }}
fetch-depth: 0
persist-credentials: false
- name: Set up Temurin 25.0.4.1+1
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
with:
distribution: temurin
java-version: '25.0.4+1.1'
- name: Assert JDK version
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$version = (& java -version 2>&1 | Out-String)
$version
if ($version -notmatch '25\.0\.4\.1\+1') { throw "需要 Temurin 25.0.4.1+1,实际是:$version" }
- name: Build unsigned APK with the historical timezone
shell: pwsh
env:
TZ: CET-1
run: |
$ErrorActionPreference = 'Stop'
$jar = Join-Path $env:ANDROID_HOME 'platforms\android-36\android.jar'
$hash = (Get-FileHash -LiteralPath $jar -Algorithm SHA256).Hash.ToLowerInvariant()
if ($hash -ne 'd9eb9da824d9e247a352f570f01e1169e725b2954bca9e283a71786c59b59f9a') { throw "android.jar SHA-256 不符:$hash" }
pwsh -NoProfile -File .\build.ps1 -BuildTools (Join-Path $env:ANDROID_HOME 'build-tools\36.1.0') -AndroidJar $jar
- name: Sign the build with a one-time RSA-4096 key
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$dir = Join-Path $env:RUNNER_TEMP 'verify-release-key'
New-Item -ItemType Directory -Force $dir | Out-Null
$pass = Join-Path $dir 'pass.txt'
Set-Content -LiteralPath $pass -Value 'temporary-ci-password' -NoNewline -Encoding ascii
$ks = Join-Path $dir 'temporary-ci.p12'
# pwsh does not stop on a native command's exit code; check each one.
& (Join-Path $env:JAVA_HOME 'bin\keytool.exe') -genkeypair -noprompt -keystore $ks -storetype PKCS12 -alias ci -keyalg RSA -keysize 4096 -validity 1 -dname 'CN=Temporary CI' -storepass:file $pass -keypass:file $pass
if ($LASTEXITCODE -ne 0) { throw "keytool 失败(退出码 $LASTEXITCODE)" }
$apksigner = Join-Path $env:ANDROID_HOME 'build-tools\36.1.0\apksigner.bat'
$aligned = Join-Path $PWD 'out\obj\ime\aligned.apk'
$signed = Join-Path $dir 'ci-signed.apk'
& $apksigner sign --ks $ks --ks-type PKCS12 --ks-key-alias ci --ks-pass "file:$pass" --v1-signing-enabled false --v2-signing-enabled true --v3-signing-enabled true --v4-signing-enabled false --out $signed $aligned
if ($LASTEXITCODE -ne 0) { throw "apksigner sign 失败(退出码 $LASTEXITCODE)" }
"BUILT_SIGNED=$signed" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"APKSIGNER=$apksigner" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
"KEYTOOL=$(Join-Path $env:JAVA_HOME 'bin\keytool.exe')" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Download release assets
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-assets
path: ${{ runner.temp }}/release-assets
- name: Check release checksum and APK provenance
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$asset = Join-Path $env:RUNNER_TEMP 'release-assets\modavd-ime.apk'
$sums = Join-Path $env:RUNNER_TEMP 'release-assets\SHA256SUMS.txt'
$expected = ((Get-Content -LiteralPath $sums | Where-Object { $_ -match '\smodavd-ime\.apk$' } | Select-Object -First 1) -split '\s+')[0].ToLowerInvariant()
$actual = (Get-FileHash -LiteralPath $asset -Algorithm SHA256).Hash.ToLowerInvariant()
if ($expected -ne $actual) { throw "Release SHA256SUMS 不符:$expected != $actual" }
# pwsh does not stop on a native command's exit code; check each one.
python -B -I -X utf8 scripts/test_verify_release_apk.py -v
if ($LASTEXITCODE -ne 0) { throw "verify_release_apk 自测失败(退出码 $LASTEXITCODE)" }
python -B -I -X utf8 scripts/verify_release_apk.py --built-apk $env:BUILT_SIGNED --release-apk $asset --apksigner $env:APKSIGNER --certificate-sha256 d18cca3c1982149349f164d28e02490b2eb694e44d56834fa02a1062eb42e823
if ($LASTEXITCODE -ne 0) { throw "发布 APK 核对失败(退出码 $LASTEXITCODE)" }
55 changes: 54 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,64 @@
- **ModAVD 0.35.0 或更新**:这一版起模拟器才有 `modavd_ime` 通道。更旧的模拟器上装了也不起作用(只在后台退避重连)。
- 客体 Android 11(API 30)以上;ModAVD 现用的是 Android 14 镜像。

## 版本

宿主输入法以本仓库为准:在这里开发,单独编号,单独发 GitHub Release。

- tag 用 `v1.x.y`,APK 的 `versionName` 写成同样的 `1.x.y`。只有 v1.0.0 例外,保持 `versionName 1.0`、`versionCode 1`。
- APK 内容一变,`versionCode` 就加一。
- 第一个独立版本 [v1.0.0](https://github.com/ModAVD/ModAVDHostIME/releases/tag/v1.0.0) 和 ModAVD 0.36.0 包里的
`components\modavd-ime.apk` 逐字节相同。

## 随 ModAVD 分发

ModAVD 0.36.0 起,发布包里带 `components\modavd-ime.apk`,ModAVD 管理器每次开机自动处理:
ModAVD 发布包照旧带着 `components\modavd-ime.apk`,ModAVD 发版时钉住它的 SHA-256。ModAVD 0.36.0 起,ModAVD 管理器每次开机自动处理:

- 没装、已装的版本号(`versionCode`)比包里的旧,或版本号相同但内容和包里的不同(`base.apk` 的 SHA-256 不同)时装,不降级;
读不出客体里 APK 的哈希时,版本号相同就当作已是最新;
- 新装后启用,并在同一份客体数据上只决定一次是否设成默认输入法:当时默认的还是镜像自带的键盘(或没设)才设成它,
之后你切到别的输入法就不再改回来。

用 ModAVD 的发布包就不需要手动装。包里的 APK 用维护者的密钥签名,证书见下一节。
要一直用自己编的版本,把 `ime/AndroidManifest.xml` 的 `versionCode` 设得比发布包里的高;不高于它(包括相同)的话,管理器开机时会换回官方版本。

## 发版和核对

正式签名只在维护者本机做,正式密钥不进 CI。所以这里的发版不是「打 tag 自动出包」:

1. 合并、打 tag;
2. 维护者本机跑 `scripts/release-local.ps1`,建草稿 Release,附件是 `modavd-ime.apk` 和 `SHA256SUMS.txt`;
3. 手动触发 `verify-release` workflow(输入 tag),证明草稿里的 APK 是从 tag 上的源码编出来的;
4. 核对通过再发布。发布后附件不能再换,核对要在发布前做完;发布时 `verify-release` 会再跑一遍复核。

`release-local.ps1`:

- 一般的版本:从 tag `git archive` 出源码,用临时开发密钥跑 `build.ps1`,只取未签名的 `out\obj\ime\aligned.apk`,
再用正式密钥单独签这一个文件(`-FormalKeyStore`、`-FormalKeyAlias`、`-FormalKeyPassFile`)。密钥库和口令文件不存在就报错,
绝不生成新钥。测试 APK 不用正式密钥签。
- v1.0.0 不重新签名,直接用 0.36.0 包里那份,脚本先核对它等于 [release/1.0.0.SHA256SUMS.txt](release/1.0.0.SHA256SUMS.txt):

```powershell
pwsh -NoProfile -File .\scripts\release-local.ps1 -Tag v1.0.0 `
-UseExistingApk <0.36.0 包里的 modavd-ime.apk> -BuildTools <SDK 根>\build-tools\36.1.0
```

- 没设 `ANDROID_HOME` 就要用 `-BuildTools` 指定 build-tools(签名、核对证书都要 apksigner)。`-WorkDir` 要给新目录或空目录。
`-DryRun` 只打印要执行的 `gh release create`,不调用 gh。
- `SHA256SUMS.txt` 只有 v1.0.0 预先提交进仓库(它的 APK 在打 tag 之前就有了)。以后的版本只作 Release 附件,不补进仓库:
哈希要等打 tag、签名之后才有,发布后附件又不能再换,以 Release 为准。

核对的办法(`verify-release` 就是这么做的,也可以自己照做):正式签名的 APK 和 CI 编出的不可能逐字节相同,比的是签名以外的部分。

- 用一次性 RSA-4096 密钥给 tag 源码编出的 `aligned.apk` 签名(v1 关,v2、v3 开,v4 关,和正式签名一样)。
RSA-4096 的签名块和正式版一样是 8192 字节,换别的长度偏移就对不上。
- `scripts/verify_release_apk.py` 比较两边签名块之前的全部字节和中央目录,再用 `apksigner` 核对发布的 APK:
只有一个签名者、v3 验证通过、证书摘要是下一节的值。
- 不能直接拿未签名的 `aligned.apk` 比:apksigner 签名时会改写条目对齐,后面条目的偏移都会变。

CI(`ci.yml`)只在 Pull Request 和手动触发时跑:协议自测、在 Windows 上编两个 APK、隐私扫描、提交作者检查。
构建钉住 Temurin 25.0.4.1+1、build-tools 36.1.0 和 android-36(核对 `android.jar` 的 SHA-256)。

## 官方 APK 的签名证书

- 证书 SHA-256:`d18cca3c1982149349f164d28e02490b2eb694e44d56834fa02a1062eb42e823`
Expand All @@ -42,6 +89,8 @@ ModAVD 0.36.0 起,发布包里带 `components\modavd-ime.apk`,ModAVD 管理
| `testapp/` | 测试 APK `org.modavd.ime.testapp`,不随产品分发 |
| `docs/protocol-v1.md` | 通道协议 v1 |
| `build.ps1` | 构建脚本(Windows,pwsh 7.2 以上) |
| `scripts/` | CI 的隐私扫描、作者检查,发版和核对脚本 |
| `release/` | 各版本的发布说明;v1.0.0 还有预先提交的 `SHA256SUMS.txt` |

## 构建

Expand All @@ -67,6 +116,10 @@ pwsh -NoProfile -File .\build.ps1
- **密钥绝不进 git**:`out/`、`*.p12`、`*.pass` 都在 `.gitignore` 里。
- 代码只用 API 34 及以下的接口;编译用 android-36 的 `android.jar`,它只在编译时用,不进 APK。

v1.0.0 为了逐字节复现历史 APK,构建时仍依赖本地时区;CI 的 APK job 使用 `TZ=CET-1`。以后改 APK
时要在构建里固定 ZIP 条目时间并去掉这个依赖,同时把 `versionCode` 加一、`versionName` 改成新的
`1.x.y`(例如 `1.1.0`)。

只跑协议自测(任何系统都行,只要 JDK 17 以上):

```sh
Expand Down
1 change: 1 addition & 0 deletions release/1.0.0.SHA256SUMS.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
912c9c9724e63fa606fa5d4e4212cef0e24148b013923ba49d1ebf8cfd38e0e2 modavd-ime.apk
24 changes: 24 additions & 0 deletions release/1.0.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# ModAVDHostIME v1.0.0

这是 ModAVDHostIME 的第一个独立版本。APK 与 ModAVD 0.36.0 包里的
`components\modavd-ime.apk` 逐字节相同:

- SHA-256:`912c9c9724e63fa606fa5d4e4212cef0e24148b013923ba49d1ebf8cfd38e0e2`
- versionName:`1.0`
- versionCode:`1`
- 签名证书 SHA-256:`d18cca3c1982149349f164d28e02490b2eb694e44d56834fa02a1062eb42e823`

来源包:[ModAVD 0.36.0](https://github.com/ModAVD/ModAVD/releases/tag/v0.36.0)。核对发布 APK 时,
先用一次性 RSA-4096 密钥签 tag 上源码编出的 `aligned.apk`,再运行仓库里的
`scripts/verify_release_apk.py`;它逐字节比较签名块之前的条目区和中央目录,并用 `apksigner` 核对
唯一签名者、v3 验证和证书摘要。

单独使用时:

```sh
adb install modavd-ime.apk
adb shell ime enable org.modavd.ime/.HostImeService
adb shell ime set org.modavd.ime/.HostImeService
```

使用 ModAVD 时,管理器会在开机时按版本号、内容哈希和签名规则自动安装并启用它。
1 change: 1 addition & 0 deletions scripts/privacy-allowlist.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"exceptions": []}
Loading