diff --git a/.forgejo/workflows/android.yml b/.forgejo/workflows/android.yml index 0e8e0b9..f748b72 100644 --- a/.forgejo/workflows/android.yml +++ b/.forgejo/workflows/android.yml @@ -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 diff --git a/.gitignore b/.gitignore index 9ac56d9..6f5f659 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 97e57d3..61f3a38 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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("cargoNdkDebug") { - description = "Cross-compile thoughtsync-ffi for the Android ABIs (debug)." +val cargoNdk = + tasks.register("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("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) } }