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, LocationManagerwith 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
- App ID:
org.opentracker, andorg.opentracker.debugfor debug builds. - Language and build: Kotlin, Gradle, minSdk 31 (Android 12, for GNSS batching and the modern
LocationRequest), targetSdk 36. - Dependencies: only AndroidX Browser, for Custom Tabs. HTTP uses
HttpURLConnection, JSON uses the platform'sorg.json, and the UI uses plain platform 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
LocationManagerand sensors.
Connecting the app to an account
The app gets a device token. It never stores the account password.
- The user enters the server URL.
- The app opens
{server}/#pairin a Custom Tab. That is the real browser, so passkeys, password managers and two-factor sign-in work. - After sign-in, the page asks "Connect this app as device <name>?".
- On confirm, the server creates a one-time code. The page redirects to
opentracker://paired?code=.... - 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 asAuthorization: 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 tracking settings, see Configurable battery settings.
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
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.
- Fallback: some GNSS chips accept a batching request and then deliver almost nothing. The emulator does this. If no fix arrives within two batch periods, the service turns batching off until it restarts. Indoors, GNSS is silent anyway, so the fallback may also trigger there. That only costs some battery.
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
ConnectivityManagercallback 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
locationforeground service with an ongoing notification. The notification shows the mode and the last upload. - Watchdog:
postDelayedstops counting in deep sleep. AsetAndAllowWhileIdlealarm 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 batterystatsand 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.
Configurable battery settings (later)
The constants above become per-device settings. They are edited in the web UI, so the app needs no settings screens.
-
Presets: Battery saver, Balanced (the default) and Precise. Each fills in the table above.
-
Advanced values:
- Walk and Vehicle intervals,
- the accuracy limit,
- the stationary heartbeat,
- the upload delay,
- the low-battery threshold.
Changing one turns the preset into "Custom".
-
Where: Settings → Devices, a "Tracking" section per device. This also works from a desktop, and each device can have its own profile.
-
Storage: a JSON
trackingcolumn ondevices, with a version number. The server checks each value against a minimum and maximum, for example an interval between 1 s and 1 h. -
Delivery: the upload response and
GET /api/devicecarry the config. The app applies it when the version changes. A stationary phone uploads every 15 minutes, so a change arrives within that time without a push service. -
In the app: the top bar and the notification show the active profile, for example "Balanced · stationary". There is nothing to change there.
-
Code:
SamplingPolicytakes aProfiledata class instead of constants, and its JVM tests run once per preset. Theapicrate gets aTrackingConfigtype, andUploadedgets an optionalconfigfield.
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
Steps 1 to 6 are done and tested in the emulator. Doze and the watchdog are not tested yet, because they need hours on a real phone.
- Server: device endpoints, pairing endpoints,
#deviceand#pairweb modes. Tests for pairing and device-only access. - App skeleton: MainActivity with the WebView in device mode, token in app-private preferences, manual token paste. Backups and device transfers exclude all app data, so the token never leaves the phone.
- Tracking v1: foreground service,
LocationManager, fixed 30 s interval, outbox, uploader, notification. - Battery: sampling policy with JVM tests, motion detector, GNSS batching, upload batching, watchdog, boot receiver.
- Pairing: Custom Tab flow and the code exchange.
- Permission flow and battery exemption, with explanation screens.
- Measurement: one day each of stationary, walking and driving traces. Tune the constants.
- 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.