From 6c9c650b68847d2fe3400e79a767156945c9cd25 Mon Sep 17 00:00:00 2001 From: Florian Date: Mon, 7 Sep 2026 11:31:57 +0200 Subject: [PATCH] Adding support for Obtainium with fixed Signing Keys and apk Release publishing --- .gitea/workflows/BuildAndroid.yml | 101 ++++++++++++++++++++++++++-- .gitea/workflows/BuildLinux.yml | 32 ++++++++- CLAUDE.md | 16 ++++- ac_web/android/app/build.gradle.kts | 46 +++++++++++-- 4 files changed, 181 insertions(+), 14 deletions(-) diff --git a/.gitea/workflows/BuildAndroid.yml b/.gitea/workflows/BuildAndroid.yml index d84fa47..2ada892 100644 --- a/.gitea/workflows/BuildAndroid.yml +++ b/.gitea/workflows/BuildAndroid.yml @@ -39,7 +39,7 @@ jobs: - name: Install the download tools run: | apt-get update - apt-get install -y --no-install-recommends ca-certificates curl xz-utils + apt-get install -y --no-install-recommends ca-certificates curl xz-utils jq - name: Set up Java uses: https://github.com/actions/setup-java@v4 @@ -74,21 +74,110 @@ jobs: - name: Run tests run: flutter test + # Android accepts an update only when it is signed with the same key as + # the installed app, so every release has to carry one key that never + # changes. The debug key cannot be used: the runner generates a fresh one + # on every run, which would make each apk a different app to android and + # break every update through Obtainium. + # + # `android/app/build.gradle.kts` picks the key up from + # `android/key.properties`, which is written here from the repository + # secrets (Settings -> Actions -> Secrets). See CLAUDE.md for the + # `keytool` command that creates the keystore. + - name: Write the release signing key + env: + KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + set -euo pipefail + + # Failing here is on purpose: a silently debug signed apk would be + # installable once and never updatable afterwards. + if [ -z "${KEYSTORE_BASE64:-}" ]; then + echo "::error::The secret ANDROID_KEYSTORE_BASE64 is empty. Add the keystore secrets (see CLAUDE.md) before publishing a release." + exit 1 + fi + + printf '%s' "$KEYSTORE_BASE64" | base64 -d > android/app/release-keystore.jks + + # storeFile is relative to `android/app`. The file is gitignored. + cat > android/key.properties </api/v1/repos///releases` and + # downloads the *assets* of a release, so the apk has to hang off the + # release itself. A workflow artifact is invisible to it: it lives under + # the run, is zipped and needs a login. + - name: Attach the apk to the release + env: + # The token gitea creates for the run. If the instance hands out a + # read only token, put a personal access token with repository write + # access in a secret and use that instead. + TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}" + asset="ac_web-${{ github.ref_name }}.apk" + + # The release event carries the id; the lookup by tag is only there + # for a manual re-run of the job. + releaseId="${{ github.event.release.id }}" + if [ -z "$releaseId" ] || [ "$releaseId" = "null" ]; then + releaseId=$(curl -fsSL -H "Authorization: token $TOKEN" \ + "$api/releases/tags/${{ github.ref_name }}" | jq -r '.id') + fi + + curl -fsSL -X POST \ + -H "Authorization: token $TOKEN" \ + -F "attachment=@$asset" \ + "$api/releases/$releaseId/assets?name=$asset" > /dev/null + + echo "Attached $asset to release $releaseId." + # @v3 on purpose: `upload-artifact@v4` bundles `@actions/artifact` v2, # which refuses to run as soon as `GITHUB_SERVER_URL` is not github.com # ("GHESNotSupportedError") -- and on this instance it is the gitea host. diff --git a/.gitea/workflows/BuildLinux.yml b/.gitea/workflows/BuildLinux.yml index 00f53b3..3a27907 100644 --- a/.gitea/workflows/BuildLinux.yml +++ b/.gitea/workflows/BuildLinux.yml @@ -41,7 +41,7 @@ jobs: apt-get install -y --no-install-recommends \ clang cmake ninja-build pkg-config \ libgtk-3-dev liblzma-dev libstdc++-12-dev \ - curl unzip xz-utils + curl unzip xz-utils jq # `subosito/flutter-action` cannot be used: it caches the sdk through # `actions/cache@v5`, and the act-runner of this gitea instance only @@ -72,6 +72,36 @@ jobs: - name: Pack the bundle run: tar -czf ac_web-linux-x64-${{ github.ref_name }}.tar.gz -C build/linux/x64/release bundle + # The tarball hangs off the release itself, next to the apk of the + # android job, so it can be downloaded without a login and stays after + # the artifacts of the run have been pruned. + - name: Attach the bundle to the release + env: + # The token gitea creates for the run. If the instance hands out a + # read only token, put a personal access token with repository write + # access in a secret and use that instead. + TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}" + asset="ac_web-linux-x64-${{ github.ref_name }}.tar.gz" + + # The release event carries the id; the lookup by tag is only there + # for a manual re-run of the job. + releaseId="${{ github.event.release.id }}" + if [ -z "$releaseId" ] || [ "$releaseId" = "null" ]; then + releaseId=$(curl -fsSL -H "Authorization: token $TOKEN" \ + "$api/releases/tags/${{ github.ref_name }}" | jq -r '.id') + fi + + curl -fsSL -X POST \ + -H "Authorization: token $TOKEN" \ + -F "attachment=@$asset" \ + "$api/releases/$releaseId/assets?name=$asset" > /dev/null + + echo "Attached $asset to release $releaseId." + # @v3 on purpose: `upload-artifact@v4` bundles `@actions/artifact` v2, # which refuses to run as soon as `GITHUB_SERVER_URL` is not github.com # ("GHESNotSupportedError") -- and on this instance it is the gitea host. diff --git a/CLAUDE.md b/CLAUDE.md index ae6e7e2..e9e3b8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,8 +38,8 @@ Widget tests run against a mocked backend: build a `MockClient` that answers by | Workflow | Builds | Result | | --- | --- | --- | -| `BuildLinux.yml` | `flutter build linux --release` | `ac_web-linux-x64-.tar.gz` (the whole bundle directory) as an artifact | -| `BuildAndroid.yml` | `flutter build apk --release` | `ac_web-.apk` as an artifact | +| `BuildLinux.yml` | `flutter build linux --release` | `ac_web-linux-x64-.tar.gz` (the whole bundle directory), attached to the release and uploaded as an artifact | +| `BuildAndroid.yml` | `flutter build apk --release` | `ac_web-.apk`, attached to the release and uploaded as an artifact | | `PublishDocker.yml` | `ac_web/Dockerfile` | image pushed to the Gitea registry as ``, `latest` and the short sha | Notes that are easy to get wrong: @@ -50,7 +50,17 @@ Notes that are easy to get wrong: - **`actions/upload-artifact` must stay at `@v3`.** `@v4` bundles `@actions/artifact` v2, which aborts with `GHESNotSupportedError` whenever `GITHUB_SERVER_URL` is not `github.com` -- on this instance it is the gitea host, so the check fires before any request and cannot be worked around on the server side. `@v3` uses the older artifact api gitea implements. - The workflows are read from the tag the release points at, so they have to be committed **before** the release is created. - Artifacts and the built files carry the tag name (`github.ref_name`); the docker image is pushed as the tag, as `latest` and as the short sha. -- The apk is signed with a debug key, because `android/app/build.gradle.kts` still has no release signing config. +- **The built files are attached to the release, not only uploaded as artifacts.** Artifacts live under the workflow run, are zipped and need a login; a release asset is a plain download on the release page. The upload is a `curl` against `POST /api/v1/repos///releases//assets?name=` with `${{ github.token }}`, and the release id comes from the event (falling back to a lookup by tag for a manual re-run). Obtainium needs the apk exactly there, see below. +- **The apk must keep one signing key forever.** Android identifies an app by its application id *and* its certificate, so an update signed with a different key is rejected (`INSTALL_FAILED_UPDATE_INCOMPATIBLE`) and the app has to be uninstalled first. The debug key is regenerated on every runner, so it can never be used for a published apk. `android/app/build.gradle.kts` reads the key from `android/key.properties` (gitignored) and falls back to the debug key when that file is missing, so `flutter run --release` still works locally. The workflow writes the file from four secrets and fails loudly when the first one is empty: `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`. Create the keystore once and keep a backup — losing it means every installed app has to be uninstalled before it can be updated again: + + ```bash + keytool -genkeypair -v -keystore ac_web-release.p12 -storetype PKCS12 \ + -keyalg RSA -keysize 2048 -validity 10000 -alias ac-web + base64 -w0 ac_web-release.p12 # the value of ANDROID_KEYSTORE_BASE64 + ``` + +- **The apk version comes from the release, not from the pubspec.** `version: 1.0.0+1` never moves, so the workflow passes `--build-name ` and `--build-number ${{ github.run_number }}`; the run number only grows, and android refuses an apk whose version code is lower than the installed one. A tag that is not a dotted number keeps the pubspec version and only warns. +- **Obtainium** tracks the app through the Gitea api: add the repository url (`https:///ArtificialCompany/AcWeb`) and pick `Codeberg (Forgejo)` under "Override Source" — self hosted instances are not detected automatically, but the source builds its api calls from the host in the url. It reads `/api/v1/repos/.../releases` and downloads the release assets, so the repository has to be readable without a login from the phone. - The docker image is self contained: it compiles the web bundle itself, so `docker build -f ac_web/Dockerfile ac_web` gives the same result locally. It serves the static bundle with nginx (`ac_web/nginx.conf`), which falls back to `index.html` so deep links survive a reload. The backend address is compiled into the bundle, so the host in `main.dart` must be reachable from the browser of every visitor, not only from the container. ## Backend connection diff --git a/ac_web/android/app/build.gradle.kts b/ac_web/android/app/build.gradle.kts index 18716a6..07bd737 100644 --- a/ac_web/android/app/build.gradle.kts +++ b/ac_web/android/app/build.gradle.kts @@ -1,9 +1,31 @@ +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") } +// Android identifies an app by its application id *and* the certificate it was +// signed with, so an update is only accepted when it carries the same +// signature as the installed app. Obtainium therefore needs every release to +// be signed with one key that never changes - the debug key does not work, it +// is generated per machine and a fresh one appears on every ci run. +// +// The key itself is described by `android/key.properties` (storeFile, +// storePassword, keyAlias, keyPassword). That file and the keystore are +// gitignored and never committed: a developer creates them once locally, the +// workflow writes them from the repository secrets. Without the file the +// release build falls back to the debug key so that `flutter run --release` +// keeps working on a machine that has no keystore. +val keystorePropertiesFile = rootProject.file("key.properties") +val hasReleaseKeystore = keystorePropertiesFile.exists() +val keystoreProperties = Properties() + +if (hasReleaseKeystore) { + keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) } +} + android { namespace = "com.fscheffers.ac_web" compileSdk = flutter.compileSdkVersion @@ -15,21 +37,37 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "com.fscheffers.ac_web" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion + // Both come from the pubspec (`version: +`); the workflow + // overrides them with the release tag and the run number. versionCode = flutter.versionCode versionName = flutter.versionName } + signingConfigs { + if (hasReleaseKeystore) { + create("release") { + // A relative storeFile is resolved against `android/app`, an + // absolute one is taken as it is. + storeFile = file(keystoreProperties.getProperty("storeFile")) + storePassword = keystoreProperties.getProperty("storePassword") + keyAlias = keystoreProperties.getProperty("keyAlias") + keyPassword = keystoreProperties.getProperty("keyPassword") + } + } + } + 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 = if (hasReleaseKeystore) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } } } }