Trim the README, add an Android README and screenshots
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MREADME.md
@@ -2,51 +2,44 @@
Self-hosted location sharing. Devices upload positions over HTTPS. A web map shows your position and the positions others share with you.

```
crates/api JSON types shared by server and web
crates/server otserver: axum + SQLite, serves the API and web/dist
web/ Leptos + Leaflet, built with Trunk
android/ Android app: background tracking, this device's map, see docs/android.md
android/ Android app: background tracking, this device's map, see android/README.md
```
## Development
## Deployment
Set `OT_PUBLIC_URL` in `compose.yaml` to your address. Then start the server:
```sh
cd web && trunk build && cd .. # or `trunk serve`: live reload on :8081, API proxied to :8080
cargo run -p server # http://localhost:8080, the first visit creates the admin account
docker compose up -d
```
Send points and read the position as a device:
The container listens on `127.0.0.1:8080`. Put a TLS reverse proxy in front of it. A proxy with HTTP/3 is recommended, because phones reconnect faster after sleep. The Android app uses HTTP/3 from Android 14. Caddy does HTTP/3 by default:
```sh
URL=http://localhost:8080 TOKEN=... # a token from Settings → Devices
curl -H "Authorization: Bearer $TOKEN" --json "[{\"ts\":$(date +%s),\"lat\":48.137,\"lon\":11.575}]" $URL/api/points
curl -H "Authorization: Bearer $TOKEN" $URL/api/device
# a random walk, one point every 2 seconds
lat=48.137 lon=11.575
while sleep 2; do
set -- $(LC_ALL=C awk -v a=$lat -v o=$lon 'BEGIN { srand(); printf "%.6f %.6f", a + (rand() - .5) / 1000, o + (rand() - .5) / 1000 }')
lat=$1 lon=$2
curl -sH "Authorization: Bearer $TOKEN" --json "[{\"ts\":$(date +%s),\"lat\":$lat,\"lon\":$lon}]" $URL/api/points
done
```
track.example.com {
encode zstd gzip
reverse_proxy 127.0.0.1:8080
}
```
### Android app
HTTP/3 needs UDP 443 open next to TCP 443.
Needs JDK 17 or newer and the Android SDK in `ANDROID_HOME`:
Open the address and create the admin account. The first visitor gets this account, so do it before you expose the server.
```sh
cd android
ANDROID_HOME=~/android-sdk ./gradlew testDebugUnitTest assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
```
The server updates the database schema at start. Back up the database file before you upgrade.
Debug builds allow plain HTTP. In the emulator, the server on the host is `http://10.0.2.2:8080`. `adb emu geo fix <lon> <lat>` moves the emulator's GPS.
### Reverse proxy
### Languages
- `--public-url` is the address that browsers use. Passkeys are bound to it. An `https://` URL also marks the session cookie `Secure`.
- `--trusted-proxy` makes the sign-in rate limits use the client address from the proxy's `X-Forwarded-For` header. Only turn it on when clients cannot reach the server port directly. Otherwise they can fake the header.
The web UI and the app are in English and German. The web UI follows the browser language, the app the phone language or its own language setting in Android. Web texts live in `web/src/i18n.rs`, with the English text as the key. App texts live in `android/app/src/main/res/values*/strings.xml`.
`compose.yaml` sets both.
## Configuration
@@ -57,81 +50,39 @@ Every flag can also be set by its environment variable. `otserver --help` lists
| `--addr` | `OT_ADDR` | `127.0.0.1:8080` | listen address |
| `--db` | `OT_DB` | `ot.db` | SQLite file |
| `--web-dir` | `OT_WEB_DIR` | `web/dist` | built web UI |
| `--public-url` | `OT_PUBLIC_URL` | | the address browsers use, see below |
| `--retention-days` | `OT_RETENTION_DAYS` | `30` | days to keep points, `0` keeps them forever. Users can choose a shorter time. Each device keeps its newest point, so it stays on the map. |
| `--trusted-proxy` | `OT_TRUSTED_PROXY` | off | the server is reachable only through one reverse proxy, see below |
| `--public-url` | `OT_PUBLIC_URL` | | the address browsers use, see [Reverse proxy](#reverse-proxy) |
| `--retention-days` | `OT_RETENTION_DAYS` | `30` | days to keep points, `0` keeps them forever. Users can choose a shorter time. |
| `--trusted-proxy` | `OT_TRUSTED_PROXY` | off | trust `X-Forwarded-For`, see [Reverse proxy](#reverse-proxy) |
`--public-url` matters behind a reverse proxy:
- Passkeys are bound to this address. Without it the server uses the request's Host header and assumes plain HTTP.
- An `https://` URL marks the session cookie `Secure`.
- The web UI shows it as the server address for devices.
`otserver passwd <user>` creates a user or resets a password. A reset also removes the user's passkeys and device tokens.
The server updates the database schema at start. Back up the database file before you upgrade.
## Usage
`otserver passwd <user>` creates a user or resets a password. A reset also removes all passkeys of the user, revokes their device tokens and turns off two-factor sign-in, so a lost device cannot sign in. The devices and their history stay. Connect them again with a new token.
- Admins manage users under Settings → Users.
- Each user can sign in with a password, a passkey, or both (two-factor). Choose this under Settings → Security.
- Devices sign in with a token. The Android app gets one through the browser. For other clients, create one under Settings → Devices.
- A person can upload from several devices. The map shows one dot per person, at the newest position.
- A share with another user chooses which devices, how much history and how exact the position is.
- A guest link shares with anyone who has the link, optionally with a password.
## Accounts and sign-in
- The first visit to a server with no users shows a setup form for the admin account. Anyone who reaches the server first can claim it, so set it up before you expose it.
- Admins add and delete users, change their role and reset passwords under Settings → Users. Admins cannot change their own role, so one admin always remains.
- Each user picks a sign-in mode under Settings → Security: password **or** passkey, or password **and** passkey (two-factor), in either order. A user with a passkey can remove the password.
- Wrong passwords are limited: 5 per username from all client addresses together, and 30 per client address across all usernames, then a 15-minute lockout. An IPv6 /64 counts as one address. An address that signed in to an account in the last 30 days skips the shared limit, so others cannot lock the owner out. It keeps its own limit of 5.
- Changes to how you sign in (password, two-factor, passkeys) and admin changes to users need a sign-in in the last 10 minutes. Otherwise sign out and sign in again.
- Devices sign in with a token. The Android app gets one by signing in through the browser. For other clients, create one under Settings → Devices.
## Devices and sharing
## Development
- A person can upload from several devices at once. Each device keeps its own trail. The map shows one dot per person, at the newest position of any device.
- The web app counts as one device, "Web", in every browser.
- Removing a device deletes its points.
- A share chooses what the viewer sees:
- all devices, including ones added later, or only selected devices,
- only the current position, or the trail from now on, since a chosen time, or the full history,
- the exact position, or one rounded to about 100 m, 1 km or 10 km. Rounding also rounds the times, so the trail does not show the moment of moving into the next cell. The shown cell changes only when the position is clearly inside the next cell, so GPS noise near an edge does not reveal it. A viewer who watches the current position still sees the change soon after it happens. Rounded shares do not show the battery.
- Sharing again with the same person replaces the settings.
- A guest link shares with anyone who has the link, without an account. It has the same choices, plus an optional password. The link has the form `https://track.example.com/#l=<token>`. The token stays after the `#`, so it does not reach server or proxy logs when the page loads.
```sh
cd web && trunk build && cd .. # or `trunk serve`: live reload on :8081, API proxied to :8080
cargo run -p server # http://localhost:8080
```
## Deployment
Send a point as a device:
```sh
OT_PUBLIC_URL=https://track.example.com docker compose up -d
URL=http://localhost:8080 TOKEN=... # a token from Settings → Devices
curl -H "Authorization: Bearer $TOKEN" --json "[{\"ts\":$(date +%s),\"lat\":48.137,\"lon\":11.575}]" $URL/api/points
```
The container listens on `127.0.0.1:8080`. Put a TLS reverse proxy in front of it. A proxy with HTTP/3 is recommended: phones connect faster after sleeping, and the connection survives network changes. Caddy does HTTP/3 by default:
### Android app
```
track.example.com {
reverse_proxy 127.0.0.1:8080
}
```
See [android/README.md](android/README.md).
### Languages
HTTP/3 needs UDP 443 open next to TCP 443. `curl --http3-only -sI https://track.example.com/healthz` prints `HTTP/3 200` when it works.
Set `OT_TRUSTED_PROXY=true`, so the password limits see the real client address. The server then takes the last `X-Forwarded-For` entry, the one the proxy wrote. Clients must not reach the server port directly, or they could set that header themselves. With several proxies in a row, the limits see the outer proxy's address.
## API
Web endpoints use the session cookie. Devices use `Authorization: Bearer <token>`.
| | | |
|---|---|---|
| `GET/POST /api/setup` | none | first-boot admin account |
| `POST /api/login`, `/api/logout` | password | can answer with a passkey challenge (two-factor) |
| `POST /api/passkey/login[/finish]` | none | passkey sign-in. Can ask for the password next (two-factor). At most 30 starts per client address in 15 minutes |
| `GET /api/me` | session | |
| `POST/DELETE /api/me/password`, `PUT /api/me/two-factor`, `PUT /api/me/retention` | session | |
| `GET /api/passkeys`, `POST /api/passkeys/register[/finish]`, `DELETE /api/passkeys/{id}` | session | |
| `GET /healthz` | none | `ok` while the database answers |
| `GET /api/people` | session | you plus everyone who shares with you, with each visible device and its latest point |
| `GET /api/people/{id}/track?from=&to=&device=` | session | one device's points in a time range, at most 31 days |
| `GET/POST /api/devices`, `DELETE /api/devices/{id}` | session | POST creates a device token |
| `GET/POST /api/shares`, `DELETE /api/shares/{id}` | session | POST with an existing viewer replaces that share |
| `GET /api/usernames` | session | all other usernames, for the share form |
| `GET/POST /api/links`, `DELETE /api/links/{id}` | session | guest links |
| `POST /api/guest`, `/api/guest/track` | link token (+ key) | what a guest link shows. 401 means the link needs its password |
| `POST /api/guest/unlock` | link token + password | returns the key for a password-protected link. 5 failures lock the link for 15 minutes |
| `GET/POST /api/users`, `DELETE /api/users/{id}`, `PUT /api/users/{id}/role`, `POST /api/users/{id}/password` | admin | |
| `POST /api/devices/pair/begin` | session | `{challenge, name}` → one-time code for the app, valid 5 minutes and while the session that asked for it lasts |
| `POST /api/devices/pair` | code + verifier | the app exchanges the code and the secret behind the challenge for a device token. Each code works once. |
| `GET /api/device`, `GET /api/device/track?from=&to=` | device token | the app's own position and trail. The web UI's `#device` page uses them inside the app. |
| `POST /api/points` | device token or session | JSON array of points, at most 1000. Returns how many were stored and skipped. Invalid points are skipped, so one bad point cannot block a client's queue. Duplicates (same device and second) are ignored, so a client can retry a batch. A session uploads as the "Web" device. |
The web UI and the app are in English and German. Web texts live in `web/src/i18n.rs`. App texts live in `android/app/src/main/res/values*/strings.xml`.
Aandroid/README.md
@@ -0,0 +1,48 @@
# opentracker for Android
The app records the phone's position in the background and uploads it to an opentracker server. Its screen shows this phone's position and trail. The menu opens the full web UI for shares, guest links and settings.
<img src="../docs/screenshots/android.webp" alt="The app with this phone's trail" width="300">
- Android 12 or newer.
- No Google Play Services, so it also runs on de-Googled phones.
## Connect
1. Enter the server address, for example `track.example.com`.
2. The browser opens. Sign in and confirm the phone.
3. The browser returns to the app, and the app gets its own device token. It never stores your password.
Without a browser sign-in, create a token in the web UI under Settings → Devices and paste it into the app.
## Permissions
Turn on tracking with the switch. The app then asks for each permission and explains it first:
- **Precise location:** required.
- **Location all the time:** tracking restarts after a reboot.
- **Notifications:** shows the tracking notification.
- **Run in the background:** some phones stop tracking without it.
Menu → Permissions shows what is missing. Many vendors add their own battery settings that stop background apps. The same screen links to [dontkillmyapp.com](https://dontkillmyapp.com), which explains these settings for each vendor.
## Battery
The app uses GNSS only while the phone moves. The phone's motion sensor wakes it again. When the phone is still, the app records one network position every 15 minutes.
The GNSS chip collects positions while the phone sleeps and delivers them in batches. Uploads also go out in batches. So the map can lag a few minutes behind.
On Android 14 and newer, uploads use the system's HTTP stack with HTTP/2 and HTTP/3. Older versions use HTTP/1.1. HTTP/3 needs a server address with a host name, not an IP address.
## Build
Needs JDK 17 or newer and the Android SDK in `ANDROID_HOME`:
```sh
ANDROID_HOME=~/android-sdk ./gradlew testDebugUnitTest assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
```
Debug builds have the app ID `org.opentracker.debug` and allow plain HTTP. In the emulator, a server on the host is `http://10.0.2.2:8080`. `adb emu geo fix <lon> <lat>` moves the emulator's GPS.
The design and its reasons are in [docs/android.md](../docs/android.md).