android: a Kotlin/Compose app that drives the Rust core (M12 step 5)
The skeleton, and the lane that builds it. Gradle invokes cargo-ndk to
cross-compile thoughtsync-ffi for four ABIs, generates the Kotlin bindings from
the resulting .so, and packages both.
BUILT ON MINSTREL'S TOOLCHAIN, not a fresh guess. Gradle 9.1.0 / AGP 9.0.1 /
Kotlin 2.3.21 on JDK 25 is the combination already proven in this family on
ci-android, including the JDK 22+ native-access opt-in the launcher JVM needs
and the artifact-upload action pinned by SHA (issues 2255 / 2270). It also
independently confirms the JDK 25 call made on ci-rust-android in step 3.
Gradle wiring worth noting:
* ExecOperations, not project.exec — the latter was REMOVED in Gradle 9, and
touching `project` at execution time is also what breaks the configuration
cache this build enables.
* The cargo task's inputs are the Rust SOURCES, not the workspace directory.
Declaring the directory would make Gradle hash target/, which is gigabytes.
* Bindings are generated with `--library` against the built .so, so they can
never describe a different version of the Rust than the one being packaged.
* cargo runs --locked, so an Android build cannot silently re-resolve the
lockfile the desktop lanes are gated on.
JNA is a real dependency, with the @aar classifier. The plain jar builds fine
and fails at runtime with UnsatisfiedLinkError, which is the worst way to learn
it. R8 keep rules for JNA and the bindings are in for the same reason — that
failure would otherwise appear only in a minified release.
The UI is a working board, not a debug screen: capture field, note list, empty
state, error banner, and an honest failure screen for a store that won't open.
Rules 23/24 — a surface ships at quality from the first commit. Capture uses the
IME action key because the north star is a thought captured in under a second,
and leaves the title empty so the core derives it from the first body line.
Every core call runs on Dispatchers.IO: they are blocking FFI into synchronous
SQLite, and running them on the main thread is exactly the jank going native was
meant to avoid.
The launcher icon reuses frontend/public/icon-maskable-512.png as an adaptive
foreground on the brand #F5C518 — the same asset and colour the web app already
ships, so the three surfaces wear one face.
No signing config. A release keystore that has passed through an agent session
or shell history is compromised by construction (task 2136); it has to be
generated by the operator and reach CI only as a secret. CI builds debug.
CI can only prove this BUILDS — a Linux runner cannot execute an APK, so feel
and on-device correctness remain an operator pass on an emulator.
Scribe #2739.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
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.
|
||||
|
||||
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 (debug 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
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: android
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- 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-
|
||||
|
||||
- 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
|
||||
|
||||
- name: ktlint
|
||||
run: ./gradlew ktlintCheck
|
||||
|
||||
- name: detekt
|
||||
run: ./gradlew detekt
|
||||
|
||||
- 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.
|
||||
run: ./gradlew testDebugUnitTest
|
||||
|
||||
- name: Assemble debug APK
|
||||
run: ./gradlew assembleDebug
|
||||
|
||||
- name: Upload debug APK
|
||||
# Mirrored action, never actions/upload-artifact. @v4+ throws
|
||||
# GHESNotSupportedError client-side on this hostname, and @v3 is worse —
|
||||
# it reports success while Gitea serves artifacts back only through the
|
||||
# v4 API, so the upload is stored and invisible. Pinned by SHA because
|
||||
# the mirror auto-syncs; full URL because DEFAULT_ACTIONS_URL sends bare
|
||||
# owner/repo to github.com. See Scribe issues 2255 / 2270.
|
||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||
with:
|
||||
name: thoughtsync-android-debug-${{ github.sha }}
|
||||
path: android/app/build/outputs/apk/debug/app-debug.apk
|
||||
if-no-files-found: error
|
||||
Reference in New Issue
Block a user