This is a manual, step-by-step procedure for exercising the Android driver app
against a real local server and a real (or emulated) Android device end to
end. It complements the automated test suites (make test for the server,
./gradlew :app:testDebugUnitTest for the app) — those check units of
behavior in isolation; this checks that the whole system actually works
together: login, permissions, GPS capture, network loss, task removal, and
trip lifecycle, as seen through both the app UI and the GTFS-RT feed.
Run this before cutting an APK for a pilot deployment, and after any change that touches auth, the location-tracking service, permissions, or the trip lifecycle.
All server-side commands below were verified live against the actual server (Postgres + the Go binary in this repo) while writing this doc.
- Docker + Docker Compose (to run Postgres + the server)
curlpsql(ordocker compose exec db psql, used below — no local Postgres client install required)- Android Studio with an emulator image (API 26+; API 35 was used for the reference run), or a physical Android 8.0+ device on the same network as the server
adbon yourPATH
The server hard-fails at startup if JWT_SECRET is unset or shorter than 32
bytes (main.go), and docker-compose.yml does not set one — so make up alone will start a server that refuses to boot. Supply the secret with an
untracked Compose override (this is what was used to verify every command in
this doc; delete the override file when you're done so it never gets
committed):
cat > docker-compose.override.yml <<'EOF'
services:
server:
environment:
JWT_SECRET: "local-dev-only-jwt-secret-please-change-32bytes"
EOF
make upAlternative without Docker for the server process (see
docs/development.md
for the full "Local Server Run" recipe): run Postgres via docker compose up -d db, then export JWT_SECRET=<32+ byte secret> alongside PORT,
DATABASE_URL, and STALENESS_THRESHOLD, and make run.
Confirm the server is up:
curl -s http://localhost:8080/health
# {"status":"ok"}seed_dev.sql seeds both a driver and an admin (admin@test.com /
password). Every admin endpoint requires an admin-role JWT (requireAdmin
in auth.go, wired in main.go), and account creation itself is an
admin-only endpoint, so getting that seed admin in place is the easiest way
to bootstrap:
docker compose exec -T db psql -U postgres -d vehicle_positions < seed_dev.sql(Alternatively, for production/staging deployments rather than local dev, set
ADMIN_BOOTSTRAP_EMAIL / ADMIN_BOOTSTRAP_PASSWORD before the server's first
boot — it creates that admin once, only when the users table has zero
admins. See docs/development.md for the full admin-UI setup, including the
server-rendered UI at /admin itself.)
Log in to confirm and capture the admin token for the next step:
ADMIN_TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@test.com","password":"password"}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')
echo "$ADMIN_TOKEN"Everything from here on uses the admin API (user_handlers.go,
handlers_vehicles.go, assignment_handlers.go), all mounted under
/api/v1/admin/... and requiring the admin bearer token from step 2.
Create the driver (driver@example.com / driverpass123):
curl -s -i -X POST http://localhost:8080/api/v1/admin/users \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name":"Test Driver","email":"driver@example.com","password":"driverpass123","role":"driver"}'
# 201 Created — note the returned "id", you'll need it for the assignment belowCreate the vehicle (bus-1):
curl -s -i -X POST http://localhost:8080/api/v1/admin/vehicles \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"id":"bus-1","label":"Bus 1","agency_tag":"demo-agency"}'
# 200 OKAssign the vehicle to the driver (replace 6 with the id from the
create-driver response above):
curl -s -i -X POST http://localhost:8080/api/v1/admin/assignments \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"user_id":6,"vehicle_id":"bus-1"}'
# 201 CreatedSanity-check as the driver:
DRIVER_TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"driver@example.com","password":"driverpass123"}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')
curl -s http://localhost:8080/api/v1/vehicles -H "Authorization: Bearer $DRIVER_TOKEN"
# [{"id":"bus-1","label":"Bus 1","agency_tag":"demo-agency","active":true,...}]If you'd rather use the driver seeded by seed_dev.sql (driver@test.com /
password) instead of creating a new one, apply it and skip straight to
creating/assigning the vehicle:
docker compose exec -T db psql -U postgres -d vehicle_positions < seed_dev.sqlcd android
./gradlew :app:assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apkThe app talks to the server at whatever URL you enter on the login screen.
From an emulator, the host machine's localhost:8080 is reachable at
http://10.0.2.2:8080. From a physical device on the same LAN, use the
host machine's LAN IP instead. Debug builds ship a network-security config
that permits cleartext HTTP to 10.0.2.2 / localhost / 127.0.0.1 only
(android/app/src/debug/) — login over plain HTTP to any other host, or from
a release build, will be blocked by Android's default cleartext policy.
To simulate driving, feed the emulator a sequence of GPS fixes:
adb emu geo fix <longitude> <latitude>Note the argument order is longitude first, then latitude — the reverse of how coordinates are usually spoken/written. Example, walking a route southwest in small steps:
adb emu geo fix -122.1050 37.4275
adb emu geo fix -122.1055 37.4272
adb emu geo fix -122.1060 37.4269(Equivalently, use the emulator's Extended Controls → Location panel to load a route or set points interactively.)
Run these in order against a single trip; check 5 ends it.
- Launch the app, enter the server URL (
http://10.0.2.2:8080on an emulator), and log in asdriver@example.com/driverpass123. - With exactly one assigned vehicle, the app should skip straight to Trip
Setup (auto-select). With more than one, pick
bus-1from the vehicle list. - Enter a route ID (e.g.
5) and tap Start Trip. - Work through the permission sequence as it appears: fine+coarse location (grant precise), background location explanation → OS settings redirect (choose "Allow all the time"), notifications (allow), battery-optimization exemption (continue or not-now — either is fine).
Expected outcome: after the permission sequence completes and device location services are confirmed on, the app navigates to the Tracking screen showing a green "Tracking – Connected" status, and a persistent foreground- service notification appears in the status bar.
-
With the app on the Tracking screen, feed the emulator a GPS fix (see "GPS playback" above).
-
Wait up to ~10 seconds (the location-report interval) and query the feed:
curl -s 'http://localhost:8080/gtfs-rt/vehicle-positions?format=json'
Expected outcome: the feed's entity[].vehicle.position.latitude /
.longitude match the fix you injected (within GPS precision), and the
timestamp advances on repeated calls as new fixes are sent. The app's
"fixes sent" counter should also be climbing.
- Disable networking on the device/emulator (physical airplane mode, or on
an emulator:
adb shell svc wifi disable && adb shell svc data disable— realAIRPLANE_MODEbroadcasts are blocked by emulator shell permissions, but this achieves the sameConnectivityManagercallback). - Watch the Tracking screen.
- Re-enable networking (
adb shell svc wifi enable && adb shell svc data enable, or toggle airplane mode off on a physical device).
Expected outcome: within ~10 seconds of the network dropping, the status banner flips to red "No connection". Within ~10 seconds of the network returning, it flips back to green "Tracking – Connected". GPS fixes captured while offline are dropped, not queued (v1 behavior) — the counter should resume climbing from wherever it left off, not "catch up".
-
Remove the app from Recents (swipe away). On some emulator builds the fling gesture is unreliable; an equivalent is:
adb shell dumpsys activity activities | grep -i taskId # find the app's task id adb shell am stack remove <taskId>
-
Confirm the service and notification are still present:
adb shell dumpsys activity services LocationTrackingService # should show the service, not empty adb shell cmd notification list | grep vehicletracker # should show one entry
-
Query the feed twice, ~10-15 seconds apart, and confirm the timestamp advances between calls.
Expected outcome: the foreground service and its notification survive task removal (this is the point of running as a foreground service), and location fixes keep arriving at the server the whole time. Relaunching the app should rehydrate directly to the Tracking screen (active trip state is persisted).
- Reopen the app (if not already open) and tap End Trip.
- Confirm in the dialog ("End this trip? This will stop location tracking and mark the trip as complete.").
Expected outcome, immediately:
- The app navigates back to the vehicle-selection screen (the session token is still fresh, so there's no need to log in again).
- The notification is gone:
adb shell cmd notification list | grep vehicletrackerreturns nothing. - The service is stopped:
adb shell dumpsys activity services LocationTrackingServicereturns nothing.
Expected outcome, after the staleness window: the server's
STALENESS_THRESHOLD (default 5 minutes; docker-compose.yml sets it
explicitly) excludes points older than the threshold from the feed. Poll the
feed every 20-30 seconds after the trip's last report:
curl -s 'http://localhost:8080/gtfs-rt/vehicle-positions?format=json'The vehicle's entity should disappear from entity[] once its last report
ages past the threshold (around 5 minutes with the default configuration).
docker compose down
rm -f docker-compose.override.yml # if you created one for JWT_SECRETmake down also works and is equivalent to docker compose down here.