Files
paseo/docs/android.md
2026-07-17 15:48:06 +02:00

8.7 KiB

Android

App variants

Controlled by APP_VARIANT in packages/app/app.config.js (vanilla Expo, no custom Gradle plugin):

Variant App name Package ID
production Paseo sh.paseo
development Paseo Debug sh.paseo.debug

EAS profiles: development, production, and production-apk in packages/app/eas.json.

development uses Android debug.

Version codes

packages/app/app.config.js derives Android versionCode from the package version with:

major * 1_000_000 + minor * 1_000 + patch

Prerelease metadata is ignored, so 0.1.102-beta.1 and 0.1.102 both produce 1102. The same value is used as the iOS buildNumber because packages/app/eas.json uses EAS's local app version source. Do not re-enable EAS remote version counters or Android autoIncrement; F-Droid and other source-based builders need the native build number to be visible in the repo.

The formula reserves three digits each for minor and patch. If either reaches 1000, change the formula before cutting that release.

Prerequisites (local dev)

Local Android builds run on macOS (or Linux) and need the Android toolchain, pinned in .tool-versions (java 21, android-sdk 21.0) and wired up by .mise.toml (which sets ANDROID_HOME and puts cmdline-tools/21.0/bin, platform-tools, and emulator on PATH). With mise:

mise install        # java 21 + android-sdk 21.0 command-line tools

Pin a real android-sdk version, not latest. The mise android-sdk plugin's latest resolved to the ancient 1.0 bundle, whose sdkmanager (3.6.0) predates the emulator package and fails with Failed to find package emulator. 21.0 ships a current sdkmanager. If you bump it, update the version in .tool-versions and in all four paths in .mise.toml.

mise install only lays down the command-line tools. Install the rest and create an emulator. On Apple Silicon:

sdkmanager --licenses
sdkmanager "platform-tools" "emulator" "platforms;android-35" "build-tools;35.0.0" \
           "system-images;android-35;google_apis;arm64-v8a"
avdmanager create avd -n paseo -k "system-images;android-35;google_apis;arm64-v8a" -d pixel_7
emulator @paseo     # start it; leave running

On an Intel Mac, use the x86_64 system image:

sdkmanager --licenses
sdkmanager "platform-tools" "emulator" "platforms;android-35" "build-tools;35.0.0" \
           "system-images;android-35;google_apis;x86_64"
avdmanager create avd -n paseo -k "system-images;android-35;google_apis;x86_64" -d pixel_7
emulator @paseo     # start it; leave running

Gradle auto-fetches the platform/build-tools it needs once licenses are accepted, so adjust android-35 only if it asks for a different level.

Local build + install

From repo root:

npm run android:development    # Debug build
npm run android:production     # Release build
npm run android:clear          # Remove generated Android project

Or from packages/app:

# Debug
npx cross-env APP_VARIANT=development expo prebuild --platform android --non-interactive
npx cross-env APP_VARIANT=development expo run:android --variant=debug

# Release
npx cross-env APP_VARIANT=production expo prebuild --platform android --non-interactive
npx cross-env APP_VARIANT=production expo run:android --variant=release

# Clear generated Android project
rm -rf android

Running on an emulator against a worktree daemon

npm run android builds and installs the dev client, but two connections have to reach your Mac from inside the emulator — Metro (the JS bundle) and the Paseo daemon — and the emulator does not share the host's loopback: localhost inside the emulator is the emulator itself. Reach the host at 10.0.2.2 (the standard AVD's host alias) for both:

REACT_NATIVE_PACKAGER_HOSTNAME=10.0.2.2 \
  EXPO_PUBLIC_LOCAL_DAEMON=10.0.2.2:$PASEO_SERVICE_DAEMON_PORT \
  npm run android
  • REACT_NATIVE_PACKAGER_HOSTNAME=10.0.2.2 — without it, Expo bakes your Mac's LAN IP into the dev client's Metro URL, which the emulator can't route to, and the app dies with Failed to connect to /<lan-ip>:8081 before any JS loads.
  • EXPO_PUBLIC_LOCAL_DAEMON=10.0.2.2:<port> — the client's daemon endpoint (packages/app/src/runtime/host-runtime.ts); when unset it defaults to localhost:6767, the production daemon. Use $PASEO_SERVICE_DAEMON_PORT for a worktree daemon running as a Paseo service, or 6768 for a standalone npm run dev:server. It is inlined into the JS bundle at Metro bundle time, so set it on the build command and clear the Metro cache (npx expo start -c) if a change doesn't take.

