diff --git a/.github/workflows/release-apk.yml b/.github/workflows/release-apk.yml index 0220db8..6938087 100644 --- a/.github/workflows/release-apk.yml +++ b/.github/workflows/release-apk.yml @@ -15,7 +15,8 @@ concurrency: jobs: publish: if: github.ref_type == 'tag' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + timeout-minutes: 360 steps: - uses: actions/checkout@v4 with: @@ -48,24 +49,33 @@ jobs: - uses: android-actions/setup-android@v4 with: - packages: 'platform-tools platforms;android-35 build-tools;35.0.0' + packages: 'platform-tools platforms;android-35 build-tools;35.0.0 ndk;26.2.11394342' - uses: gradle/actions/setup-gradle@v4 - - name: Download and verify pinned FFmpegKit AAR + - name: Install FFmpeg source build tools run: | - mkdir -p app/libs - curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' \ - 'https://repo.maven.apache.org/maven2/dev/ffmpegkit-maintained/ffmpeg-kit-https/7.1.6/ffmpeg-kit-https-7.1.6.aar' \ - -o app/libs/ffmpeg-kit-https-7.1.6.aar - echo 'e80e5ee52248a3dcb1dda0524dd784fe4bbb212dcd4d575e82e15d21a002d860 app/libs/ffmpeg-kit-https-7.1.6.aar' | sha256sum --check + sudo apt-get update + sudo apt-get install --yes gettext - - name: Run unit tests and lint - run: bash gradlew --no-daemon :app:testDebugUnitTest :app:lintDebug --console=plain + - name: Build FFmpegKit from pinned sources + run: | + bash scripts/build_ffmpeg_source.sh + aar="$GITHUB_WORKSPACE/build/compliance/ffmpeg-7.1.6/ffmpeg-kit-https-7.1.6.aar" + test -s "$aar" + digest="$(sha256sum "$aar" | cut -d' ' -f1)" + printf 'CASTTRACE_FFMPEG_AAR_PATH=%s\n' "$aar" >> "$GITHUB_ENV" + printf 'CASTTRACE_FFMPEG_AAR_SHA256=%s\n' "$digest" >> "$GITHUB_ENV" + + - name: Collect FFmpeg corresponding-source materials + run: python scripts/prepare_ffmpeg_compliance.py --aar "$CASTTRACE_FFMPEG_AAR_PATH" - name: Enforce FFmpeg corresponding-source release gate run: python scripts/verify_release_readiness.py + - name: Run unit tests and lint with the release FFmpeg AAR + run: bash gradlew --no-daemon :app:testDebugUnitTest :app:lintDebug --console=plain + - name: Prepare signing key env: CASTTRACE_KEYSTORE_BASE64: ${{ secrets.CASTTRACE_KEYSTORE_BASE64 }} @@ -127,11 +137,6 @@ jobs: raise SystemExit('APK version changed after tag validation.') PY - - name: Collect FFmpeg corresponding-source materials - env: - GH_TOKEN: ${{ github.token }} - run: python scripts/prepare_ffmpeg_compliance.py - - name: Check source materials and assemble release assets env: TAG_NAME: ${{ github.ref_name }} @@ -141,10 +146,11 @@ jobs: mkdir -p build/release version="${TAG_NAME#v}" cp app/build/outputs/apk/release/app-release.apk "build/release/casttrace-${version}-release.apk" + cp app/build/ffmpeg/ffmpeg-kit-https-7.1.6-compatible.aar "build/release/casttrace-ffmpeg-7.1.6-compatible.aar" git archive --format=zip --prefix=casttrace-android/ HEAD -o "build/release/casttrace-${version}-source.zip" cp build/compliance/ffmpeg-7.1.6/casttrace-ffmpeg-7.1.6-source-materials.zip build/release/ cd build/release - sha256sum casttrace-*-release.apk casttrace-*-source.zip casttrace-ffmpeg-7.1.6-source-materials.zip > SHA256SUMS + sha256sum casttrace-*-release.apk casttrace-*-compatible.aar casttrace-*-source.zip casttrace-ffmpeg-7.1.6-source-materials.zip > SHA256SUMS - name: Publish GitHub Release uses: softprops/action-gh-release@v2 @@ -154,6 +160,7 @@ jobs: fail_on_unmatched_files: true files: | build/release/casttrace-*-release.apk + build/release/casttrace-*-compatible.aar build/release/casttrace-*-source.zip build/release/casttrace-ffmpeg-7.1.6-source-materials.zip build/release/SHA256SUMS diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ba0477..fe71b5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,14 @@ 此记录描述源码版本,不代表已发布可下载的 APK。设备验证范围见 README。 -## 0.2.14 - 未发布 +## 0.2.14 - 2026-10-31 - 增加直播提醒和超火、礼物、订阅等直播事件采集能力。 - 以 Hilt 统一应用、服务、Receiver、Worker 和 ViewModel 的依赖注入。 - 拆分平台 Resolver、仓储、录制存储和录制恢复职责,保留既有调用与 Intent 契约。 - 增加录制并发、重试优先级、恢复和 Resolver 工厂的 JVM 回归测试。 - 完善开发者上手文档和带源码许可门槛的 APK 发布流程。 +- 补齐 FFmpegKit 固定源码构建和对应源码材料;本地发布门槛检查通过。 ## 0.2.13 - 2026-10-01 @@ -26,4 +27,3 @@ - 手机厂商后台策略、网络状态和平台接口仍可能影响自动录制。 - Mate 20 收尾期间曾观察到短会话重新开录,触发原因待进一步确认。 - 本轮长会话手机端拖动、HarmonyOS 4.2 和未充电深度待机尚未完成验收。 -- FFmpeg 完整对应源码材料尚未补齐,暂不正式分发 APK/AAB。 diff --git a/FFMPEG-LICENSE.md b/FFMPEG-LICENSE.md new file mode 100644 index 0000000..1802f96 --- /dev/null +++ b/FFMPEG-LICENSE.md @@ -0,0 +1,38 @@ +# FFmpegKit License and Source Materials + +## Binary used by the app + +Local development uses `dev.ffmpegkit-maintained:ffmpeg-kit-https:7.1.6` from Maven Central. The AAR is excluded from Git and its SHA-256 is pinned in `app/build.gradle.kts`. Official Release builds instead compile FFmpegKit from the source commits, patches and toolchain pinned in [`third_party/ffmpeg/source-build.json`](third_party/ffmpeg/source-build.json). The Maven and source-built AARs are not claimed to be byte-for-byte identical. Both builds are LGPLv3 HTTPS variants with FFmpeg n7.1.5 and without GPL or nonfree FFmpeg components; the app packages `arm64-v8a` and `x86_64`. + +The build task `prepareCompatibleFfmpeg` adjusts only ELF64 `PT_GNU_RELRO.p_memsz` when 16 KiB alignment padding extends beyond the mapped `PT_LOAD` segments at 4 KiB page granularity. This avoids Android 10 `mprotect` failures on unmapped padding. The source AAR remains unchanged; the transformation is in `app/build.gradle.kts` and is part of the application build configuration. + +## License scope + +The root [Apache License 2.0](LICENSE) covers CastTrace application source. It does not relicense FFmpegKit or its linked libraries. Full notices shipped with the app are under `app/src/main/assets/licenses/` and can be read offline from Settings → Open-source licenses. + +The FFmpegKit build and its dependencies retain their own terms. The current native configuration identifies FFmpeg, GMP, GnuTLS, Nettle/Hogweed and libiconv on LGPL-compatible build paths. `cpu_features` and `ndk_compat` retain their upstream notices. Android zlib and MediaCodec are system libraries. The exact component versions, source commits, build options and hashes are recorded in [`third_party/ffmpeg/manifest.json`](third_party/ffmpeg/manifest.json). + +## Corresponding source and release gate + +An APK release includes the corresponding FFmpegKit/native-library source archives, pinned dependency source, build recipe and patches, application source/build configuration, license notices and SHA-256 checksums. The tag workflow builds the AAR from the pinned inputs, verifies its version, ABIs and native configuration, packages the source materials, and passes that same AAR to Gradle for tests and APK packaging. The policy records the exact verification scope and does not claim byte-for-byte equivalence with the separate Maven development artifact. + +## Rebuilding + +The pinned build uses Ubuntu 24.04, JDK 17, Android SDK Platform 35, NDK r26c and `gettext` for GnuTLS bootstrap. Reproduce it with [`scripts/build_ffmpeg_source.sh`](scripts/build_ffmpeg_source.sh), which fetches pinned commits, applies the tracked patches and writes the AAR under `build/compliance/`. Then run [`scripts/prepare_ffmpeg_compliance.py`](scripts/prepare_ffmpeg_compliance.py) and [`scripts/verify_release_readiness.py`](scripts/verify_release_readiness.py) to package and check corresponding-source materials. + +```sh +./android.sh --enable-gnutls --enable-gmp \ + --enable-android-media-codec --enable-android-zlib \ + --disable-arm-v7a --disable-arm-v7a-neon --disable-x86 +``` + +The source-built AAR is supplied to Gradle using `CASTTRACE_FFMPEG_AAR_PATH` and `CASTTRACE_FFMPEG_AAR_SHA256`. Gradle then creates a compatible AAR by trimming only unmapped ELF `PT_GNU_RELRO` padding that can cause `mprotect` failures on Android 10. This transformation is in the application build configuration and is included in the CastTrace source archive; the Release also publishes the resulting compatible AAR. Any native-library or transformation change requires renewed native loading, remuxing, playback and device compatibility tests. + +To rebuild CastTrace for development, download and verify the pinned Maven AAR, use JDK 17 and Android SDK 35, then run the Gradle commands in [README.md](README.md). To reproduce a Release, use the source-build sequence in [RELEASING.md](RELEASING.md). A modified LGPL library can be rebuilt and relinked using the application source and build configuration included with a release. Do not use CastTrace's private signing key for modified builds. + +## References + +- [FFmpegKit maintained source](https://github.com/ffmpegkit-maintained/ffmpeg) +- [FFmpeg](https://github.com/FFmpeg/FFmpeg) +- [GNU LGPL v3](https://www.gnu.org/licenses/lgpl-3.0.html) +- [Release process](RELEASING.md) diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..607f056 --- /dev/null +++ b/README.en.md @@ -0,0 +1,135 @@ +# CastTrace Android + +[简体中文](README.md) | English + +

