Skip to main content

Local Android Debugging (Emulator + Local Gradle Build)

A faster loop than the CI/Play Store cycle for reproducing and verifying native-level Android bugs (crashes, navigation issues, anything that depends on the actual compiled app, not just JS logic): build a real release-mode artifact locally, install it on a local emulator, and read adb logcat directly. Full CI/Play Store dispatch is still the ground truth for a release, but these loops keep most iteration offline from the release pipeline.

When to use this vs. the CI pipeline

Use the fast APK loop below to diagnose and verify a fix before it ever reaches CI. Still dispatch the real Android release workflow (see RELEASE_WORKFLOW.md) before considering anything actually shipped — this loop doesn't touch signing, Play Console, or the EC2 runner, and a local debug-signed build is not the same artifact users receive.

If the problem only appears in the published Play build, do not trust assembleRelease as confirmation. Use the Play-parity loop below: it builds the same release-signed Android App Bundle shape used by the release workflow, with the same small CI-shaped mobile .env, and optionally installs split APKs through bundletool. Local no-submit builds use Gradle directly because the Fastlane build lane is only a wrapper around bundleRelease; explicit Play uploads still use Fastlane.

Published-app parity loop

Use this when a Play internal/alpha install behaves differently from the local APK loop:

# Internal/alpha shape used by the current release workflow.
npm run android:play-parity:dev

# Optional: after a successful AAB build, install Play-like split APKs.
npm run android:play-parity:dev -- --install

This helper deliberately fails early unless local inputs are close enough to the Play upload path:

  • Auth-critical public env values must be present: EXPO_PUBLIC_SUPABASE_PROFILE, EXPO_PUBLIC_SUPABASE_URL, EXPO_PUBLIC_SUPABASE_ANON_KEY, EXPO_PUBLIC_SITE_URL, EXPO_PUBLIC_NATIVE_GOOGLE_SIGNIN, and EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID.
  • Android release signing must be available in config/android-signing.env or exported as ANDROID_KEYSTORE_PATH, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, and ANDROID_KEY_PASSWORD.
  • --install additionally needs bundletool on PATH or BUNDLETOOL_JAR=/path/to/bundletool.jar.
  • To complete native Google Sign-In from a local split install, Google Cloud must also have an Android OAuth client for package com.hashpass.tech with the Upload key SHA-1. Play-delivered builds use Google's App signing key SHA-1, but local bundletool install-apks builds are signed with the Upload key. If this is missing, logcat shows This android application is not registered to use OAuth2.0 and the app returns to the auth screen without reaching the dashboard.

The helper defaults to development/internal, matching the current tag-triggered Play workflow. It builds a release-signed AAB with bundleRelease; it does not upload to Play unless you explicitly pass --submit, which switches back to the real Fastlane upload lane. It also sets EXPO_NO_DOTENV=1, so local .env.local, .env.production, and .env files cannot make the bundle differ from the small workflow-shaped public env.

Use the APK loop below only after acknowledging its differences: APK instead of AAB, debug/local signing instead of the Play upload key, local env-file loading instead of the workflow's minimal mobile .env, and no Play split delivery.

One-time environment setup

export ANDROID_HOME=/usr/lib/android-sdk
export ANDROID_SDK_ROOT=/usr/lib/android-sdk
export JAVA_HOME=/home/ed/.sdkman/candidates/java/17.0.19-tem # AGP requires Java 17, not 11
export PATH="$JAVA_HOME/bin:$PATH"

Confirm an emulator is running: adb devices -l. If none is running, start one from Android Studio's AVD manager or emulator -avd <name>.

Fast APK loop: release mode, not debug

pnpm --dir apps/mobile-app exec expo prebuild --platform android --clean
cd apps/mobile-app/android
./gradlew assembleRelease -q

Use assembleRelease, not assembleDebug, for anything you're trying to reproduce faithfully. Debug builds connect to a Metro dev server and can hit dev-mode-only code paths that don't exist in the real, minified, Hermes-bundled release build users actually get — a debug-mode crash or non-crash doesn't tell you what a release build will do. This app's android/app/build.gradle signs release with the debug keystore locally (signingConfig signingConfigs.debug), so assembleRelease installs cleanly without needing production signing credentials.

Run expo prebuild --clean before the local Gradle build when validating the latest native release. The generated android/ project is gitignored, so it can keep an older versionName or versionCode from a previous release. A direct Gradle build then succeeds but installs the stale native project, which makes emulator debugging look like it is testing the latest deployment when it is not.

Before installing, confirm the local APK metadata matches the deployment you mean to test:

cat apps/mobile-app/android/app/build/outputs/apk/release/output-metadata.json

The APK lands at apps/mobile-app/android/app/build/outputs/apk/release/app-release.apk. First builds fetch fresh Maven artifacts and can take several minutes; subsequent builds are faster once Gradle's cache is warm.

Installing

adb -s <device> install -r app-release.apk

