name: Android # The native Kotlin/Compose client over the shared Rust core (M12). # # Replaces the Tauri-mobile lane deleted in step 2. What changed is what this # builds, not that Android has a lane: the UI is Compose, and the store and sync # engine are `thoughtsync-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 (Scribe issue 2803). on: push: branches: [dev, main] paths: - "android/**" # The Rust the .so is built from. A core change reaches the phone exactly # as it reaches the desktop, so this lane has to rebuild on it. - "core/**" - "Cargo.toml" - "Cargo.lock" - ".forgejo/workflows/android.yml" 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 (Minstrel hit the same thing). JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED" jobs: build: name: Kotlin + Rust (APK) # runs-on is only a scheduling label (Label Model B). flutter-ci is the # proven-working label that can pull our container images. runs-on: flutter-ci container: # The image repurposed from ci-tauri-android in M12 step 3: 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 # For the dispatch at the end: this lane starts the server image build. actions: 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 (§6.1). 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 (note 3127 §2). 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/thoughtsync-release.jks echo "variant=Release" >> $GITHUB_OUTPUT echo "label=release" >> $GITHUB_OUTPUT # DEBUG profile, in a release APK, deliberately — see the note above # the cargoNdk task. The release profile strips the symbols uniffi # reads its metadata out of, so `generateUniffiBindings` fails # outright (run 4077). Unpicking that is worth doing and is not worth # blocking signed builds on. echo "profile=debug" >> $GITHUB_OUTPUT echo "keystore=/tmp/thoughtsync-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 -PTHOUGHTSYNC_CARGO_PROFILE=${{ steps.build.outputs.profile }} # The image's PINNED CLIs, not Gradle plugins. ci-rust-android carries both # (M12 step 3) 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 covered by the workspace # tests in the desktop lane. # # DEBUG regardless of what is being packaged: AGP creates unit-test tasks # only for `testBuildType`, which is debug, so `testReleaseUnitTest` does # not exist (run 4082). 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 -PTHOUGHTSYNC_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 }} \ -PTHOUGHTSYNC_CARGO_PROFILE=${{ steps.build.outputs.profile }} \ -PTHOUGHTSYNC_VERSION_NAME=${{ steps.build.outputs.name }} \ -PTHOUGHTSYNC_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. - name: Show the signing certificate if: steps.build.outputs.keystore != '' run: | apksigner="$(ls /opt/android-sdk/build-tools/*/apksigner | head -1)" "$apksigner" verify --print-certs "app/build/outputs/apk/release/app-release.apk" # 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/thoughtsync.apk size="$(wc -c < dist/thoughtsync.apk | tr -d ' ')" sha="$(sha256sum dist/thoughtsync.apk | cut -d' ' -f1)" cat > dist/thoughtsync-android.json <