Skip to content

Mobile Release Build (sideloadable signed APK)

How to produce a release-signed, sideloadable Android APK of the mobile app using a local keystore. This is the free, local, no-cloud path: no Expo cloud build, no Google Play account, no Store submission.

CI already attaches a debug-signed APK to every release

The Mobile APK workflow builds bike-trip-planner.apk (expo prebuild + ./gradlew assembleRelease) and attaches it to each published GitHub Release (or to an existing release tag via a manual workflow_dispatch). It is signed with the Expo template's deterministic debug keystore, so it needs no secret and is fine for sideloading a beta build. Follow the procedure below when you need a build signed with a real release keystore instead.

When to use

  • You need a standalone APK to install on a real device or hand to a tester.
  • The APK must be release-signed (a debug/dev-client build is not enough).
  • You explicitly do not want Store distribution this sprint.

Diagnosis

The mobile app is an Expo CNG project: mobile/android is generated by expo prebuild and is gitignored (see mobile/.gitignore). There is therefore no committed android/app/build.gradle to edit in place — release signing is wired through EAS local credentials (eas.json + credentials.json), which EAS injects into the generated Gradle signingConfig at build time. A raw-Gradle alternative is documented in the appendix for anyone who prefers ./gradlew assembleRelease.

Prerequisites (same as mobile/README.md):

  • Node 20+ and npm, JDK 17
  • Android SDK (adb on PATH), a device with USB debugging or an emulator
  • EXPO_PUBLIC_API_URL set to an HTTPS host reachable from the phone (the value is inlined into the JS bundle at build time; a non-dev build fails closed without it)

Procedure

1. Generate the local keystore (once)

Never commit this file. Keep it and its passwords somewhere safe (a password manager) — losing it means you can never ship an update that upgrades a previously installed APK in place.

cd mobile
keytool -genkeypair -v \
  -keystore release.keystore \
  -alias btp-release \
  -keyalg RSA -keysize 2048 -validity 10000 \
  -storepass '<STORE_PASSWORD>' -keypass '<KEY_PASSWORD>' \
  -dname 'CN=Bike Trip Planner, O=les-tilleuls.coop, C=FR'

This writes mobile/release.keystore, which is gitignored (*.keystore).

2. Declare the keystore to EAS (credentials.json)

Create mobile/credentials.json (gitignored — it holds the passwords):

{
  "android": {
    "keystore": {
      "keystorePath": "release.keystore",
      "keystorePassword": "<STORE_PASSWORD>",
      "keyAlias": "btp-release",
      "keyPassword": "<KEY_PASSWORD>"
    }
  }
}

keystorePath is relative to mobile/. The release profile in eas.json sets credentialsSource: local, so EAS reads this file instead of prompting for or storing credentials in the cloud.

3. Build the signed release APK

cd mobile
EXPO_PUBLIC_API_URL=https://<api-host> \
  npm run build:release
# -> npx eas-cli build --local --platform android --profile release
# -> writes a release-signed APK such as build-<timestamp>.apk in mobile/

The build runs entirely on your machine (--local). No cloud build is submitted.

4. Sideload onto a device

adb install -r build-<timestamp>.apk   # USB debugging enabled, device authorised
# or copy the .apk to the phone and open it (allow "install unknown apps")

Verify the signing certificate if needed:

keytool -list -v -keystore release.keystore -alias btp-release | grep SHA256

Verification and follow-up

  • Store release.keystore and its passwords in the team password manager. The same keystore MUST be reused for every future update of the same package (coop.lestilleuls.biketripplanner); a different key forces an uninstall/reinstall.
  • Bump android.versionCode (and version when user-facing) in mobile/app.json before each new build you distribute — Android refuses to upgrade in place over an equal-or-lower versionCode.

Out of scope (deferred)

The following are intentionally not covered this sprint:

  • Google Play / Store distribution — no Play account, no App Bundle upload, no managed Play signing. The production profile in eas.json (app-bundle) exists for that future path but is not exercised here.
  • App Links for a release-keystore build - the PWA serves /.well-known/assetlinks.json (pwa/src/app/.well-known/assetlinks.json/route.ts) from the ANDROID_APP_PACKAGE and ANDROID_SHA256_CERT_FINGERPRINTS variables of the pwa service. The prod env file (env.j2) sets them from android_app_package / android_sha256_cert_fingerprints in ansible/group_vars/all.yml, which default to the debug keystore fingerprint, so the CI APK verifies its autoVerify https links. An APK signed with a real release keystore does not, until its fingerprint (keytool -list -v -keystore release.keystore | grep SHA256) replaces or is appended (comma-separated) to android_sha256_cert_fingerprints, the playbook is re-run and btp-compose up -d recreates pwa on the VM. No image rebuild is needed: the route is force-dynamic and reads the variables at runtime.

Appendix - raw Gradle path (optional)

If you prefer building through Gradle directly instead of eas build --local, run expo prebuild to generate mobile/android, then wire the same keystore into the generated android/app/build.gradle. Note that expo prebuild --clean regenerates android/ and discards these edits — the EAS path above avoids that.

Create mobile/android/keystore.properties (gitignored via keystore.properties):

storeFile=../../release.keystore
storePassword=<STORE_PASSWORD>
keyAlias=btp-release
keyPassword=<KEY_PASSWORD>

In android/app/build.gradle, load it and add a release signingConfig:

def keystorePropertiesFile = rootProject.file("keystore.properties")
def keystoreProperties = new Properties()
if (keystorePropertiesFile.exists()) {
    keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}

android {
    signingConfigs {
        release {
            if (keystorePropertiesFile.exists()) {
                storeFile file(keystoreProperties['storeFile'])
                storePassword keystoreProperties['storePassword']
                keyAlias keystoreProperties['keyAlias']
                keyPassword keystoreProperties['keyPassword']
            }
        }
    }
    buildTypes {
        release {
            signingConfig signingConfigs.release
        }
    }
}

Then build and sideload:

cd mobile/android
./gradlew assembleRelease
# -> app/build/outputs/apk/release/app-release.apk
adb install -r app/build/outputs/apk/release/app-release.apk

References

  • mobile/README.md — preview builds, deep-link testing, assetlinks note
  • mobile/eas.json — the release (local-signed apk) build profile
  • ADR-053 — npm-workspaces monorepo (core / pwa / mobile)