android.md
⎇
Raw

Android client plan

Goal

The app records the phone's position in the background and uploads it to the server. It shows one screen: the map of the web UI, limited to this device. A menu opens the full web UI in an in-app browser for settings, shares and guest links.

Why native Kotlin

The hard part of this app is background work:

  • a foreground service of type location,
  • LocationManager with GNSS batching,
  • the significant-motion sensor,
  • alarms that fire in Doze,
  • a boot receiver,
  • the permission flow for background location.

No cross-platform framework covers these. Flutter, React Native, Capacitor and Tauri all need a native Kotlin plugin for them. The UI part is small, and a WebView covers it. So a plain Kotlin app is the smallest total.

The old app in git history (commit 4cb515d, android/) already solved several of these parts. Its sampling policy, motion detector, watchdog, boot receiver and permission gate can be adapted. Its UDP transport, crypto and login code are not needed.

Architecture

MainActivity
  top bar: tracking switch, status line, menu (Web UI, Log out)
  WebView: {server}/#device, the map limited to this device

TrackerService (foreground service, type location)
  LocationSource -> SamplingPolicy -> Outbox (SQLite) -> Uploader (HTTP)
  MotionDetector wakes the policy when the phone starts moving
  Watchdog alarm restarts the loop after deep sleep

BootReceiver: starts TrackerService after a reboot, if tracking was on
  • Language and build: Kotlin, Gradle, minSdk 29, targetSdk current.
  • Dependencies: OkHttp and AndroidX Browser (for Custom Tabs). Jetpack Compose only if the top bar needs more than plain views.
  • No Google Play Services. The app must work on de-Googled phones. The fused location provider and activity recognition need Play Services, so the app uses LocationManager and sensors.

Connecting the app to an account

The app gets a device token. It never stores the account password.

  1. The user enters the server URL.
  2. The app opens {server}/#pair in a Custom Tab. That is the real browser, so passkeys, password managers and two-factor sign-in work.
  3. After sign-in, the page asks "Connect this app as device <name>?".
  4. On confirm, the server creates a one-time code. The page redirects to opentracker://paired?code=....
  5. The app exchanges the code for a device token with a direct POST /api/devices/pair. It sends a random verifier that it created in step 2. This works like PKCE: another app that intercepts the redirect cannot use the code without the verifier.

Fallback: paste the ot use-token line from Settings → Devices.

Map screen

The map is the existing Leptos map page in a WebView, in a new "device mode":

  • The URL is {server}/#device.
  • The app gives the page its token through a JavaScript bridge, window.OtApp.token(). The page sends it as Authorization: Bearer.
  • The page shows only this device: its position, its trail and the trail filters. There is no people list, no device picker and no "share from this browser".
  • Polling stops when the WebView is hidden, as it already does in the browser.

The WebView loads only pages from the server. Links to other sites open in the browser.

Settings screen

"Web UI" in the menu opens {server}/ in a Custom Tab. It uses the browser's session, so the user signs in once in the browser.

A Custom Tab instead of a WebView for this part:

  • passkeys work in a Custom Tab, but in a WebView they need a verified app-to-domain link. A self-hosted server cannot provide that link for every domain.
  • password managers and the browser session are available,
  • no login code in the app.

Server changes

Change Purpose
GET /api/device (Bearer) this device as a Person with one device
GET /api/device/track?from=&to= (Bearer) this device's trail
POST /api/devices/pair/begin (session) creates a one-time code for a verifier hash, valid 5 minutes
POST /api/devices/pair (no auth) code + verifier + name → device token
Web: #device mode the map page with the device endpoints, like guest mode
Web: #pair page confirm step and redirect to opentracker://

Later: per-device settings that the server sends back in the upload response, for example the sampling profile.

Battery

Two things drain a tracker's battery: GNSS on while it is not needed, and radio wakeups for small uploads. Each item below targets one of them.

Sampling modes

The sampling policy has four modes:

Mode When Location request
Vehicle speed above about 25 km/h GNSS, interval 5 s, minimum distance 20 m
Walk moving, slower GNSS, interval 30 s, minimum distance 10 m
Dwell stopped less than 5 minutes ago as Walk
Stationary still for 5 minutes network only, interval 15 min, GNSS off
  • Speeding up is immediate. The first fix that shows faster movement switches the mode, so the start of a trip is not lost.
  • Slowing down waits. Dwell bridges red lights, shop queues and platform waits.
  • Stationary turns GNSS off. The significant-motion sensor (TYPE_SIGNIFICANT_MOTION) runs in the sensor hub, not on the CPU, and wakes the policy when the phone moves. This sensor is one-shot, so the handler must arm it again after each trigger.
  • Heartbeat: while stationary, one network fix every 15 minutes. The map can then tell "here and still" from "gone".
  • Filters: fixes worse than 50 m accuracy are dropped, unless nothing better came for 10 minutes. Fixes inside the minimum distance are dropped.
  • Low battery: below 15 % and not charging, all intervals double. Charging allows Walk settings in every mode.

