name: Android # The native Kotlin/Compose client over the shared Rust core. The store and sync # engine are `inkwell-core` cross-compiled by cargo-ndk and loaded through uniffi. # # CI can only prove this BUILDS. A Linux runner cannot execute an APK, so anything # about feel, touch or on-device correctness is an operator pass on an emulator or # phone. # # The artifact is a SIGNED RELEASE APK when the keystore secret is present, and an # unsigned debug one when it is not. That distinction is not cosmetic: two builds # signed with different keys cannot replace one another, and bridging that gap # means uninstalling first — which deletes the app's database and every local note # with it. on: push: # NO `paths:` FILTER — the `decide` job below reads the real file set instead. # See desktop.yml for why. branches: [dev, main] workflow_dispatch: concurrency: group: android-${{ github.ref }} cancel-in-progress: true env: # Silences the JDK 22+ "restricted method in java.lang.System has been called" # warning that Gradle 9.1's bundled native-platform jar trips at launch. This # targets the LAUNCHER JVM, which is why org.gradle.jvmargs in # gradle.properties is not enough on its own. JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED" jobs: # Does the APK need rebuilding, or is the channel already serving this source? # See the equivalent job in desktop.yml — same reasoning, the same one file set in # `packaging/version.sh`. # # The guard runs here so it covers the skip path too. # # NOTE THE COUPLING WITH ci.yml: when this lane builds, its server-image job dispatches # ci.yml so the image bakes in the APK just published. When it SKIPS, no dispatch # happens — and that is correct, because ci.yml's `gate` stands down only when the # push touched Android's files, which is the same condition that makes this build. # The two decisions agree because they read the same fact; they are still two # readers of it, which is why the gate's grep carries a comment pointing here. decide: name: Build, or is the channel already serving this? runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-python:3.14 outputs: build: ${{ steps.d.outputs.build }} steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Decide id: d env: GITHUB_TOKEN: ${{ github.token }} run: | case "$GITHUB_REF_NAME" in main) channel=stable ;; *) channel=dev ;; esac sh packaging/guard-forward.sh android "$channel" echo "build=$(sh packaging/should-build.sh android "$channel")" >> $GITHUB_OUTPUT # The core ships inside the APK (through android/ffi), so its checks have to gate # the APK HERE, in this workflow's graph. desktop.yml runs them too, but a red run # there cannot stop this lane publishing. # # On ci-tauri, not ci-rust-android: these are host-target tests, and on Linux the # core's native-tls is OpenSSL, whose headers only ci-tauri carries. The Android # image has OpenSSL vendored for the Android targets alone, so `openssl-sys` fails # there. Same Rust pin, same Cargo.lock. rust: name: Core and FFI clippy and tests needs: [decide] if: needs.decide.outputs.build == 'true' runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-tauri:1.97 steps: - uses: actions/checkout@v6 - name: Clippy run: cargo clippy --locked -p inkwell-core -p inkwell-ffi --all-targets -- -D warnings - name: Test run: cargo test --locked -p inkwell-core -p inkwell-ffi build: name: Kotlin + Rust (APK) needs: [decide, rust] if: needs.decide.outputs.build == 'true' # runs-on is only a scheduling label. flutter-ci is the # proven-working label that can pull our container images. runs-on: flutter-ci container: # Rust + the four # Android ABIs + cargo-ndk + SDK/NDK + JDK 25 + ktlint + detekt. image: git.fabledsword.com/bvandeusen/ci-rust-android:1.97 permissions: contents: write defaults: run: working-directory: android steps: - uses: actions/checkout@v4 with: # Derives a version, so it needs the whole history — see the note in # desktop.yml. Depth-1 is silently wrong here, not loudly broken. fetch-depth: 0 - name: Cache Gradle and Cargo uses: actions/cache@v4 with: path: | ~/.gradle/caches ~/.gradle/wrapper ~/.kotlin target key: android-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/gradle/libs.versions.toml', 'android/**/*.gradle.kts', 'Cargo.lock') }} restore-keys: | android- # Everything downstream keys off this: the variant to build, the Cargo # profile to build it with, and the version it carries. Decided once so no # two Gradle invocations in this run can disagree and force a second # four-minute cross-compile. - name: Signing key, variant and version id: build env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} run: | # TWO CLOCKS, ON PURPOSE. The NAME answers "is this the # same code?", so it comes from the COMMIT and a dev build and the main # build of one commit read identically. The CODE answers "may this be # installed over that?" and must be monotonic BY CONSTRUCTION, because # Android hard-fails a downgrade with INSTALL_FAILED_VERSION_DOWNGRADE and # leaves a channel you cannot get out of — so it comes from BUILD time, # which cannot go backwards. Commit time can. version="$(sh ../packaging/version.sh display android)" code="$(sh ../packaging/version.sh key android)" echo "name=$version" >> $GITHUB_OUTPUT echo "code=$code" >> $GITHUB_OUTPUT if [ -n "${ANDROID_KEYSTORE_BASE64:-}" ]; then printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/inkwell-release.jks echo "variant=Release" >> $GITHUB_OUTPUT echo "label=release" >> $GITHUB_OUTPUT # An optimised .so for the APK people install. The cargoNdk task keeps # the symbols uniffi reads its metadata from, which the release # profile would strip. echo "profile=release" >> $GITHUB_OUTPUT echo "keystore=/tmp/inkwell-release.jks" >> $GITHUB_OUTPUT echo "apk=android/app/build/outputs/apk/release/app-release.apk" >> $GITHUB_OUTPUT echo "Signed release build — $version (versionCode $code)" else echo "::warning::No ANDROID_KEYSTORE_BASE64 secret. Building an UNSIGNED DEBUG APK: it cannot be installed over a signed build and cannot self-update." echo "variant=Debug" >> $GITHUB_OUTPUT echo "label=debug" >> $GITHUB_OUTPUT echo "profile=debug" >> $GITHUB_OUTPUT echo "keystore=" >> $GITHUB_OUTPUT echo "apk=android/app/build/outputs/apk/debug/app-debug.apk" >> $GITHUB_OUTPUT fi - name: Make gradlew executable run: chmod +x ./gradlew # Fails loudly here if the wrapper and the image's JDK disagree, rather # than thirty seconds into a compile with an opaque version message. - name: Gradle wrapper check run: ./gradlew --version # Cross-compiles the core for four ABIs and generates the Kotlin bindings # from the built .so. Run as its own step so a Rust failure is legible as a # Rust failure instead of arriving inside a Gradle stack trace. - name: Build the native library and bindings run: ./gradlew generateUniffiBindings -PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }} # The image's PINNED CLIs, not Gradle plugins. ci-rust-android carries both # precisely so this lane needs no second image, and going # through Gradle plugins would mean a second version of each tool resolved # at build time and kept in lockstep with the image's by hand. # # Scoped to src/main: the generated uniffi bindings live under build/ and # are not ours to style. - name: ktlint run: ktlint "app/src/main/**/*.kt" - name: detekt run: detekt --build-upon-default-config --config config/detekt.yml --input app/src/main/java - name: Unit tests # Host-JVM tests only. Anything touching the core needs an Android # runtime to load the .so, so those are instrumented tests and belong on # an emulator, not here — the Rust side is the `rust` job, which this # one needs. # # DEBUG regardless of what is being packaged: AGP creates unit-test tasks # only for `testBuildType`, which is debug, so `testReleaseUnitTest` does # not exist. It costs one extra Kotlin compile and buys the # type-check on the debug variant, which is the one an emulator build # would use. run: ./gradlew testDebugUnitTest -PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }} - name: Assemble the APK env: # Empty on the unsigned path, which build.gradle.kts reads as "no # signing config" rather than as a path to a missing file. ANDROID_KEYSTORE_FILE: ${{ steps.build.outputs.keystore }} ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} run: | ./gradlew assemble${{ steps.build.outputs.variant }} \ -PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }} \ -PINKWELL_VERSION_NAME=${{ steps.build.outputs.name }} \ -PINKWELL_VERSION_CODE=${{ steps.build.outputs.code }} # Prints the certificate the APK was actually signed with, so the operator # can compare it against the fingerprint recorded when the key was # generated. Signing with the WRONG key produces a perfectly valid APK that # simply refuses to install over the app already on the phone — a failure # that otherwise only shows up on the device, after the run is green. # # And FAILS unless there is exactly one signer and it is the release key. # Printing alone is not a check: a build signed with any other key would go on # to be staged and published, and every phone would refuse it. The steps that # publish come after this one in the same job, so a failure here stops them. - name: Check the signing certificate if: steps.build.outputs.keystore != '' env: # The release certificate's SHA-256, as apksigner prints it for a signed # APK (CN=Bryan Van Deusen, O=fabledsword). Every # installed copy carries this certificate. It only changes if the key # does, and then every install has to be replaced by hand anyway. RELEASE_CERT_SHA256: 408a5835ea1627f329d9fba4f00712b096faf2d30624ec6f3a8b2b5f8bb15caa run: | apksigner="$(ls /opt/android-sdk/build-tools/*/apksigner | head -1)" certs="$("$apksigner" verify --print-certs "app/build/outputs/apk/release/app-release.apk")" printf '%s\n' "$certs" signers="$(printf '%s\n' "$certs" | grep -c '^Signer #[0-9]* certificate SHA-256 digest:' || true)" digest="$(printf '%s\n' "$certs" | sed -n 's/^Signer #1 certificate SHA-256 digest: //p')" if [ "$signers" != 1 ] || [ "$digest" != "$RELEASE_CERT_SHA256" ]; then echo "::error::The APK is not signed by the release key alone ($signers signer(s), first $digest; expected $RELEASE_CERT_SHA256)." exit 1 fi echo "Signed by the release key." # Staged with a STABLE name plus the sidecar the server reads its version # out of — an APK keeps that in a binary manifest Python cannot parse, and # `aapt` is not on a Quart server. Computed here, where the real values are # already known. - name: Stage the client for distribution if: steps.build.outputs.keystore != '' run: | mkdir -p dist cp "app/build/outputs/apk/release/app-release.apk" dist/inkwell.apk size="$(wc -c < dist/inkwell.apk | tr -d ' ')" sha="$(sha256sum dist/inkwell.apk | cut -d' ' -f1)" cat > dist/inkwell-android.json <` holding a client that # isn't from that commit. The previous `:dev` stays where it is; fix forward. # # Not skipped on tags: every ref that builds an image needs the call, since # ci.yml stands down on these pushes and nothing else builds one. - name: Build the server image now the client is published env: GITHUB_TOKEN: ${{ github.token }} run: | # Loud on failure rather than `|| true`: if this call stops working, the # symptom is server images silently never being built for Android pushes, # which is invisible until someone wonders why the app never updates. curl -fsS -X POST \ -H "Authorization: token $GITHUB_TOKEN" \ -H "Content-Type: application/json" \ -d '{"ref":"${{ github.ref_name }}"}' \ "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/workflows/ci.yml/dispatches" echo "Dispatched ci.yml on ${{ github.ref_name }}."