M12 — the Android client, end to end #2

Merged
bvandeusen merged 86 commits from dev into main 2026-08-21 08:53:58 -04:00
3 changed files with 132 additions and 19 deletions
Showing only changes of commit d0a9c73bf9 - Show all commits
+66 -8
View File
@@ -10,6 +10,12 @@ name: Android
# 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:
@@ -37,7 +43,7 @@ env:
jobs:
build:
name: Kotlin + Rust (debug APK)
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
@@ -65,6 +71,38 @@ jobs:
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: |
version="$(sh ../desktop/packaging/build-version.sh)"
echo "name=$version" >> $GITHUB_OUTPUT
# versionCode must RISE for Android to accept an update, and the run
# number is the same monotonic counter the desktop's version scheme
# already uses — no state carried between runs, and immune to the
# shallow checkout that makes a commit count useless here.
echo "code=$GITHUB_RUN_NUMBER" >> $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 "profile=release" >> $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 $GITHUB_RUN_NUMBER)"
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 "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
@@ -77,7 +115,7 @@ jobs:
# 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
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
@@ -97,12 +135,32 @@ jobs:
# 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
run: ./gradlew test${{ steps.build.outputs.variant }}UnitTest -PTHOUGHTSYNC_CARGO_PROFILE=${{ steps.build.outputs.profile }}
- name: Assemble debug APK
run: ./gradlew assembleDebug
- 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 }}
- name: Upload debug APK
# 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"
- name: Upload the 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
@@ -111,6 +169,6 @@ jobs:
# 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
name: thoughtsync-android-${{ steps.build.outputs.profile }}-${{ github.sha }}
path: ${{ steps.build.outputs.apk }}
if-no-files-found: error
+9
View File
@@ -196,3 +196,12 @@ android/local.properties
# the working tree is a convenience, never a source. Ignored because they are
# ~57 MB and `git add -A` would otherwise put one in history forever.
*.apk
# Signing material. NEVER committed — an Android signing key cannot be rotated
# without the original (v3 lineage needs it), so a leaked or lost one means every
# install has to be removed and replaced by hand. Listed before any keystore
# exists so that generating one in this directory cannot go wrong.
*.jks
*.keystore
*.p12
*.b64
+57 -11
View File
@@ -1,3 +1,4 @@
import java.io.File
import javax.inject.Inject
plugins {
@@ -120,15 +121,33 @@ val rustInputs =
workspaceRoot.file("Cargo.lock"),
)
/**
* Which Cargo profile the `.so` is built with.
*
* A property rather than a debug/release task PAIR, deliberately. This runner has
* no working Gradle or Cargo cache (`reserveCache failed` on every run), so a cold
* cross-compile of four ABIs costs about four minutes — and a lane that both
* type-checks and packages would pay that twice if the two used different
* profiles. `android.yml` picks one profile and uses it for every Gradle call in
* the run.
*
* Defaults to debug so a local build stays fast; CI passes release, because an
* unoptimised store and sync engine is a real difference on a phone, not a
* theoretical one.
*/
val rustProfile =
(project.findProperty("THOUGHTSYNC_CARGO_PROFILE") as String?)?.takeIf { it.isNotBlank() }
?: "debug"
val jniLibsOut = layout.buildDirectory.dir("rustJniLibs")
val bindingsOut = layout.buildDirectory.dir("generated/uniffi")
val cargoNdkDebug =
tasks.register<CargoNdkBuild>("cargoNdkDebug") {
description = "Cross-compile thoughtsync-ffi for the Android ABIs (debug)."
val cargoNdk =
tasks.register<CargoNdkBuild>("cargoNdk") {
description = "Cross-compile thoughtsync-ffi for the Android ABIs."
rustSources.from(rustInputs)
abis.set(androidAbis)
cargoProfile.set("debug")
cargoProfile.set(rustProfile)
workspaceDir.set(workspaceRoot)
jniLibsDir.set(jniLibsOut)
}
@@ -136,7 +155,7 @@ val cargoNdkDebug =
val generateBindings =
tasks.register<UniffiBindgen>("generateUniffiBindings") {
description = "Generate the Kotlin bindings from the compiled .so."
dependsOn(cargoNdkDebug)
dependsOn(cargoNdk)
// arm64 is arbitrary — every ABI carries the same uniffi metadata, and
// reading one is cheaper than reading four.
libraryFile.set(jniLibsOut.map { it.file("arm64-v8a/libthoughtsync_ffi.so") })
@@ -174,15 +193,42 @@ android {
}
}
// The signing key reaches this build only through the environment: CI decodes
// it from a secret into a file and points ANDROID_KEYSTORE_FILE at that path.
// It is never in the repo and never in this file. Generated by the operator
// and never seen by an agent session, because an Android signing key cannot be
// rotated without the original — v3 lineage needs it — so a leaked or lost one
// means every install has to be removed and replaced by hand.
val keystoreFile = System.getenv("ANDROID_KEYSTORE_FILE")?.takeIf { it.isNotBlank() }
val keystorePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")?.takeIf { it.isNotBlank() }
signingConfigs {
if (keystoreFile != null && keystorePassword != null) {
create("release") {
storeFile = File(keystoreFile)
storePassword = keystorePassword
// Hardcoded, and NOT a secret: the alias is fixed for the life of
// this app and is written into the certificate every install
// already carries. Hiding it would buy nothing and stop this file
// describing its own signing setup.
keyAlias = "thoughtsync"
// PKCS12 cannot hold a key password distinct from the store
// password — keytool refuses to set one — so this is the same
// value by necessity rather than by shortcut.
keyPassword = keystorePassword
}
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
// Signing is deliberately absent. A release keystore that has passed
// through an agent session or shell history is compromised by
// construction (Scribe task 2136) — it has to be generated by the
// operator and reach CI only as a secret. Until then a release build
// is unsigned and CI builds debug.
// Null when no keystore reached this build, which leaves the APK
// unsigned and therefore uninstallable. `android.yml` builds debug in
// that case rather than producing an artifact nobody can put on a
// phone.
signingConfig = signingConfigs.findByName("release")
}
}
@@ -213,7 +259,7 @@ android {
androidComponents {
onVariants { variant ->
variant.sources.kotlin?.addGeneratedSourceDirectory(generateBindings, UniffiBindgen::outputDir)
variant.sources.jniLibs?.addGeneratedSourceDirectory(cargoNdkDebug, CargoNdkBuild::jniLibsDir)
variant.sources.jniLibs?.addGeneratedSourceDirectory(cargoNdk, CargoNdkBuild::jniLibsDir)
}
}