From cb3ff714d9ad6de7ddde42eb360b5e95c6fa599d Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Thu, 2 Jul 2026 21:54:15 +0200 Subject: [PATCH] feat(android): sign release builds with the persistent keystore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_android now requires the keystore generated by the previous commit, prompts for its passwords at build time, and signs the APK with it instead of an ephemeral debug key auto-generated per container run — that was the actual cause of every release needing a manual uninstall to update. Also derives versionCode from instead of leaving it fixed at 1, so version ordering stays monotonic across releases. Docs (docker/README.md, CLAUDE.md) updated to match the new signing flow. --- CLAUDE.md | 2 +- docker/README.md | 34 +++++++++++++++++++++++++--------- docker/build.sh | 38 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 62 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 15aeb29..07b6ac9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ by `MainWindow` on desktop and as the single-view root on Android. - Tests (headless, the primary verification layer): `dotnet test` — single: `dotnet test --filter "FullyQualifiedName~TestName"`; property-based tests (CsCheck, `PropertyTests.cs`) run in the same command and take ~30 s - GUI hot reload: `dotnet watch --project src/App.Desktop` (on WSL2/WSLg the window shows on the Windows desktop, no graphics dependencies to install) - CLI: `dotnet run --project src/Cli -- ` (no args → usage) -- **Release binaries (all 3 targets): `./docker/build.sh [windows|linux|android|all]`** — reproducible builds in Docker (toolchain pinned in `docker/Dockerfile.*`, no SDK needed on host), artifacts in `dist/`, version taken from the App csproj. See `docker/README.md`. Gotchas already encoded there: single-file desktop publishes need `-p:IncludeNativeLibrariesForSelfExtract=true` (without it Avalonia's native libs — Skia/HarfBuzz/ANGLE — stay outside the exe, which then silently fails to start); the android workload dictates the SDK API level (error XA5207 → bump `ANDROID_SDK_PLATFORM` in `Dockerfile.android`). +- **Release binaries (all 3 targets): `./docker/build.sh [windows|linux|android|all]`** — reproducible builds in Docker (toolchain pinned in `docker/Dockerfile.*`, no SDK needed on host), artifacts in `dist/`, version taken from the App csproj. See `docker/README.md`. Gotchas already encoded there: single-file desktop publishes need `-p:IncludeNativeLibrariesForSelfExtract=true` (without it Avalonia's native libs — Skia/HarfBuzz/ANGLE — stay outside the exe, which then silently fails to start); the android workload dictates the SDK API level (error XA5207 → bump `ANDROID_SDK_PLATFORM` in `Dockerfile.android`). Android release builds need a persistent signing keystore, generated once with `docker/keystore/generate-keystore.sh` (never committed — see `docker/keystore/README.md`): without it every build gets a different random signature and users must uninstall the old app to receive an update instead of updating in place. - Manual Windows publish: `dotnet publish src/App.Desktop -r win-x64 -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true --self-contained` - Manual Linux publish: same with `-r linux-x64` (single-file binary; AppImage via PupNet Deploy is a future step, no pupnet.conf yet) diff --git a/docker/README.md b/docker/README.md index 4190f92..42ee549 100644 --- a/docker/README.md +++ b/docker/README.md @@ -46,6 +46,16 @@ automatically on first use — a few minutes for desktop, 10–20 minutes for Android (large downloads). Subsequent builds reuse the cached images and take well under a minute (desktop) / a few minutes (Android). +**Android release signing:** before the first `android` build, generate the +persistent signing keystore once — see [`keystore/README.md`](keystore/README.md): + +```bash +./docker/keystore/generate-keystore.sh +``` + +Without it, `build_android` refuses to run — every APK must be signed with +the same key so future releases can update a previous install in place. + --- ## Quick start @@ -64,7 +74,7 @@ Running without arguments shows an interactive menu — pick a single target or Targets: windows Win x64 single-file executable (native libs embedded) linux Linux x64 single-file binary (runs as-is, nothing to install) - android Android APK (debug-signed) + android Android APK (release-signed, prompts for keystore passwords) all All three targets Options: @@ -109,16 +119,16 @@ effectively all of them); no .NET or other packages to install. If you transfer it through a channel that strips permissions (e.g. a web download), restore the execute bit with `chmod +x`. -**Android** — a debug-signed APK for sideloading: transfer it to the phone +**Android** — a release-signed APK for sideloading: transfer it to the phone and open it (enable "install from unknown sources" if prompted), or install via `adb install dist/android/PalladiumWallet-*.apk`. Supports Android 6.0+ (API 23), arm64 phones and x86_64 emulators. -> **Signature caveat:** debug-signed APKs built on different machines carry -> different keys. If a previous build is already installed, Android refuses -> the update (`INSTALL_FAILED_UPDATE_INCOMPATIBLE`) — uninstall the old app -> first. **Uninstalling deletes the app's data: back up the wallet seed -> before doing this.** Release signing with a stable key is not set up yet. +> **Signature:** every APK is signed with the persistent keystore in +> `docker/keystore/` (see [Prerequisites](#prerequisites)), so installing a +> newer build over an existing one updates it in place — no uninstall, no +> data loss. This only holds as long as every release keeps using that same +> keystore file; see `docker/keystore/README.md` for the backup story. --- @@ -166,8 +176,14 @@ docker volume rm plm-nuget-cache android workload moved to a newer API level. Bump `ANDROID_SDK_PLATFORM` (and `ANDROID_SDK_BUILD_TOOLS`) in `Dockerfile.android` to the level the error names, then `--rebuild`. -- **APK won't install over an existing app** — signature mismatch between - debug keys; see the signature caveat above. +- **APK won't install over an existing app + (`INSTALL_FAILED_UPDATE_INCOMPATIBLE`)** — the new build wasn't signed with + the same keystore as the installed one. Make sure `docker/keystore/release.keystore` + hasn't changed since the installed build; if it's genuinely a different key, + the user must uninstall the old app first (this deletes app data — back up + the wallet seed before doing this). +- **`build_android` refuses to run, asks to generate a keystore** — run + `./docker/keystore/generate-keystore.sh` once (see `docker/keystore/README.md`). - **Everything is broken / start from scratch** — `docker system prune -a && docker volume rm plm-nuget-cache`, then rerun the script (images and packages are re-downloaded). diff --git a/docker/build.sh b/docker/build.sh index bd51c0d..bbe2de4 100755 --- a/docker/build.sh +++ b/docker/build.sh @@ -29,7 +29,7 @@ $(bold "Usage:") $(basename "$0") [TARGET] [OPTIONS] $(bold "Targets:") windows Win x64 single-file executable → dist/windows/ linux Linux x64 single-file binary → dist/linux/ - android Android APK (debug-signed) → dist/android/ + android Android APK (release-signed) → dist/android/ all All three targets $(bold "Options:") @@ -112,6 +112,10 @@ ensure_android_image() { # $1 = image name, $2 = inline bash commands to execute inside the container. # Source is copied to /tmp/build inside the container so bin/obj never pollute # the repo. Output is written to /output (→ host DIST_DIR sub-folder). +# Optional extra `docker run` args (e.g. keystore mount, signing passwords via +# -e) can be set by the caller in the EXTRA_DOCKER_ARGS array beforehand. +EXTRA_DOCKER_ARGS=() + run_build() { local image="$1" local commands="$2" @@ -123,6 +127,7 @@ run_build() { --volume "${PROJECT_ROOT}:/src:ro" \ --volume "${out_dir}:/output" \ --volume "${NUGET_VOLUME}:/root/.nuget/packages" \ + "${EXTRA_DOCKER_ARGS[@]}" \ "$image" \ bash -euo pipefail -c " cp -r /src/. /tmp/build @@ -169,16 +174,45 @@ build_linux() { build_android() { ensure_android_image + + local keystore="${SCRIPT_DIR}/keystore/release.keystore" + [[ -f "$keystore" ]] || die "No release keystore found at docker/keystore/release.keystore. Run ./docker/keystore/generate-keystore.sh once first (see docker/keystore/README.md)." + info "Building Android APK …" + read -r -s -p "Keystore password: " ANDROID_KS_PASS + echo + read -r -s -p "Key password (press Enter to reuse the keystore password): " ANDROID_KEY_PASS + echo + ANDROID_KEY_PASS="${ANDROID_KEY_PASS:-$ANDROID_KS_PASS}" + + # versionCode derived from (MAJOR.MINOR.PATCH, pre-release suffix + # stripped) so it always increases with releases — required for some + # installers to accept an in-place update even when the signature matches. + local semver="${VERSION%%-*}" + IFS='.' read -r VMAJOR VMINOR VPATCH <<< "$semver" + local VERSION_CODE=$(( ${VMAJOR:-0} * 10000 + ${VMINOR:-0} * 100 + ${VPATCH:-0} )) + info "versionCode: ${VERSION_CODE}" + + EXTRA_DOCKER_ARGS=( + --volume "${keystore}:/keystore/release.keystore:ro" + --env "ANDROID_KS_PASS=${ANDROID_KS_PASS}" + --env "ANDROID_KEY_PASS=${ANDROID_KEY_PASS}" + ) run_build "$IMAGE_ANDROID" \ "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 \ dotnet build src/App.Android \ -c Release \ -t:SignAndroidPackage \ - -p:AndroidSdkDirectory=\${ANDROID_HOME} + -p:AndroidSdkDirectory=\${ANDROID_HOME} \ + -p:ApplicationVersion=${VERSION_CODE} \ + -p:AndroidSigningKeyStore=/keystore/release.keystore \ + -p:AndroidSigningKeyAlias=palladiumwallet \ + -p:AndroidSigningStorePass=\${ANDROID_KS_PASS} \ + -p:AndroidSigningKeyPass=\${ANDROID_KEY_PASS} cp src/App.Android/bin/Release/net10.0-android/*-Signed.apk \ \"/output/PalladiumWallet-${VERSION}.apk\"" \ "${DIST_DIR}/android" + EXTRA_DOCKER_ARGS=() ok "Android → dist/android/PalladiumWallet-${VERSION}.apk" }