If the previously-installed build was signed differently (e.g. a real Play Store / CI build vs. this locally debug-signed one), install -r fails with INSTALL_FAILED_UPDATE_INCOMPATIBLE. You must adb uninstall com.hashpass.tech first — this wipes the app's local storage, including any persisted session, so plan to either re-login or use the dev auth bypass (below) afterward.

Reproducing and reading crashes

adb -s <device> logcat -c # clear old logs first
adb -s <device> shell am force-stop com.hashpass.tech
adb -s <device> shell monkey -p com.hashpass.tech -c android.intent.category.LAUNCHER 1
# ...reproduce the issue...
adb -s <device> logcat -d 2>&1 | grep -iE "FATAL EXCEPTION|ReactNativeJS"

For a fatal native crash, look for FATAL EXCEPTION in an AndroidRuntime line — that's the real stack trace, not the JS-side console.error that usually precedes it by a few hundred milliseconds. See native-module-version-pinning.md for the specific "Unsupported top level event type" crash signature.

Timing matters: if a crash happens some seconds after a screen mounts (not immediately), don't assume the first suspicious log line near the crash timestamp is the cause — pull a wider unfiltered window (logcat -v time, filtered by timestamp range rather than tag) and check what's actually still executing right before the crash, since async JS/native interleaving can put unrelated log lines right next to the real trigger.

Deep-linking to a specific screen

adb -s <device> shell am start -W -a android.intent.action.VIEW -d "hashpass://dashboard/explore" com.hashpass.tech

Cold-start gotcha: if the app isn't already running, the very first deep-link intent after a fresh launch/install is often consumed as a plain launch (opening to the default route) instead of actually routing — Expo Router's linking isn't ready yet when the intent arrives. Launch the app plainly first (monkey -c android.intent.category.LAUNCHER), wait a few seconds, then send the deep link as a second, separate command once the app is confirmed running (adb shell am start ... against an already-running app logs "Warning: Activity not started, intent has been delivered to currently running top-most instance" — that's the success case here, not an error).

Validating login paths locally

Local assembleRelease builds are signed with the debug keystore, so the native Google sign-in client can fail before the app receives an ID token when the OAuth client certificate does not match that local signing cert. Use OTP to validate the post-login dashboard path in that case. If the app reaches the logged-in dashboard and stays alive, treat non-fatal provider bootstrap logs such as Better Auth getSession error: TypeError: Network request failed as a separate follow-up instead of conflating them with the native dashboard crash signature.

Reaching screens behind the login gate, without a real session

Real OTP/magic-link login requires either a deliverable email/SMS or direct database access to read the generated code — both are more friction than most local debugging needs. lib/auth/dev-bypass.ts provides a dev-only bypass, gated so it can never affect a real user:

# In apps/mobile-app/.env.local (gitignored, never committed):
EXPO_PUBLIC_DEV_AUTH_BYPASS=true

isDevAuthBypassEnabled() requires both __DEV__ (false in every release build, regardless of this env var) and the explicit opt-in above. This means:

  • It has zero effect in a real assembleRelease build, by design — a release build always has __DEV__ === false, so this flag cannot leak into a shipped build no matter what's in .env.local.
  • To actually exercise the bypass, you need a debug build connected to Metro (assembleDebug + adb reverse tcp:8081 tcp:8081 + npx expo start), not a release build. This is the one case where the debug-vs-release distinction above flips: verifying the auth-guard logic itself needs __DEV__ === true; verifying anything else (crashes, rendering, navigation) needs a release build.
  • With the bypass active, user stays null — anything that depends on a real user object (profile data, admin checks, personalized content) will render its empty/loading state, not crash. If you need a screen populated with realistic data, request a data-mocking pass instead of assuming the bypass alone is sufficient.
  • Reach a gated screen with the bypass on via deep link (see above) — the guard is what would otherwise redirect you to /auth.

Testing the web build for a runtime-only bug

A successful npm run build:web does not mean the bundle runs correctly — build success just means the code parsed and bundled; some bugs (e.g. circular-import bindings resolving to undefined) only manifest when the minified JS actually executes in a browser. Build, serve, and load it for real:

npm run build:web
npx serve -l <port> dist/client # or: cd dist/client && npx serve -l <port>

Then load http://localhost:<port>/ in a real browser context (PinchTab, agent-browser, or a normal browser) and check for a startup error, not just that the server returned 200. See native-module-version-pinning.md's third incident for a concrete example of a bug that only a loaded-page check (not a build check) would catch.

Patching a dependency instead of changing its version

If a version pin required for one platform breaks another, don't resolve it by picking a version — see the "Open risk" and version-pinning guidance in native-module-version-pinning.md. Use pnpm patch <package>@<version>, edit the files under the printed temp directory, pnpm patch-commit <path> to persist it (writes patches/<package>@<version>.patch and registers it in pnpm.patchedDependencies in package.json). Check the target package's own package.json main/module/react-native fields before patching, to scope the patch to only the platform(s) actually affected — patching files a given platform never reads is free extra safety margin, not wasted effort.