M12 — the Android client, end to end #2
@@ -10,6 +10,12 @@ name: Android
|
|||||||
# CI can only prove this BUILDS. A Linux runner cannot execute an APK, so anything
|
# 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
|
# about feel, touch or on-device correctness is an operator pass on an emulator or
|
||||||
# phone.
|
# 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:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -37,7 +43,7 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Kotlin + Rust (debug APK)
|
name: Kotlin + Rust (APK)
|
||||||
# runs-on is only a scheduling label (Label Model B). flutter-ci is the
|
# runs-on is only a scheduling label (Label Model B). flutter-ci is the
|
||||||
# proven-working label that can pull our container images.
|
# proven-working label that can pull our container images.
|
||||||
runs-on: flutter-ci
|
runs-on: flutter-ci
|
||||||
@@ -65,6 +71,38 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
android-
|
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
|
- name: Make gradlew executable
|
||||||
run: chmod +x ./gradlew
|
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
|
# 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.
|
# Rust failure instead of arriving inside a Gradle stack trace.
|
||||||
- name: Build the native library and bindings
|
- 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
|
# 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
|
# (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
|
# 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
|
# an emulator, not here — the Rust side is covered by the workspace
|
||||||
# tests in the desktop lane.
|
# 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
|
- name: Assemble the APK
|
||||||
run: ./gradlew assembleDebug
|
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
|
# Mirrored action, never actions/upload-artifact. @v4+ throws
|
||||||
# GHESNotSupportedError client-side on this hostname, and @v3 is worse —
|
# GHESNotSupportedError client-side on this hostname, and @v3 is worse —
|
||||||
# it reports success while Gitea serves artifacts back only through the
|
# 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.
|
# owner/repo to github.com. See Scribe issues 2255 / 2270.
|
||||||
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||||
with:
|
with:
|
||||||
name: thoughtsync-android-debug-${{ github.sha }}
|
name: thoughtsync-android-${{ steps.build.outputs.profile }}-${{ github.sha }}
|
||||||
path: android/app/build/outputs/apk/debug/app-debug.apk
|
path: ${{ steps.build.outputs.apk }}
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|||||||
@@ -196,3 +196,12 @@ android/local.properties
|
|||||||
# the working tree is a convenience, never a source. Ignored because they are
|
# 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.
|
# ~57 MB and `git add -A` would otherwise put one in history forever.
|
||||||
*.apk
|
*.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
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
@@ -120,15 +121,33 @@ val rustInputs =
|
|||||||
workspaceRoot.file("Cargo.lock"),
|
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 jniLibsOut = layout.buildDirectory.dir("rustJniLibs")
|
||||||
val bindingsOut = layout.buildDirectory.dir("generated/uniffi")
|
val bindingsOut = layout.buildDirectory.dir("generated/uniffi")
|
||||||
|
|
||||||
val cargoNdkDebug =
|
val cargoNdk =
|
||||||
tasks.register<CargoNdkBuild>("cargoNdkDebug") {
|
tasks.register<CargoNdkBuild>("cargoNdk") {
|
||||||
description = "Cross-compile thoughtsync-ffi for the Android ABIs (debug)."
|
description = "Cross-compile thoughtsync-ffi for the Android ABIs."
|
||||||
rustSources.from(rustInputs)
|
rustSources.from(rustInputs)
|
||||||
abis.set(androidAbis)
|
abis.set(androidAbis)
|
||||||
cargoProfile.set("debug")
|
cargoProfile.set(rustProfile)
|
||||||
workspaceDir.set(workspaceRoot)
|
workspaceDir.set(workspaceRoot)
|
||||||
jniLibsDir.set(jniLibsOut)
|
jniLibsDir.set(jniLibsOut)
|
||||||
}
|
}
|
||||||
@@ -136,7 +155,7 @@ val cargoNdkDebug =
|
|||||||
val generateBindings =
|
val generateBindings =
|
||||||
tasks.register<UniffiBindgen>("generateUniffiBindings") {
|
tasks.register<UniffiBindgen>("generateUniffiBindings") {
|
||||||
description = "Generate the Kotlin bindings from the compiled .so."
|
description = "Generate the Kotlin bindings from the compiled .so."
|
||||||
dependsOn(cargoNdkDebug)
|
dependsOn(cargoNdk)
|
||||||
// arm64 is arbitrary — every ABI carries the same uniffi metadata, and
|
// arm64 is arbitrary — every ABI carries the same uniffi metadata, and
|
||||||
// reading one is cheaper than reading four.
|
// reading one is cheaper than reading four.
|
||||||
libraryFile.set(jniLibsOut.map { it.file("arm64-v8a/libthoughtsync_ffi.so") })
|
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 {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
// Signing is deliberately absent. A release keystore that has passed
|
// Null when no keystore reached this build, which leaves the APK
|
||||||
// through an agent session or shell history is compromised by
|
// unsigned and therefore uninstallable. `android.yml` builds debug in
|
||||||
// construction (Scribe task 2136) — it has to be generated by the
|
// that case rather than producing an artifact nobody can put on a
|
||||||
// operator and reach CI only as a secret. Until then a release build
|
// phone.
|
||||||
// is unsigned and CI builds debug.
|
signingConfig = signingConfigs.findByName("release")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +259,7 @@ android {
|
|||||||
androidComponents {
|
androidComponents {
|
||||||
onVariants { variant ->
|
onVariants { variant ->
|
||||||
variant.sources.kotlin?.addGeneratedSourceDirectory(generateBindings, UniffiBindgen::outputDir)
|
variant.sources.kotlin?.addGeneratedSourceDirectory(generateBindings, UniffiBindgen::outputDir)
|
||||||
variant.sources.jniLibs?.addGeneratedSourceDirectory(cargoNdkDebug, CargoNdkBuild::jniLibsDir)
|
variant.sources.jniLibs?.addGeneratedSourceDirectory(cargoNdk, CargoNdkBuild::jniLibsDir)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user