Alternative — adb reverse + localhost (if 10.0.2.2 misbehaves):

adb reverse tcp:8081 tcp:8081
adb reverse tcp:$PASEO_SERVICE_DAEMON_PORT tcp:$PASEO_SERVICE_DAEMON_PORT
REACT_NATIVE_PACKAGER_HOSTNAME=localhost \
  EXPO_PUBLIC_LOCAL_DAEMON=localhost:$PASEO_SERVICE_DAEMON_PORT \
  npm run android

This is the Android counterpart of the iOS local-simulator flow in development.md: on iOS the simulator shares the Mac's loopback so localhost:<port> works directly; on Android you need 10.0.2.2 or adb reverse.

F-Droid / source-only Android builds

F-Droid builds should set PASEO_FDROID_BUILD=1 when running Expo prebuild:

cd packages/app
PASEO_FDROID_BUILD=1 APP_VARIANT=production npx expo prebuild --platform android --clean --non-interactive
cd android
PASEO_FDROID_BUILD=1 ./gradlew assembleRelease --no-daemon --max-workers=1 -Dorg.gradle.parallel=false

The flag must be present for both prebuild and Gradle because Gradle starts Metro for the release bundle. Keep the source build serial and daemon-free as shown above: compiling every Expo module can exhaust memory when Gradle workers run in parallel. The profile enables source-built Expo modules, excludes the proprietary camera, Firebase notification, and Expo development-client native modules, disables EAS updates and Gradle dependency metadata, and substitutes JavaScript stubs for camera and notifications. The resulting app supports direct and pasted-link pairing but not QR scanning or push notifications.

Keep the excluded npm packages installed. Normal builds use them, while the F-Droid profile removes only their Android native modules and config plugins. Paseo always applies expo-gradle-jvmargs with -Xmx4096m and -XX:MaxMetaspaceSize=1024m so local Expo prebuilds have enough Gradle heap whether they use precompiled AARs or source-built Expo modules.

The EAS production-apk profile uses the large Android resource class. Release builds compile the native ABIs and run Hermes bundling in the same Gradle invocation; the default worker can exhaust its remaining memory and kill Hermes with exit code 137 even when Gradle's own heap is correctly sized.

React version lockstep

Keep react and react-dom pinned to the React version embedded by the current react-native release. React Native 0.81.x embeds react-native-renderer 19.1.0, so packages/app must use React 19.1.0. Bumping React to a newer patch can build successfully but crash at JS startup on Android with Incompatible React versions, leaving the app on the native splash screen.

Screenshots

adb exec-out screencap -p > screenshot.png

Cloud build + submit (EAS)

Stable tag pushes like v0.1.0 trigger:

  • The EAS GitHub app on Expo servers (iOS + Android production builds + store submit). There is no workflow file in this repo for it.
  • .github/workflows/android-apk-release.yml on GitHub Actions (APK asset on GitHub Release).

iOS auto-submits to App Store review via a Fastlane lane after EAS uploads to TestFlight. Android auto-submits to the Play Store via EAS-managed credentials.

Beta tags like v0.1.1-beta.1 only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.

android-v* tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores. The GitHub APK workflow supports workflow_dispatch with an existing tag input so you can rebuild without cutting a new tag.

Useful commands

cd packages/app

# Recent builds
npx eas build:list --limit 10 --non-interactive --json | jq '.[] | {platform, status, appVersion, gitCommitHash}'

# Inspect a build (the printed `Logs` URL opens the build's Expo dashboard page,
# which has a Submissions section showing the auto-submit to the Play Store).
npx eas build:view <build-id>

The Play Console (Internal testing → Production tracks) is the final confirmation that the binary reached the store.

See docs/release.md for the full mobile-build babysitting flow.