The policy is pure Kotlin with no Android types. It gets the time as a parameter, so it runs in JVM unit tests.

GNSS batching

On Android 12 and newer, LocationRequest.Builder.setMaxUpdateDelayMillis lets the GNSS chip collect fixes while the CPU sleeps. It then delivers them in one batch.

  • Walk mode uses a 2-minute delay and Vehicle mode 1 minute.
  • The map then lags behind by that delay, which is fine for this app.
  • On Android 10 and 11 the app gets fixes one by one.

Uploads

  • Points go into a SQLite outbox first, then upload in batches. The server's (device, second) key makes retries safe.
  • When to upload:
    • Upload when 20 points are waiting, or the oldest point is 2 minutes old.
    • Upload at once when the app is open, when the mode changes, or for the first fix after a long stillness.
    • Stationary heartbeats upload alone. Battery cost is the same, because one point is one radio wakeup either way.
  • No network: a ConnectivityManager callback pauses uploads while offline. Uploads resume when the network returns.
  • Retries: exponential backoff from 30 s to 15 minutes.
  • Outbox limit: it keeps at most 50,000 points and drops the oldest first.
  • Connection: one OkHttp client with keep-alive, so a batch reuses the open TLS connection. HTTP/3 through Cronet is possible later, if measurements show it saves power.

Doze and process death

  • Foreground service: a location foreground service with an ongoing notification. The notification shows the mode and the last upload.
  • Watchdog: postDelayed stops counting in deep sleep. A setAndAllowWhileIdle alarm every 15 minutes restarts the loop and a pending upload. It is a one-shot alarm, armed again each time. It needs no exact-alarm permission.
  • Battery optimisation exemption: the app asks for it with an explanation. Without it, some vendors stop the service anyway. dontkillmyapp.com lists the vendor-specific settings, and the app links to the page for the phone's vendor.
  • Restarts: START_STICKY, plus the boot receiver. All state comes from preferences, not from the Intent, because the system restarts a sticky service with a null Intent.
  • No long wakelocks. Location callbacks and the alarm wake the app when needed.

Measuring

  • adb shell dumpsys batterystats and Battery Historian, over a full day of normal use.
  • Rough targets:
    • a stationary day costs under 2 % extra,
    • walking costs about 1 % to 2 % per hour,
    • driving costs about 3 % to 5 % per hour.
  • Real traces may show that the sampling numbers need tuning. They are constants in one file for that reason.

Permissions

Permission Why
ACCESS_FINE_LOCATION GNSS
ACCESS_BACKGROUND_LOCATION start tracking from the boot receiver and the watchdog
FOREGROUND_SERVICE, FOREGROUND_SERVICE_LOCATION the tracking service
POST_NOTIFICATIONS the service notification, Android 13+
RECEIVE_BOOT_COMPLETED restart after reboot
INTERNET, ACCESS_NETWORK_STATE uploads, offline detection
REQUEST_IGNORE_BATTERY_OPTIMIZATIONS the exemption request

The permission flow asks in steps: fine location, then "Allow all the time" in system settings, then notifications, then the battery exemption. Each step explains why first. The app works without background location, but then tracking does not restart after a reboot.

Steps

  1. Server: device endpoints, pairing endpoints, #device and #pair web modes. Tests for pairing and device-only access.
  2. App skeleton: MainActivity with the WebView in device mode, token in encrypted preferences, manual token paste.
  3. Tracking v1: foreground service, LocationManager, fixed 30 s interval, outbox, uploader, notification.
  4. Battery: sampling policy with JVM tests, motion detector, GNSS batching, upload batching, watchdog, boot receiver.
  5. Pairing: Custom Tab flow and the code exchange.
  6. Permission flow and battery exemption, with explanation screens.
  7. Measurement: one day each of stationary, walking and driving traces. Tune the constants.
  8. Release: signed APK, later F-Droid.

Out of scope for now

  • seeing other people in the app (the web UI does that),
  • offline map tiles,
  • iOS.