http:// to services on your own machines. It is signed with the Android debug keystore and must never be distributed.This runbook gets PlatePilot onto a real Android phone while the API, worker, Postgres and LocalStack keep running on the dev desktop. Nothing is exposed to the internet: the phone reaches the desktop through your laptop, over USB (recommended) or your home Wi-Fi.
Why not a public tunnel: the corporate network sinkholes the popular tunnel services (Cloudflare quick tunnels, ngrok, localtunnel, localhost.run, bore, serveo, pinggy all resolve to an internal blackhole). That is a deliberate control, so this runbook does not work around it.
On the dev desktop, from the repository root:
scripts/mobile/build-android-apk.sh # USB variant, API at http://localhost:3001
scripts/mobile/build-android-apk.sh http://192.168.1.20:3001 wifi # Wi-Fi variant, laptop LAN address
The build runs inside reactnativecommunity/react-native-android (JDK 17, Android SDK 36, NDK 27, Node 22), so nothing needs installing on the desktop. The first run takes a few minutes; later runs reuse the Gradle cache in .cache/gradle. Output lands in dist/android/platepilot-<variant>-<stamp>.apk with a -latest.apk symlink; both dist/ and .cache/ are gitignored.
What is baked in:
EXPO_PUBLIC_API_BASE_URL — the API address the app uses. It is compiled into the JavaScript bundle, so changing it means rebuilding (a few minutes with a warm cache).PLATEPILOT_LOCAL_DEVICE_BUILD=1 — makes apps/mobile/app.config.ts add android:usesCleartextTraffic="true" to the release manifest. Without it Android refuses plain http:// in a release build. Store builds never set it.EXTRA_ARCHS=armeabi-v7a for an old 32-bit phone.# on the laptop
scp <desktop>:~/nutrition-coach/nutrition-coach/dist/android/platepilot-usb-latest.apk .
adb install -r platepilot-usb-latest.apk
adb comes with Android platform-tools (brew install android-platform-tools on macOS). On the phone enable Developer options → USB debugging and accept the laptop’s fingerprint. Without adb you can also copy the file over and open it from the Files app; Android will ask to allow installs from that app.
Keep your existing SSH forward from the laptop to the desktop, adding LocalStack so photo uploads work:
ssh -L 3001:localhost:3001 -L 4566:localhost:4566 <desktop>
Then map the phone’s own localhost ports to the laptop:
adb reverse tcp:3001 tcp:3001
adb reverse tcp:4566 tcp:4566
Open PlatePilot. The USB variant already points at http://localhost:3001, and the presigned photo-upload URLs the API hands out point at http://localhost:4566, which the phone now resolves through the same chain. No API configuration change is needed. adb reverse does not survive a reboot or re-plug; run the two commands again if the app reports the server as unreachable.
ipconfig getifaddr en0 on macOS, hostname -I on Linux), for example 192.168.1.20.Bind the SSH forwards on all laptop interfaces:
ssh -L 0.0.0.0:3001:localhost:3001 -L 0.0.0.0:4566:localhost:4566 <desktop>
The laptop firewall must allow inbound 3001 and 4566 from the local network.
Tell the API to write the laptop address into presigned upload URLs, then restart the API container. In the repository .env on the desktop:
PHOTO_UPLOAD_PUBLIC_ENDPOINT_URL=http://192.168.1.20:4566
The API keeps talking to LocalStack on localhost:4566 itself; only the URL given to the phone changes. The variable is refused in production or without a local AWS_ENDPOINT_URL.
scripts/mobile/build-android-apk.sh http://192.168.1.20:3001 wifi.Home routers hand out new addresses now and then; if the laptop’s address changes, update .env and rebuild.
local.synthetic-token) automatically; there is no sign-in.-fail demonstrates the failure path, see the M1 demo runbook).| Symptom | Cause and fix |
|---|---|
| App shows “server unreachable” on every screen | USB: re-run both adb reverse commands and check the SSH session is alive. Wi-Fi: confirm curl http://<laptop-ip>:3001/health works from the phone’s browser. |
| Photo upload fails but everything else works | Port 4566 is not forwarded (USB: missing adb reverse tcp:4566; Wi-Fi: missing PHOTO_UPLOAD_PUBLIC_ENDPOINT_URL or the API was not restarted after setting it). |
INSTALL_FAILED_UPDATE_INCOMPATIBLE |
A build signed with a different keystore is installed. adb uninstall com.platepilot.mobile and install again. |
Build fails with Could not resolve or Connection refused |
Docker could not reach Maven Central or the npm registry. Retry; the Gradle cache keeps what was already downloaded. |
| Build fails with an out-of-memory error | Raise -Xmx8g in scripts/mobile/build-android-apk.sh, or drop EXTRA_ARCHS. |
| Android says the app is from an unknown developer | Expected: debug keystore. Allow the install for this app only. |
EXPO_PUBLIC_API_BASE_URL is baked into the APK at build time, but a development build can override
it on the device: open Me → About and help → Practice switches (local builds) → Server address,
enter the full origin (for example http://localhost:3001 behind adb reverse, or a laptop LAN
address such as http://192.168.1.20:3001), tap Save, then close and reopen PlatePilot. The value is
kept in SecureStore on the device and read once at startup; leave the field empty and save to return
to the built-in address. The section shows in development bundles and in APKs built with EXPO_PUBLIC_LOCAL_DEVICE_BUILD=1 (the build script sets it); store builds never show it.