+ Android checks + GitHub Releases + Android 8.0+ + Apache 2.0 +

+ +CastTrace is an Android application for monitoring live rooms and recording supported streams. It is built with Kotlin, Jetpack Compose, Room, WorkManager, Media3 and FFmpegKit. It manages room lists locally, monitors live status in the background, resumes interrupted recordings, collects supported chat messages, and plays saved videos. + +This is an independently maintained Android project that takes inspiration from the platform recording approach in [StreamCap](https://github.com/ihmily/StreamCap). It is not an official StreamCap mobile client. A platform being supported upstream does not mean that CastTrace supports it. + +

+ Screenshots · + Download APK · + Features · + Build from source · + Developer guide · + Contributing +

+ +## Screenshots + +

+ Live room monitoring and recording console + Live reminder list + Recording and background operation settings +

+ +## Download + +Download the APK and checksums from [GitHub Releases](https://github.com/SRiftX/casttrace-android/releases). CastTrace supports Android 8.0+ on `arm64-v8a` and `x86_64`. The APK uses the established CastTrace signing identity and can update an existing installation while retaining app data. Each release also includes CastTrace source, FFmpeg corresponding-source materials, the compatible AAR and SHA-256 checksums. See [RELEASING.md](RELEASING.md) for the release procedure. + +## Features + +- Manage multiple rooms and import room links, shared text or batches. +- Monitor live status in the background and schedule live reminders. +- Recover from short disconnects, expired stream URLs and process restarts. +- Save FLV, HLS/TS or MP4 recordings, including long-session segments. +- Browse, seek, rename, delete, export and share saved videos. +- Capture text chat on supported platforms and export videos with chat overlays. +- Back up room lists and app settings. + +Background monitoring depends on notification permission, battery policy and vendor-specific startup controls. Some devices require CastTrace to be allowed to start and run in the background. + +## Platform Support + +The platform catalog tracks supported, needs-review, planned and stopped entries. Current supported entry points include Huya, Douyin, Bilibili, Douyu and direct HLS/FLV URLs. Some other platforms require device, account or regional verification. Recognizing a shared link does not guarantee that a room is live or recordable. See [`PlatformCatalog.kt`](app/src/main/java/com/streamcap/mobile/platform/PlatformCatalog.kt) for the current status. + +## Build from Source + +Requirements: + +- JDK 17 +- Android SDK Platform 35 and Build Tools 35.0.0 +- Android 8.0 (API 26) or newer; APKs include `arm64-v8a` and `x86_64` +- The pinned FFmpegKit 7.1.6 AAR, verified by SHA-256 + +The AAR is excluded from Git. Download and verify it before building: + +Linux and macOS: + +```bash +mkdir -p app/libs +curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' \ + 'https://repo.maven.apache.org/maven2/dev/ffmpegkit-maintained/ffmpeg-kit-https/7.1.6/ffmpeg-kit-https-7.1.6.aar' \ + -o app/libs/ffmpeg-kit-https-7.1.6.aar +echo 'e80e5ee52248a3dcb1dda0524dd784fe4bbb212dcd4d575e82e15d21a002d860 app/libs/ffmpeg-kit-https-7.1.6.aar' | sha256sum --check +``` + +Windows PowerShell: + +```powershell +New-Item -ItemType Directory -Path app\libs -Force | Out-Null +Invoke-WebRequest ` + 'https://repo.maven.apache.org/maven2/dev/ffmpegkit-maintained/ffmpeg-kit-https/7.1.6/ffmpeg-kit-https-7.1.6.aar' ` + -OutFile app\libs\ffmpeg-kit-https-7.1.6.aar +$expected = 'e80e5ee52248a3dcb1dda0524dd784fe4bbb212dcd4d575e82e15d21a002d860' +$actual = (Get-FileHash app\libs\ffmpeg-kit-https-7.1.6.aar -Algorithm SHA256).Hash.ToLowerInvariant() +if ($actual -ne $expected) { throw "Unexpected FFmpegKit AAR SHA-256: $actual" } +``` + +Set `ANDROID_HOME` to the local SDK. If Gradle reports `Unable to establish loopback connection` on Windows, use a short writable temporary path: + +```powershell +$env:ANDROID_HOME = "$env:LOCALAPPDATA\Android\Sdk" +$env:TEMP = 'C:\tmp' +$env:TMP = 'C:\tmp' +New-Item -ItemType Directory -Path $env:TEMP -Force | Out-Null +./gradlew.bat --no-daemon :app:testDebugUnitTest :app:lintDebug :app:assembleDebug +``` + +Linux and macOS: + +```bash +bash gradlew --no-daemon :app:testDebugUnitTest :app:lintDebug :app:assembleDebug +``` + +The Debug APK is written to `app/build/outputs/apk/debug/app-debug.apk`. The application ID remains `com.streamcap.mobile` for compatibility with existing installations and data. + +## Developer Guide + +Start with [CONTRIBUTING.md](CONTRIBUTING.md). Read [ARCHITECTURE.md](ARCHITECTURE.md) before changing dependency injection, recording lifecycle or data boundaries. + +Production code is under `app/src/main/java/com/streamcap/mobile/`: + +- `app/`: Hilt root graph and Android entry points. +- `data/`: Room, preferences, network policy and repositories. +- `platform/`: platform identities, aliases and resolver construction. +- `service/`: background monitoring, recording coordination, recovery and publication. +- `storage/`: recording files, MediaStore, export, deletion and segment discovery. +- `ui/`: Compose screens and Hilt ViewModels. + +To add a platform, update `PlatformCatalog` and add JVM tests for URL recognition, aliases and resolver selection. Preserve recording Intent actions and Room schema compatibility; database changes require a migration. + +## Tests + +Run the checks relevant to the change. Cross-module changes should pass: + +```bash +bash gradlew --no-daemon :app:testDebugUnitTest :app:lintDebug :app:assembleDebug :app:assembleRelease +``` + +Instrumentation tests require a connected device or emulator. Real recording tests require an explicit authorized room ID and may create media files. Do not test rooms without permission to record them. Device test setup is documented in [CONTRIBUTING.md](CONTRIBUTING.md). + +## License + +Application source is licensed under [Apache License 2.0](LICENSE); see [NOTICE](NOTICE) for attribution. Third-party components retain their own licenses. FFmpegKit is an LGPLv3 build and its redistribution status is tracked separately from the application license. See [THIRD_PARTY.md](THIRD_PARTY.md) and [FFMPEG-LICENSE.md](FFMPEG-LICENSE.md). + +## Contributing + +Report reproducible issues through [GitHub Issues](https://github.com/SRiftX/casttrace-android/issues) and read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. Remove cookies, account details, device identifiers and private recordings from reports. diff --git a/README.md b/README.md index 862b1b1..ae68cd2 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # CastTrace Android +[简体中文](README.md) | [English](README.en.md) +

Android checks GitHub Releases @@ -12,9 +14,26 @@ CastTrace 是一款 Android 多平台直播监控和录制工具,使用 Kotlin 本项目参考了 [StreamCap](https://github.com/ihmily/StreamCap) 的平台录制思路,是独立维护的 Android 项目,并非 StreamCap 官方移动端;上游平台支持范围不代表本项目全部支持。 +

+ 界面截图 · + 下载 APK · + 功能 · + 从源码构建 · + 开发入口 · + 参与和反馈 +

+ +## 界面截图 + +

+ 直播间监控和录制控制台 + 开播提醒列表 + 录制和后台运行设置 +

+ ## 下载 APK -前往 [GitHub Releases](https://github.com/SRiftX/casttrace-android/releases) 查看 APK。只有 FFmpeg 完整对应源码通过核验后,Release 工作流才会发布正式 APK。目前源码清单仍将公开二进制分发标记为未就绪,因此尚无正式 APK 下载。门槛和发布步骤见 [RELEASING.md](RELEASING.md)。 +前往 [GitHub Releases](https://github.com/SRiftX/casttrace-android/releases) 下载 APK 和校验文件。应用支持 Android 8.0+,目前提供 `arm64-v8a` 与 `x86_64`。APK 使用 CastTrace 正式签名,可通过系统安装器覆盖旧版并保留应用数据。Release 同时提供应用源码、FFmpeg 对应源码材料、兼容 AAR 和 SHA-256 清单。发布门槛和步骤见 [RELEASING.md](RELEASING.md)。 ## 功能 @@ -39,7 +58,7 @@ CastTrace 是一款 Android 多平台直播监控和录制工具,使用 Kotlin - JDK 17 - Android SDK Platform 35 和 Build Tools 35.0.0 - Android 8.0(API 26)及以上;APK 仅包含 `arm64-v8a` 和 `x86_64` -- FFmpegKit 7.1.6 AAR,SHA-256 必须与下方固定值一致 +- 开发构建使用 FFmpegKit 7.1.6 Maven AAR,SHA-256 必须与下方固定值一致。正式 Release 从锁定源码构建 FFmpegKit,并通过 `CASTTRACE_FFMPEG_AAR_PATH` 提供给 Gradle;两种 AAR 不声明逐字节相同。 克隆仓库并下载固定版本 AAR。AAR 不提交到 Git,Gradle 和 CI 会校验它的 SHA-256。 @@ -53,6 +72,20 @@ curl --fail --silent --show-error --location --proto '=https' --proto-redir '=ht echo 'e80e5ee52248a3dcb1dda0524dd784fe4bbb212dcd4d575e82e15d21a002d860 app/libs/ffmpeg-kit-https-7.1.6.aar' | sha256sum --check ``` +Windows PowerShell: + +```powershell +git clone https://github.com/SRiftX/casttrace-android.git +Set-Location casttrace-android +New-Item -ItemType Directory -Path app\libs -Force | Out-Null +Invoke-WebRequest ` + 'https://repo.maven.apache.org/maven2/dev/ffmpegkit-maintained/ffmpeg-kit-https/7.1.6/ffmpeg-kit-https-7.1.6.aar' ` + -OutFile app\libs\ffmpeg-kit-https-7.1.6.aar +$expected = 'e80e5ee52248a3dcb1dda0524dd784fe4bbb212dcd4d575e82e15d21a002d860' +$actual = (Get-FileHash app\libs\ffmpeg-kit-https-7.1.6.aar -Algorithm SHA256).Hash.ToLowerInvariant() +if ($actual -ne $expected) { throw "Unexpected FFmpegKit AAR SHA-256: $actual" } +``` + 设置 `ANDROID_HOME` 指向本机 Android SDK。Windows 上若 Gradle 报 `Unable to establish loopback connection`,请使用短且可写的临时目录后再构建: ```powershell @@ -107,7 +140,7 @@ bash gradlew --no-daemon :app:assembleDebugAndroidTest ## 许可证 -应用源码采用 [Apache License 2.0](LICENSE),归属说明见 [NOTICE](NOTICE)。第三方组件仍采用各自许可证。FFmpegKit 是 LGPLv3 构建,其完整对应源码和二进制分发状态与应用许可证分别核验。发布前请查看 [THIRD_PARTY.md](THIRD_PARTY.md)。 +应用源码采用 [Apache License 2.0](LICENSE),归属说明见 [NOTICE](NOTICE)。第三方组件仍采用各自许可证。FFmpegKit 是 LGPLv3 构建,其完整对应源码和二进制分发状态与应用许可证分别核验。发布前请查看 [THIRD_PARTY.md](THIRD_PARTY.md) 和 [FFMPEG-LICENSE.md](FFMPEG-LICENSE.md)。 ## 参与和反馈 diff --git a/RELEASING.md b/RELEASING.md index 8f2079d..8540e88 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,54 +1,70 @@ -# Android APK 发布 - -正式 APK 只能从已合入 `main` 的版本标签构建。发布工作流会运行 JVM 单测和 lint,校验 FFmpeg AAR、对应源码分发状态及签名证书,最后把 APK、应用源码、FFmpeg 源码材料和 SHA-256 校验文件放入同一 GitHub Release。 - -## 当前发布门槛 - -`third_party/ffmpeg/manifest.json` 中的 `redistributionReady` 必须为 `true`,`sourceGaps` 必须为空。当前清单仍为 `false`,原因是上游构建缓存和浮动子模块等原生源码输入尚未核实。因此 tag 工作流会停止,不会上传或发布 APK。单独签名成功或收集到源码归档都不等于该门槛已通过。 - -关闭门槛前,维护者必须取得或自行构建与实际 AAR 对应的完整 LGPL 源码和构建输入,校验归档,并更新清单、`THIRD_PARTY.md`、README 与许可证说明。不能只把未核实的源码材料包标记为完整对应源码。 - -## GitHub Actions 配置 - -发布工作流需要仓库 Actions secrets。它们只用于签名,不应放在普通变量、仓库文件或命令行参数中: - -| Secret | 内容 | -| --- | --- | -| `CASTTRACE_KEYSTORE_BASE64` | 正式 `.p12` 密钥文件的 Base64 内容 | -| `CASTTRACE_STORE_PASSWORD` | 密钥库密码 | -| `CASTTRACE_KEY_ALIAS` | 正式密钥别名,当前为 `casttrace-release` | -| `CASTTRACE_KEY_PASSWORD` | 密钥密码 | - -需由仓库所有者在 GitHub 仓库 Settings → Secrets and variables → Actions 添加。`CASTTRACE_KEYSTORE_BASE64` 必须是单行 Base64,不要加 PEM 头、换行或空格。私钥不进入 GitHub Release、构建日志或生成的源码包。签名身份必须继续与既有安装兼容,不能临时生成新密钥替代。 - -## 发布步骤 - -1. 更新 `versionName`、`versionCode` 和 `CHANGELOG.md`,确认 README 与许可证材料反映本次发布状态。 -2. 确保改动已合入 `main`,并通过 `:app:testDebugUnitTest`、`:app:lintDebug`、`:app:assembleRelease` 以及必要的设备回归。 -3. 确认源码清单通过发布门槛,并验证正式密钥已配置且证书指纹与既有身份一致。 -4. 创建并推送匹配 `versionName` 的标签,例如 `v0.2.14`。 -5. GitHub Actions 会再次验证标签位于 `main` 历史、版本号匹配、测试通过、签名正确,然后创建 GitHub Release。 -6. 下载 Release 中的 APK、应用源码包、FFmpeg 对应源码材料包和 `SHA256SUMS`,核对文件哈希与 APK 签名后再对外宣传。 - -Release 资产应包含: - -- `casttrace--release.apk` -- `casttrace--source.zip` -- `casttrace-ffmpeg-7.1.6-source-materials.zip` -- `SHA256SUMS` - -任何一个门槛失败,工作流都不会创建包含 APK 的 Release。当前不要为源码状态未就绪的版本创建正式标签。 - -## 本地签名验证 - -仓库维护者可在本机使用受保护的密钥生成候选 APK: - -```powershell -$env:ANDROID_HOME = "$env:LOCALAPPDATA\Android\Sdk" -$env:TEMP = 'C:\tmp' -$env:TMP = 'C:\tmp' -New-Item -ItemType Directory -Path $env:TEMP -Force | Out-Null -./scripts/Build-SignedRelease.ps1 +# Release Process + +This repository publishes CastTrace APKs as GitHub Releases. A release tag is +accepted only when it matches `versionName` in `app/build.gradle.kts`, points to +a commit on `main`, passes the FFmpeg corresponding-source gate, and produces an +APK signed with the established CastTrace release certificate. + +## Release prerequisites + +- The release commit is merged into `main` and its Android checks pass. +- `third_party/ffmpeg/source-build.json` pins every source archive, build option, + patch, ABI and toolchain used by the release FFmpegKit build. +- The GitHub Actions repository secrets are configured: + - `CASTTRACE_KEYSTORE_BASE64`: base64-encoded PKCS#12 signing keystore. + - `CASTTRACE_STORE_PASSWORD`: keystore password. + - `CASTTRACE_KEY_ALIAS`: signing key alias. + - `CASTTRACE_KEY_PASSWORD`: signing key password. +- The configured certificate SHA-256 is + `D39B0394EE312DE5B1F25F8A215A0E81CA9C47229835D2F96DD0187694523C57`. + +Do not commit a keystore or password. Keep an offline backup of the signing key; +Android updates must keep the same application id and signing identity. + +## Local release checks + +Use Ubuntu 24.04, JDK 17, Android SDK Platform 35, Build Tools 35.0.0, NDK +26.2.11394342 (r26c), Python 3 and `gettext`. Download or install the pinned +sources only through the tracked build recipe. From the repository root: + +```bash +export ANDROID_HOME="$ANDROID_HOME" +export ANDROID_NDK_ROOT="$ANDROID_HOME/ndk/26.2.11394342" +bash scripts/build_ffmpeg_source.sh +export CASTTRACE_FFMPEG_AAR_PATH="$PWD/build/compliance/ffmpeg-7.1.6/ffmpeg-kit-https-7.1.6.aar" +export CASTTRACE_FFMPEG_AAR_SHA256="$(sha256sum "$CASTTRACE_FFMPEG_AAR_PATH" | cut -d' ' -f1)" +python scripts/prepare_ffmpeg_compliance.py --aar "$CASTTRACE_FFMPEG_AAR_PATH" +python scripts/verify_release_readiness.py +bash gradlew --no-daemon :app:testDebugUnitTest :app:lintDebug :app:assembleDebug :app:assembleRelease ``` -脚本会验证 APK 签名并输出 SHA-256。此本地步骤只用于验证构建,不能绕过源码分发门槛,也不代表该 APK 可以公开发布。密钥保管细节见仓库所有者本机维护的签名说明。 +The release workflow repeats these checks from a clean runner. Its APK uses the +source-built FFmpegKit AAR; ordinary local development may use the pinned Maven +AAR described in `app/libs/README.md`. The Maven artifact is not claimed to be +byte-for-byte equivalent to the source build. + +## Publish + +1. Update `versionName` and `versionCode` in `app/build.gradle.kts`, then update + `CHANGELOG.md` and user-facing release notes. +2. Merge the release commit into `main`; wait for Android checks to pass. +3. Confirm the corresponding-source policy and signing secrets are ready. +4. Create and push the matching annotated tag, for example: + + ```bash + git tag -a v0.2.14 -m "CastTrace 0.2.14" + git push origin v0.2.14 + ``` + +5. Follow the `Publish Android APK` Actions run. The workflow verifies the + source inputs, builds FFmpegKit, runs tests and lint, signs the APK, checks + its package/version/certificate, and creates the GitHub Release. +6. Verify every uploaded asset and its SHA-256 using `SHA256SUMS`. The release + contains the APK, the Gradle-compatible FFmpeg AAR, CastTrace source, FFmpeg + corresponding-source materials and checksums. +7. Install the APK over an existing CastTrace installation with `adb install + -r` and verify app startup, native FFmpeg loading, playback and recording + before announcing the release. + +The APK is intended for Android 8.0+ on `arm64-v8a` and `x86_64`. Keep the +repository public if the Release is intended for unauthenticated downloads. diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md index 01df3b9..8d22b30 100644 --- a/THIRD_PARTY.md +++ b/THIRD_PARTY.md @@ -14,18 +14,21 @@ are bundled in `app/src/main/assets/licenses/rust-srec-MIT.txt`. JVM parser tests use JSON-java 20240303 (public domain / Unlicense, https://github.com/stleary/JSON-java); Android uses its built-in `org.json` API. -The local FFmpegKit binary is excluded from Git. Its SHA-256 matches Maven -Central `dev.ffmpegkit-maintained:ffmpeg-kit-https:7.1.6` exactly. This is the -LGPLv3 HTTPS build containing FFmpeg n7.1.5, with no enable-gpl/nonfree flags. -It is not Apache-2.0 licensed. Full notices are bundled in the application's -offline Settings / Open-source licenses screen. +The development FFmpegKit AAR is excluded from Git and downloaded from Maven +Central as `dev.ffmpegkit-maintained:ffmpeg-kit-https:7.1.6`. Its SHA-256 is +pinned in Gradle. Official Release APKs use an AAR built from the source commits +and patches pinned in `third_party/ffmpeg/source-build.json`; the source-built +AAR is not claimed to be byte-for-byte identical to Maven Central's artifact. +Both are LGPLv3 HTTPS builds containing FFmpeg n7.1.5, with no enable-gpl or +nonfree flags. Full notices are bundled in the application's offline Settings +/ Open-source licenses screen and in the Release source-material archive. -The exact upstream native source cache and floating GnuTLS gnulib input remain -unverified, so public binary redistribution is still blocked. Collected source -archives are investigation materials, not a complete corresponding-source claim. -See `app/libs/README.md` and `third_party/ffmpeg/manifest.json` for -the provenance, obligations and remaining gap. -Publishing this source repository does not automatically publish an APK. +FFmpegKit's binary provenance, license scope, source-build procedure and +redistribution gate are documented in `FFMPEG-LICENSE.md`. The Maven artifact +evidence is in `third_party/ffmpeg/manifest.json`; the release source build and +its input hashes are pinned in `third_party/ffmpeg/source-build.json`. The tag +workflow builds the AAR from those sources and will not publish an APK unless +the resulting source materials and binary checks pass. Upstream project references: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 38855e0..73f3517 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -80,11 +80,17 @@ ksp { arg("room.schemaLocation", "$projectDir/schemas") } +val mavenFfmpegSha256 = "E80E5EE52248A3DCB1DDA0524DD784FE4BBB212DCD4D575E82E15D21A002D860" +val ffmpegArtifactPath = providers.environmentVariable("CASTTRACE_FFMPEG_AAR_PATH") + .orElse(layout.projectDirectory.file("libs/ffmpeg-kit-https-7.1.6.aar").asFile.absolutePath) +val ffmpegArtifact = providers.provider { file(ffmpegArtifactPath.get()) } +val expectedFfmpegSha256 = providers.environmentVariable("CASTTRACE_FFMPEG_AAR_SHA256") + .orElse(mavenFfmpegSha256) + val verifyFfmpegArtifact by tasks.registering { - val artifact = layout.projectDirectory.file("libs/ffmpeg-kit-https-7.1.6.aar") - inputs.file(artifact) + inputs.file(ffmpegArtifact) doLast { - val binary = artifact.asFile + val binary = ffmpegArtifact.get() check(binary.isFile) { "Missing FFmpegKit AAR: see app/libs/README.md; do not download an unverified replacement." } val digest = MessageDigest.getInstance("SHA-256") binary.inputStream().use { input -> @@ -96,8 +102,10 @@ val verifyFfmpegArtifact by tasks.registering { } } val actual = digest.digest().joinToString("") { "%02X".format(it) } - check(actual == "E80E5EE52248A3DCB1DDA0524DD784FE4BBB212DCD4D575E82E15D21A002D860") { - "FFmpegKit AAR checksum differs from the reviewed local artifact. Verify provenance before updating the pin." + val expected = expectedFfmpegSha256.get().uppercase() + check(expected.matches(Regex("[A-F0-9]{64}"))) { "CASTTRACE_FFMPEG_AAR_SHA256 must be a SHA-256 digest." } + check(actual == expected) { + "FFmpegKit AAR checksum differs from CASTTRACE_FFMPEG_AAR_SHA256. Verify provenance before building." } } } @@ -106,12 +114,12 @@ tasks.named("preBuild").configure { dependsOn(verifyFfmpegArtifact) } val compatibleFfmpegArtifact = layout.buildDirectory.file("ffmpeg/ffmpeg-kit-https-7.1.6-compatible.aar") val prepareCompatibleFfmpeg by tasks.registering { dependsOn(verifyFfmpegArtifact) - inputs.file(layout.projectDirectory.file("libs/ffmpeg-kit-https-7.1.6.aar")) + inputs.file(ffmpegArtifact) outputs.file(compatibleFfmpegArtifact) doLast { val target = compatibleFfmpegArtifact.get().asFile target.parentFile.mkdirs() - ZipFile(layout.projectDirectory.file("libs/ffmpeg-kit-https-7.1.6.aar").asFile).use { source -> + ZipFile(ffmpegArtifact.get()).use { source -> ZipOutputStream(target.outputStream()).use { output -> source.entries().asSequence().forEach { entry -> val bytes = source.getInputStream(entry).use { it.readBytes() } diff --git a/app/libs/README.md b/app/libs/README.md index 894f100..0f9030d 100644 --- a/app/libs/README.md +++ b/app/libs/README.md @@ -1,8 +1,10 @@ # Local FFmpegKit Dependency -The application currently references `ffmpeg-kit-https-7.1.6.aar` from this -directory. This local binary is not committed. Place a verified copy here -before running Gradle; the original development workspace retains its copy. +Local development uses `ffmpeg-kit-https-7.1.6.aar` from this directory. This +Maven artifact is not committed. Place a verified copy here before running +Gradle. Official release builds provide a separately source-built AAR through +`CASTTRACE_FFMPEG_AAR_PATH` and its digest through +`CASTTRACE_FFMPEG_AAR_SHA256`. SHA256 of the development binary: @@ -10,7 +12,7 @@ SHA256 of the development binary: E80E5EE52248A3DCB1DDA0524DD784FE4BBB212DCD4D575E82E15D21A002D860 ``` -The checksum matches Maven Central exactly: +The development checksum matches Maven Central exactly: `dev.ffmpegkit-maintained:ffmpeg-kit-https:7.1.6`. Download URL: @@ -18,9 +20,10 @@ https://repo.maven.apache.org/maven2/dev/ffmpegkit-maintained/ffmpeg-kit-https/7 Gradle and CI enforce this pin. This is the LGPLv3 build containing FFmpeg n7.1.5 for arm64-v8a and x86_64. Do not substitute an arbitrary artifact. -The upstream native source cache remains unavailable, so public binary -redistribution is not yet cleared. See `THIRD_PARTY.md` -and `third_party/ffmpeg/manifest.json` for evidence and outstanding source gaps. +The formal Release does not redistribute this downloaded Maven binary. It uses +the pinned source build and includes corresponding-source materials; see +`RELEASING.md`, `FFMPEG-LICENSE.md` and +`third_party/ffmpeg/source-build.json`. The build uses a generated compatibility AAR under `app/build/ffmpeg/`. `prepareCompatibleFfmpeg` changes only ELF64 `PT_GNU_RELRO.p_memsz` where @@ -32,7 +35,8 @@ trims only that unmapped padding; all RELRO file data, code, load segments, alignment and license files are preserved. The x86_64 artifact also needs this correction in `libavdevice.so` and `libswresample.so`. -This is a local binary modification, not an upstream rebuild. Any future -artifact update must review/remove this transformation and rerun device -loading, remuxing, decoding and seek tests, including a 16 KiB-page device. -The source/compliance gaps above still apply to the derived artifact. +This is a local compatibility transformation, not a source change to FFmpeg. +The official release publishes the derived compatible AAR alongside the +corresponding source materials and application build configuration. Any future +artifact update must review this transformation and rerun device loading, +remuxing, decoding and seek tests, including a 16 KiB-page device. diff --git a/screenshots/live-reminders.png b/screenshots/live-reminders.png new file mode 100644 index 0000000..7796a44 Binary files /dev/null and b/screenshots/live-reminders.png differ diff --git a/screenshots/live-rooms-large-screen.png b/screenshots/live-rooms-large-screen.png new file mode 100644 index 0000000..9b0078a Binary files /dev/null and b/screenshots/live-rooms-large-screen.png differ diff --git a/screenshots/settings.png b/screenshots/settings.png new file mode 100644 index 0000000..04d6693 Binary files /dev/null and b/screenshots/settings.png differ diff --git a/scripts/build_ffmpeg_source.sh b/scripts/build_ffmpeg_source.sh new file mode 100644 index 0000000..5eeb037 --- /dev/null +++ b/scripts/build_ffmpeg_source.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUT="$ROOT/build/compliance/ffmpeg-7.1.6" +mkdir -p "$OUT" "$ROOT/build" + +BUILD_COMMIT="$(python3 -c 'import json, pathlib, sys; print(json.loads((pathlib.Path(sys.argv[1]) / "third_party/ffmpeg/source-build.json").read_text())["upstreamBuildCommit"])' "$ROOT")" +WORKSPACE="$(mktemp -d "$ROOT/build/ffmpeg-source-build.XXXXXX")" +SOURCE="$WORKSPACE/ffmpegkit" +trap 'printf "FFmpeg source build workspace: %s\n" "$WORKSPACE"' EXIT + +git init -q "$SOURCE" +git -C "$SOURCE" remote add origin https://github.com/ffmpegkit-maintained/ffmpeg.git +git -C "$SOURCE" fetch --depth=1 origin "$BUILD_COMMIT" +git -C "$SOURCE" checkout --detach FETCH_HEAD + +for patch in "$ROOT"/third_party/ffmpeg/patches/*.patch; do + git -C "$SOURCE" apply --unidiff-zero --check "$patch" + git -C "$SOURCE" apply --unidiff-zero "$patch" +done + +export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}" +if [[ -z "$ANDROID_SDK_ROOT" ]]; then + echo "ANDROID_SDK_ROOT or ANDROID_HOME must point to the Android SDK." >&2 + exit 1 +fi +if [[ -z "${ANDROID_NDK_ROOT:-}" ]]; then + ANDROID_NDK_ROOT="$ANDROID_SDK_ROOT/ndk/26.2.11394342" +fi +export ANDROID_NDK_ROOT +if [[ ! -f "$ANDROID_NDK_ROOT/source.properties" ]]; then + echo "Android NDK r26c was not found at $ANDROID_NDK_ROOT." >&2 + exit 1 +fi + +cd "$SOURCE/android-7.1-lts" +./android.sh --enable-gnutls --enable-gmp \ + --enable-android-media-codec --enable-android-zlib \ + --disable-arm-v7a --disable-arm-v7a-neon --disable-x86 + +AAR="$SOURCE/android-7.1-lts/prebuilt/bundle-android-aar/ffmpeg-kit/ffmpeg-kit.aar" +if [[ ! -s "$AAR" ]]; then + echo "The FFmpegKit source build did not produce an AAR." >&2 + exit 1 +fi +cp "$AAR" "$OUT/ffmpeg-kit-https-7.1.6.aar" +sha256sum "$OUT/ffmpeg-kit-https-7.1.6.aar" diff --git a/scripts/prepare_ffmpeg_compliance.py b/scripts/prepare_ffmpeg_compliance.py index 4df6661..411d641 100644 --- a/scripts/prepare_ffmpeg_compliance.py +++ b/scripts/prepare_ffmpeg_compliance.py @@ -1,8 +1,5 @@ -"""Collect the pinned FFmpegKit source bundle and verify the unchanged native artifact. - -Uses curl for HTTPS downloads and gh for public GitHub metadata. Never executes -downloaded build scripts. Archives stay under ignored build/compliance/. -""" +"""Package and verify the pinned FFmpegKit source-built release AAR.""" +import argparse import concurrent.futures import hashlib import json @@ -13,139 +10,204 @@ import zipfile ROOT = pathlib.Path(__file__).resolve().parents[1] -OUT = ROOT / 'build/compliance/ffmpeg-7.1.6' -EVIDENCE = ROOT / 'third_party/ffmpeg' -ASSETS = ROOT / 'app/src/main/assets/licenses' -AAR_SHA = 'e80e5ee52248a3dcb1dda0524dd784fe4bbb212dcd4d575e82e15d21a002d860' -MAVEN = 'https://repo.maven.apache.org/maven2/dev/ffmpegkit-maintained/ffmpeg-kit-https/7.1.6/' -BUILD_COMMIT = '0cc470227a1730ce3d467967677de4f54e759f7a' +OUT = ROOT / "build/compliance/ffmpeg-7.1.6" +EVIDENCE = OUT / "evidence" +PATCHES = ROOT / "third_party/ffmpeg/patches" +REVIEW = ROOT / "third_party/ffmpeg/source-build.json" +ASSETS = ROOT / "app/src/main/assets/licenses" SOURCES = [ - ('ffmpegkit', 'ffmpegkit-maintained/ffmpeg', BUILD_COMMIT), - ('ffmpeg', 'arthenica/FFmpeg', '3a0867c2bfda4a4d4309ca1a8cbdc6175e67f587'), - ('cpu-features', 'arthenica/cpu_features', '936b9ab5515dead115606559502e3864958f7f6e'), - ('gmp', 'arthenica/gmp', '66a538082b4a485bd2b34012db96e44a86a8e712'), - ('gnutls', 'arthenica/gnutls', 'ca61668d7764fc29fb4cc2aa396cb035e176636d'), - ('libiconv', 'arthenica/libiconv', '4b9c27bec873392533dd3ef309cd62962a644e18'), - ('nettle', 'arthenica/nettle', '990abad16ceacd070747dcc76ed16a39c129321e'), - ('libiconv-gnulib', 'arthenica/gnulib', '485d983b7795548fb32b12fbe8370d40789e88c4'), - # Declared submodule, not proof of the revision selected by --remote in CI. - ('gnutls-declared-gnulib', 'arthenica/gnulib', '3639c57a970191e0bf7a9789bd1341786d0255a1'), + ("gnu-config", "arthenica/gnu-config", "805517123cbfe33d17c989a18e78c5789fab0437"), + ("ffmpegkit", "ffmpegkit-maintained/ffmpeg", "0cc470227a1730ce3d467967677de4f54e759f7a"), + ("ffmpeg", "arthenica/FFmpeg", "3a0867c2bfda4a4d4309ca1a8cbdc6175e67f587"), + ("cpu-features", "arthenica/cpu_features", "936b9ab5515dead115606559502e3864958f7f6e"), + ("gmp", "arthenica/gmp", "66a538082b4a485bd2b34012db96e44a86a8e712"), + ("gnutls", "arthenica/gnutls", "ca61668d7764fc29fb4cc2aa396cb035e176636d"), + ("libiconv", "arthenica/libiconv", "4b9c27bec873392533dd3ef309cd62962a644e18"), + ("nettle", "arthenica/nettle", "990abad16ceacd070747dcc76ed16a39c129321e"), + ("libiconv-gnulib", "arthenica/gnulib", "485d983b7795548fb32b12fbe8370d40789e88c4"), + ("gnutls-declared-gnulib", "arthenica/gnulib", "3639c57a970191e0bf7a9789bd1341786d0255a1"), ] -EXPECTED_SOURCE_HASHES = {} +LICENSE_NAMES = ( + "COPYING", "COPYING.LIB", "COPYING.LESSER", "LICENSE", "LICENSE.md", + "LICENSE.LGPLv3", "LICENSE.GPLv3", "COPYING.GPLv3", "COPYING.LGPLv2.1", + "COPYING.LGPLv3", "COPYING.LESSERv3", "COPYINGv3", "THIRD-PARTY-NOTICES.txt", +) + -def sha(path): +def sha256(path: pathlib.Path) -> str: digest = hashlib.sha256() - with path.open('rb') as stream: - for chunk in iter(lambda: stream.read(1048576), b''): + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() -def api(endpoint): - result = subprocess.run(['gh', 'api', endpoint], capture_output=True, check=True) - return json.loads(result.stdout) -def download(url, target): +def download(url: str, target: pathlib.Path) -> pathlib.Path: if not target.exists(): - part = target.with_name(target.name + '.download') - subprocess.run(['curl.exe' if __import__('os').name == 'nt' else 'curl', '-sSfL', - '--proto', '=https', '--proto-redir', '=https', '--retry', '3', - '--connect-timeout', '30', '--max-time', '900', url, '-o', str(part)], check=True) + part = target.with_name(target.name + ".download") + curl = "curl.exe" if __import__("os").name == "nt" else "curl" + subprocess.run( + [curl, "-sSfL", "--proto", "=https", "--proto-redir", "=https", "--retry", "3", + "--connect-timeout", "30", "--max-time", "900", url, "-o", str(part)], + check=True, + ) part.replace(target) return target -def collect(item): - name, repo, ref = item - if re.fullmatch('[a-f0-9]{40}', ref): - commit = ref - else: - obj = api(f'repos/{repo}/git/ref/tags/{ref}')['object'] - while obj['type'] == 'tag': - obj = api(f'repos/{repo}/git/tags/{obj["sha"]}')['object'] - assert obj['type'] == 'commit' - commit = obj['sha'] - url = f'https://codeload.github.com/{repo}/tar.gz/{commit}' - target = download(url, OUT / f'{name}-{commit}.tar.gz') - actual_hash = sha(target) - expected_hash = EXPECTED_SOURCE_HASHES.get(name) - assert expected_hash and actual_hash == expected_hash, f'Unexpected source archive hash: {name}' + +def collect(item: tuple[str, str, str]) -> dict[str, object]: + name, repo, commit = item + url = f"https://codeload.github.com/{repo}/tar.gz/{commit}" + target = download(url, OUT / f"{name}-{commit}.tar.gz") + actual_hash = sha256(target) + expected_hash = EXPECTED_HASHES.get(name) + if not expected_hash or actual_hash != expected_hash: + raise RuntimeError(f"Unexpected source archive SHA-256 for {name}: {actual_hash}") + with tarfile.open(target) as archive: - # Read rather than extract an untrusted archive into the workspace. - members = [m for m in archive.getmembers() if m.isfile()] - assert members, f'Empty source archive: {name}' - for basename in ('COPYING', 'COPYING.LIB', 'COPYING.LESSER', 'LICENSE', 'LICENSE.md', 'LICENSE.LGPLv3', 'LICENSE.GPLv3', 'COPYING.GPLv3', 'COPYING.LGPLv2.1', 'COPYING.LGPLv3', 'COPYING.LESSERv3', 'COPYINGv3', 'THIRD-PARTY-NOTICES.txt'): - matches = [m for m in members if len(pathlib.PurePosixPath(m.name).parts) == 2 and m.name.endswith('/' + basename)] + members = [member for member in archive.getmembers() if member.isfile()] + if not members: + raise RuntimeError(f"Empty source archive: {name}") + for basename in LICENSE_NAMES: + matches = [ + member for member in members + if len(pathlib.PurePosixPath(member.name).parts) == 2 + and member.name.endswith("/" + basename) + ] if matches: - (ASSETS / f'{name}-{basename}.txt').write_bytes(archive.extractfile(matches[0]).read()) - if name == 'ffmpegkit': - for suffix, output in [('/android-7.1-lts/scripts/source.sh', 'source-versions.sh'), - ('/.github/workflows/build-71-free-https.yml', 'upstream-build.yml')]: - member = next(m for m in members if m.name.endswith(suffix)) - (EVIDENCE / output).write_bytes(archive.extractfile(member).read()) - if name == 'gnutls': - for suffix in ('doc/COPYING.LESSER', 'lib/accelerated/x86/license.txt', - 'lib/inih/LICENSE.txt', 'devel/perlasm/license.txt', - 'devel/perlasm/license-gnutls.txt', 'devel/perlasm/license-vpaes.txt'): - member = next(m for m in members if m.name.endswith('/' + suffix)) - (ASSETS / ('gnutls-' + suffix.replace('/', '-') + '.txt')).write_bytes(archive.extractfile(member).read()) - result = {'name': name, 'repository': f'https://github.com/{repo}', 'ref': ref, - 'commit': commit, 'url': url, 'file': target.name, 'sha256': actual_hash, 'bytes': target.stat().st_size} - print(f'Collected {name}: {commit} ({target.stat().st_size} bytes)', flush=True) + content = archive.extractfile(matches[0]) + assert content is not None + (ASSETS / f"{name}-{basename}.txt").write_bytes(content.read()) + + if name == "ffmpegkit": + for suffix, output in ( + ("/android-7.1-lts/scripts/source.sh", "source-versions.sh"), + ("/.github/workflows/build-71-free-https.yml", "upstream-build.yml"), + ): + member = next(member for member in members if member.name.endswith(suffix)) + content = archive.extractfile(member) + assert content is not None + (EVIDENCE / output).write_bytes(content.read()) + if name == "gnutls": + for suffix in ( + "doc/COPYING.LESSER", "lib/accelerated/x86/license.txt", "lib/inih/LICENSE.txt", + "devel/perlasm/license.txt", "devel/perlasm/license-gnutls.txt", + "devel/perlasm/license-vpaes.txt", + ): + member = next(member for member in members if member.name.endswith("/" + suffix)) + content = archive.extractfile(member) + assert content is not None + (ASSETS / ("gnutls-" + suffix.replace("/", "-") + ".txt")).write_bytes(content.read()) + + result = { + "name": name, + "repository": f"https://github.com/{repo}", + "commit": commit, + "url": url, + "file": target.name, + "sha256": actual_hash, + "bytes": target.stat().st_size, + } + print(f"Verified {name} source archive {commit} ({target.stat().st_size} bytes)", flush=True) return result -def main(): - reviewed_manifest = json.loads((EVIDENCE / 'manifest.json').read_text(encoding='utf8')) - global EXPECTED_SOURCE_HASHES - EXPECTED_SOURCE_HASHES = {source['name']: source['sha256'] for source in reviewed_manifest.get('sources', [])} - assert set(EXPECTED_SOURCE_HASHES) == {name for name, _, _ in SOURCES}, 'Manifest source archive pins are incomplete.' - for directory in (OUT, EVIDENCE, ASSETS): - directory.mkdir(parents=True, exist_ok=True) - for name in ('LICENSE', 'NOTICE'): - (ASSETS / ('CastTrace-' + name + '.txt')).write_bytes((ROOT / name).read_bytes()) - aar = ROOT / 'app/libs/ffmpeg-kit-https-7.1.6.aar' - assert sha(aar) == AAR_SHA, 'Unexpected FFmpeg binary; do not reuse these conclusions.' - sidecar = download(MAVEN + aar.name + '.sha256', OUT / 'maven-aar.sha256').read_text().strip() - assert sidecar.lower() == AAR_SHA - pom = download(MAVEN + 'ffmpeg-kit-https-7.1.6.pom', OUT / 'maven.pom') - (EVIDENCE / 'maven.pom').write_bytes(pom.read_bytes()) - java = download(MAVEN + 'ffmpeg-kit-https-7.1.6-sources.jar', OUT / 'ffmpeg-kit-https-7.1.6-sources.jar') - java_hash = download(MAVEN + java.name + '.sha256', OUT / 'java-sources.sha256').read_text().strip() - assert sha(java) == java_hash - binaries = [] + +def inspect_aar(aar: pathlib.Path, reviewed: dict[str, object]) -> list[dict[str, str]]: + native = [] + found_abis: set[str] = set() with zipfile.ZipFile(aar) as archive: for name in archive.namelist(): - if name.startswith('res/raw/license') and name.endswith('.txt'): + if name.startswith("res/raw/license") and name.endswith(".txt"): (ASSETS / pathlib.PurePosixPath(name).name).write_bytes(archive.read(name)) - if name.endswith('/libavcodec.so'): - strings = re.findall(rb'[ -~]{20,}', archive.read(name)) - config = next(s.decode() for s in strings if b'--enable-' in s) - assert '--enable-gpl' not in config and '--enable-nonfree' not in config - assert '--enable-version3' in config and '--enable-gnutls' in config - binaries.append({'file': name, 'configuration': config, 'version': next(s.decode() for s in strings if s.startswith(b'FFmpeg version '))}) + match = re.fullmatch(r"jni/(arm64-v8a|x86_64)/libavcodec\.so", name) + if not match: + continue + abi = match.group(1) + found_abis.add(abi) + strings = re.findall(rb"[ -~]{20,}", archive.read(name)) + configurations = [value.decode("ascii", errors="replace") for value in strings if b"--enable-" in value] + versions = [value.decode("ascii", errors="replace") for value in strings if value.startswith(b"FFmpeg version ")] + if len(configurations) != 1 or len(versions) != 1: + raise RuntimeError(f"Could not identify FFmpeg configuration/version for {abi}.") + config = configurations[0] + if any(flag in config for flag in reviewed["forbiddenFfmpegOptions"]): + raise RuntimeError(f"The {abi} build enables GPL or nonfree FFmpeg components.") + required = reviewed["requiredFfmpegOptions"] + if any(flag not in config for flag in required): + raise RuntimeError(f"The {abi} build is missing required HTTPS/LGPL flags.") + native.append({"abi": abi, "file": name, "configuration": config, "version": versions[0]}) + if found_abis != set(reviewed["expectedAbis"]): + raise RuntimeError(f"Unexpected FFmpeg AAR ABIs: {sorted(found_abis)}") + return native + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--aar", type=pathlib.Path, required=True, help="The AAR produced by build_ffmpeg_source.sh") + args = parser.parse_args() + aar = args.aar.resolve() + if not aar.is_file(): + raise FileNotFoundError(aar) + + reviewed = json.loads(REVIEW.read_text(encoding="utf-8")) + if reviewed.get("redistributionReady") is not True or reviewed.get("sourceGaps"): + raise RuntimeError("The reviewed FFmpeg source-build policy blocks redistribution.") + global EXPECTED_HASHES + EXPECTED_HASHES = reviewed.get("sourceSha256", {}) + if set(EXPECTED_HASHES) != {name for name, _, _ in SOURCES}: + raise RuntimeError("Manifest source archive pins do not cover every build input.") + + for directory in (OUT, EVIDENCE, ASSETS): + directory.mkdir(parents=True, exist_ok=True) + for name in ("LICENSE", "NOTICE"): + (ASSETS / ("CastTrace-" + name + ".txt")).write_bytes((ROOT / name).read_bytes()) + with concurrent.futures.ThreadPoolExecutor(max_workers=7) as executor: sources = list(executor.map(collect, SOURCES)) - manifest = {'artifact': reviewed_manifest['artifact'], 'sha256': AAR_SHA, - 'url': reviewed_manifest['url'], 'license': reviewed_manifest['license'], - 'buildCommit': reviewed_manifest['buildCommit'], 'buildRun': reviewed_manifest['buildRun'], - 'buildAssociation': reviewed_manifest['buildAssociation'], - 'native': binaries, 'sources': sources, - 'javaSources': {'file': java.name, 'url': MAVEN + java.name, 'sha256': sha(java)}, - 'systemLibraries': reviewed_manifest['systemLibraries'], - 'redistributionReady': reviewed_manifest['redistributionReady'], - 'sourceGaps': reviewed_manifest['sourceGaps'], - 'verificationScope': reviewed_manifest['verificationScope']} - for target in (EVIDENCE / 'manifest.json', OUT / 'manifest.json'): - target.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n', encoding='utf8') - with zipfile.ZipFile(OUT / 'casttrace-ffmpeg-7.1.6-source-materials.zip', 'w', zipfile.ZIP_STORED) as bundle: + native = inspect_aar(aar, reviewed) + digest = sha256(aar) + build_script = ROOT / "scripts/build_ffmpeg_source.sh" + manifest = { + "artifact": reviewed["artifact"], + "sha256": digest, + "bytes": aar.stat().st_size, + "license": "LGPL-3.0", + "buildCommit": reviewed["upstreamBuildCommit"], + "buildRecipe": reviewed["buildRecipe"], + "buildPatches": reviewed["patches"], + "buildEnvironment": reviewed["buildEnvironment"], + "native": native, + "sources": sources, + "systemLibraries": reviewed["systemLibraries"], + "redistributionReady": reviewed["redistributionReady"], + "sourceGaps": reviewed["sourceGaps"], + "verificationScope": reviewed["verificationScope"], + } + manifest_bytes = json.dumps(manifest, ensure_ascii=False, indent=2).encode("utf-8") + b"\n" + (OUT / "manifest.json").write_bytes(manifest_bytes) + + sidecar = OUT / "ffmpeg-kit-https-7.1.6.aar.sha256" + sidecar.write_text(f"{digest} ffmpeg-kit-https-7.1.6.aar\n", encoding="ascii") + bundle = OUT / "casttrace-ffmpeg-7.1.6-source-materials.zip" + with zipfile.ZipFile(bundle, "w", zipfile.ZIP_STORED) as archive: for source in sources: - bundle.write(OUT / source['file'], source['file']) - for path in (OUT / 'manifest.json', java, pom): - bundle.write(path, path.name) - for path in EVIDENCE.glob('*'): - if path.is_file(): bundle.write(path, 'evidence/' + path.name) - for path in ASSETS.glob('*.txt'): - bundle.write(path, 'licenses/' + path.name) - status = 'passed' if manifest['redistributionReady'] and not manifest['sourceGaps'] else 'blocked' - print(f'Source materials collected; public binary release gate {status}: ' + str(OUT / 'casttrace-ffmpeg-7.1.6-source-materials.zip')) - -if __name__ == '__main__': + archive.write(OUT / source["file"], source["file"]) + archive.write(OUT / "manifest.json", "manifest.json") + archive.write(sidecar, sidecar.name) + archive.write(build_script, "build/build_ffmpeg_source.sh") + archive.write(REVIEW, "build/source-build.json") + for patch in sorted(PATCHES.glob("*.patch")): + archive.write(patch, "build/patches/" + patch.name) + for path in sorted(EVIDENCE.rglob("*")): + if path.is_file(): + archive.write(path, "evidence/" + path.relative_to(EVIDENCE).as_posix()) + for path in sorted(ASSETS.glob("*.txt")): + archive.write(path, "licenses/" + path.name) + print(f"Source-built FFmpegKit AAR SHA-256: {digest}") + print(f"Corresponding-source materials ready: {bundle}") + + +EXPECTED_HASHES: dict[str, str] = {} + +if __name__ == "__main__": main() diff --git a/scripts/verify_release_readiness.py b/scripts/verify_release_readiness.py index b010bb6..2a5e7ed 100644 --- a/scripts/verify_release_readiness.py +++ b/scripts/verify_release_readiness.py @@ -1,29 +1,64 @@ -"""Fail closed unless the reviewed FFmpeg corresponding-source gate is clear.""" +"""Fail closed unless the exact source-built FFmpegKit AAR is release-ready.""" +import hashlib import json import pathlib import sys ROOT = pathlib.Path(__file__).resolve().parents[1] -MANIFEST = ROOT / "third_party/ffmpeg/manifest.json" +POLICY = ROOT / "third_party/ffmpeg/source-build.json" +MANIFEST = ROOT / "build/compliance/ffmpeg-7.1.6/manifest.json" +AAR = ROOT / "build/compliance/ffmpeg-7.1.6/ffmpeg-kit-https-7.1.6.aar" + + +def load(path: pathlib.Path) -> dict[str, object]: + if not path.is_file(): + raise RuntimeError(f"Required release evidence is missing: {path.relative_to(ROOT)}") + return json.loads(path.read_text(encoding="utf-8")) def main() -> int: - manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) - gaps = manifest.get("sourceGaps", []) - ready = manifest.get("redistributionReady") is True - - if not ready or gaps: - print("Release blocked: FFmpeg corresponding-source review is incomplete.") - if not ready: - print("- redistributionReady must be true after maintainer review.") - for gap in gaps: - print(f"- {gap}") - return 1 - - print("FFmpeg source gate passed.") + policy = load(POLICY) + manifest = load(MANIFEST) + if policy.get("redistributionReady") is not True or policy.get("sourceGaps"): + raise RuntimeError("The reviewed FFmpeg source-build policy blocks redistribution.") + if manifest.get("redistributionReady") is not True or manifest.get("sourceGaps"): + raise RuntimeError("The generated FFmpeg source manifest blocks redistribution.") + if manifest.get("buildCommit") != policy.get("upstreamBuildCommit"): + raise RuntimeError("The generated FFmpeg source manifest uses an unexpected upstream commit.") + + expected_sources = policy.get("sourceSha256", {}) + actual_sources = {source["name"]: source["sha256"] for source in manifest.get("sources", [])} + if actual_sources != expected_sources: + raise RuntimeError("The generated FFmpeg source archive hashes do not match the reviewed pins.") + + digest = manifest.get("sha256", "") + if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest.lower()): + raise RuntimeError("The generated FFmpeg AAR manifest has no valid SHA-256 digest.") + if not AAR.is_file() or hashlib.sha256(AAR.read_bytes()).hexdigest() != digest.lower(): + raise RuntimeError("The generated FFmpeg AAR does not match its recorded SHA-256 digest.") + if manifest.get("buildPatches") != policy.get("patches"): + raise RuntimeError("The generated FFmpeg build patches differ from the reviewed policy.") + + native = manifest.get("native", []) + expected_abis = set(policy.get("expectedAbis", [])) + actual_abis = {item["abi"] for item in native} + if actual_abis != expected_abis: + raise RuntimeError(f"Unexpected FFmpeg native ABIs: {sorted(actual_abis)}") + for item in native: + config = item["configuration"] + if any(option in config for option in policy["forbiddenFfmpegOptions"]): + raise RuntimeError(f"The {item['abi']} binary contains a forbidden FFmpeg build option.") + if any(option not in config for option in policy["requiredFfmpegOptions"]): + raise RuntimeError(f"The {item['abi']} binary is missing a required FFmpeg build option.") + + print(f"FFmpeg source gate passed for AAR SHA-256 {digest}.") return 0 if __name__ == "__main__": - sys.exit(main()) + try: + sys.exit(main()) + except (OSError, KeyError, TypeError, ValueError, RuntimeError) as error: + print(f"Release blocked: {error}", file=sys.stderr) + sys.exit(1) diff --git a/third_party/ffmpeg/patches/0001-pin-gnutls-gnulib.patch b/third_party/ffmpeg/patches/0001-pin-gnutls-gnulib.patch new file mode 100644 index 0000000..b331538 --- /dev/null +++ b/third_party/ffmpeg/patches/0001-pin-gnutls-gnulib.patch @@ -0,0 +1,10 @@ +diff --git a/android-7.1-lts/scripts/android/gnutls.sh b/android-7.1-lts/scripts/android/gnutls.sh +--- a/android-7.1-lts/scripts/android/gnutls.sh ++++ b/android-7.1-lts/scripts/android/gnutls.sh +@@ -42,3 +42,4 @@ +- ./bootstrap --skip-po || return 1 +- git submodule update --remote gnulib || return 1 ++ # Initialize the gitlink before bootstrap, using its pinned commit. ++ git submodule update --init -- gnulib || return 1 ++ ./bootstrap --skip-po || return 1 + overwrite_file ./gnulib/lib/fpending.c ./src/gl/fpending.c || return 1 diff --git a/third_party/ffmpeg/patches/0002-pin-source-commits.patch b/third_party/ffmpeg/patches/0002-pin-source-commits.patch new file mode 100644 index 0000000..47c1016 --- /dev/null +++ b/third_party/ffmpeg/patches/0002-pin-source-commits.patch @@ -0,0 +1,70 @@ +diff --git a/android-7.1-lts/scripts/source.sh b/android-7.1-lts/scripts/source.sh +--- a/android-7.1-lts/scripts/source.sh ++++ b/android-7.1-lts/scripts/source.sh +@@ -11,5 +11,5 @@ get_library_source() { + config) + SOURCE_REPO_URL="https://github.com/arthenica/gnu-config" +- SOURCE_ID="v20210814" +- SOURCE_TYPE="TAG" ++ SOURCE_ID="805517123cbfe33d17c989a18e78c5789fab0437" ++ SOURCE_TYPE="COMMIT" + ;; +@@ -23,5 +23,5 @@ get_library_source() { + cpu-features) + SOURCE_REPO_URL="https://github.com/arthenica/cpu_features" +- SOURCE_ID="v0.8.0" +- SOURCE_TYPE="TAG" ++ SOURCE_ID="936b9ab5515dead115606559502e3864958f7f6e" ++ SOURCE_TYPE="COMMIT" + ;; +@@ -31,5 +31,5 @@ get_library_source() { + ffmpeg) + SOURCE_REPO_URL="https://github.com/arthenica/FFmpeg" +- SOURCE_ID="n7.1.5" +- SOURCE_TYPE="TAG" ++ SOURCE_ID="3a0867c2bfda4a4d4309ca1a8cbdc6175e67f587" ++ SOURCE_TYPE="COMMIT" + ;; +@@ -39,5 +39,5 @@ get_library_source() { + gmp) + SOURCE_REPO_URL="https://github.com/arthenica/gmp" +- SOURCE_ID="v6.2.1" +- SOURCE_TYPE="TAG" ++ SOURCE_ID="66a538082b4a485bd2b34012db96e44a86a8e712" ++ SOURCE_TYPE="COMMIT" + ;; +@@ -47,5 +47,5 @@ get_library_source() { + gnutls) + SOURCE_REPO_URL="https://github.com/arthenica/gnutls" +- SOURCE_ID="3.7.9" +- SOURCE_TYPE="TAG" ++ SOURCE_ID="ca61668d7764fc29fb4cc2aa396cb035e176636d" ++ SOURCE_TYPE="COMMIT" + ;; +@@ -63,5 +63,5 @@ get_library_source() { + libiconv) + SOURCE_REPO_URL="https://github.com/arthenica/libiconv" +- SOURCE_ID="v1.17" +- SOURCE_TYPE="TAG" ++ SOURCE_ID="4b9c27bec873392533dd3ef309cd62962a644e18" ++ SOURCE_TYPE="COMMIT" + ;; +@@ -79,5 +79,5 @@ get_library_source() { + nettle) + SOURCE_REPO_URL="https://github.com/arthenica/nettle" +- SOURCE_ID="nettle_3.8.1_release_20220727" +- SOURCE_TYPE="TAG" ++ SOURCE_ID="990abad16ceacd070747dcc76ed16a39c129321e" ++ SOURCE_TYPE="COMMIT" + ;; +diff --git a/android-7.1-lts/scripts/function.sh b/android-7.1-lts/scripts/function.sh +--- a/android-7.1-lts/scripts/function.sh ++++ b/android-7.1-lts/scripts/function.sh +@@ -2233,1 +2233,5 @@ download_gnu_config() { +- DOWNLOAD_RC=$(clone_git_repository_with_tag "${SOURCE_REPO_URL}" "${SOURCE_ID}" "${LIB_LOCAL_PATH}") ++ if [ "${SOURCE_TYPE}" == "TAG" ]; then ++ DOWNLOAD_RC=$(clone_git_repository_with_tag "${SOURCE_REPO_URL}" "${SOURCE_ID}" "${LIB_LOCAL_PATH}") ++ else ++ DOWNLOAD_RC=$(clone_git_repository_with_commit_id "${SOURCE_REPO_URL}" "${LIB_LOCAL_PATH}" "${SOURCE_ID}") ++ fi + echo -e "INFO: Downloading gnu config failed. Can not get source from ${SOURCE_REPO_URL}\n" 1>>"${BASEDIR}"/build.log 2>&1 diff --git a/third_party/ffmpeg/source-build.json b/third_party/ffmpeg/source-build.json new file mode 100644 index 0000000..12c424b --- /dev/null +++ b/third_party/ffmpeg/source-build.json @@ -0,0 +1,51 @@ +{ + "schemaVersion": 1, + "artifact": "CastTrace FFmpegKit HTTPS 7.1.6 source build", + "upstreamBuildCommit": "0cc470227a1730ce3d467967677de4f54e759f7a", + "buildRecipe": "scripts/build_ffmpeg_source.sh", + "patches": [ + "third_party/ffmpeg/patches/0001-pin-gnutls-gnulib.patch", + "third_party/ffmpeg/patches/0002-pin-source-commits.patch" + ], + "buildEnvironment": { + "operatingSystem": "Ubuntu 24.04", + "java": "17", + "androidPlatform": "35", + "androidNdk": "26.2.11394342 (r26c)" + }, + "expectedAbis": [ + "arm64-v8a", + "x86_64" + ], + "requiredFfmpegOptions": [ + "--enable-version3", + "--enable-gnutls", + "--enable-gmp", + "--enable-iconv", + "--enable-mediacodec" + ], + "forbiddenFfmpegOptions": [ + "--enable-gpl", + "--enable-nonfree" + ], + "sourceSha256": { + "gnu-config": "0a7fc52cf626ed21097de5610b6597d2b26cf95c4d8b3231a06fd4abb35ae84a", + "ffmpegkit": "fbc24d857b71e3b65d878e9bf5f594c5db55e306bb1f8c2f4c098fd4230a2345", + "ffmpeg": "83220fe0807da10df19714176f02ec3d3812ef0e055f69e940d01b3ad1379cdb", + "cpu-features": "851c2f0b1e31b0d3a5389ba5df1850e27670fd7c09c8fa1d1ae322d6035135d2", + "gmp": "f7d64ef6e8dd121fec28088234a723c033fb05faeb08b0f429d3c523d3466441", + "gnutls": "24a986acadfdf5598eba06efecc77cbb9b7bc51285f70b9ec6bb62ef0051b827", + "libiconv": "f652b492c4107e3c887d78ae8bc1295dfecca53e932bac92f053fcd4b9a1ffa7", + "nettle": "f4c41e50012f9c559af5a85899dc32a031a1756b1091c210c44d3c3e0f78d008", + "libiconv-gnulib": "161290586d00a752fed2d8be64713b1b3ecfe8427f9b0e6f1d68b8fd9eee9e1f", + "gnutls-declared-gnulib": "41cafb20f14f9b9bd1330d0208cc8e7daf233076cf4359c862f867c08b84daca" + }, + "systemLibraries": [ + "Android libc/libm/libdl/liblog", + "Android MediaCodec", + "Android zlib" + ], + "redistributionReady": true, + "sourceGaps": [], + "verificationScope": "The release workflow builds the AAR from these pinned sources and patches, verifies the AAR SHA-256 and native configuration, and passes that same AAR to Gradle for testing and APK packaging. This does not claim byte-for-byte equivalence with the separate Maven development artifact." +}