Keep queued points on proxy errors and fill gaps from late GNSS batches
The uploader drops a batch only on 400 or 422, the server's answers to a malformed batch. A 413 halves the batch size. Other 4xx answers retry. A 401 removes the token only if GET /api/device confirms it. The sampling policy compares a fix with the kept fix before it in time, so a GNSS batch that arrives after a network fix still fills the trail. Prefs uses property delegates. The token field and the docs no longer mention the ot CLI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mandroid/app/src/main/java/org/opentracker/MainActivity.kt
@@ -271,7 +271,7 @@ class MainActivity : Activity() {
}
}
/** Accepts the `ot use-token <url> <token>` line from the web UI, or only the token. */
/** Finds the token and an optional server URL in the pasted text, so the web UI's device setup text works as is. */
private fun pasteToken(typedUrl: String) {
val input = EditText(this).apply { setHint(R.string.token_hint) }
AlertDialog.Builder(this)
Mandroid/app/src/main/java/org/opentracker/Prefs.kt
@@ -2,44 +2,26 @@ package org.opentracker
import android.content.Context
import android.content.SharedPreferences
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
/** Everything the app keeps. The service reads it on every start, because a restarted sticky service gets no Intent. */
class Prefs(context: Context) {
val raw: SharedPreferences = context.getSharedPreferences("ot", Context.MODE_PRIVATE)
var url: String?
get() = raw.getString("url", null)
set(v) = raw.edit().putString("url", v).apply()
var token: String?
get() = raw.getString("token", null)
set(v) = raw.edit().putString("token", v).apply()
var tracking: Boolean
get() = raw.getBoolean("tracking", false)
set(v) = raw.edit().putBoolean("tracking", v).apply()
// The property name is the stored key. Renaming a property loses the setting on existing installs.
var url by string()
var token by string()
var tracking by boolean()
/** The secret behind a pairing challenge, until the browser returns with a code. */
var pairVerifier: String?
get() = raw.getString("pairVerifier", null)
set(v) = raw.edit().putString("pairVerifier", v).apply()
var pairVerifier by string()
/** A [Mode] name. */
var mode: String?
get() = raw.getString("mode", null)
set(v) = raw.edit().putString("mode", v).apply()
var lastUploadMs: Long
get() = raw.getLong("lastUploadMs", 0)
set(v) = raw.edit().putLong("lastUploadMs", v).apply()
var queued: Long
get() = raw.getLong("queued", 0)
set(v) = raw.edit().putLong("queued", v).apply()
var error: String?
get() = raw.getString("error", null)
set(v) = raw.edit().putString("error", v).apply()
var mode by string()
var lastUploadMs by long()
var queued by long()
var error by string()
/** The mode in the user's language. */
fun modeLabel(context: Context): String? = when (mode) {
@@ -53,4 +35,18 @@ class Prefs(context: Context) {
fun asked(step: String): Boolean = raw.getBoolean("asked $step", false)
fun markAsked(step: String) = raw.edit().putBoolean("asked $step", true).apply()
private fun string() = pref({ getString(it, null) }, { k, v: String? -> putString(k, v) })
private fun long() = pref({ getLong(it, 0) }, { k, v: Long -> putLong(k, v) })
private fun boolean() = pref({ getBoolean(it, false) }, { k, v: Boolean -> putBoolean(k, v) })
private fun <T> pref(get: SharedPreferences.(String) -> T, put: SharedPreferences.Editor.(String, T) -> Unit) =
object : ReadWriteProperty<Any?, T> {
override fun getValue(thisRef: Any?, property: KProperty<*>): T = raw.get(property.name)
override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) =
raw.edit().apply { put(property.name, value) }.apply()
}
}
Mandroid/app/src/main/java/org/opentracker/SamplingPolicy.kt
@@ -37,7 +37,9 @@ class SamplingPolicy {
var mode = Mode.WALK
private set
private var lastKept: Fix? = null
/** Kept fixes, oldest first. GNSS batches arrive minutes late, after newer network fixes. */
private val kept = ArrayDeque<Fix>()
private val lastKept get() = kept.lastOrNull()
private var lastFix: Fix? = null
private var stillSinceMs = 0L
private var slowSinceMs: Long? = null
@@ -120,12 +122,13 @@ class SamplingPolicy {
}
private fun keep(fix: Fix): Boolean {
val last = lastKept
// The server keeps one point per second.
if (kept.any { it.timeMs / 1000 == fix.timeMs / 1000 }) return false
val i = kept.indexOfLast { it.timeMs < fix.timeMs }
val last = kept.getOrNull(i) ?: kept.firstOrNull()
val age = if (last == null) Long.MAX_VALUE else fix.timeMs - last.timeMs
val ok = when {
last == null -> true
// The server keeps one point per second.
fix.timeMs / 1000 <= last.timeMs / 1000 -> false
// A vague position beats none after a long gap, for example indoors.
(fix.accuracy ?: 0f) > MAX_ACCURACY -> age >= STALE_MS
age >= HEARTBEAT_MS -> true
@@ -133,7 +136,11 @@ class SamplingPolicy {
mode == Mode.VEHICLE -> distance(last, fix) >= 20
else -> distance(last, fix) >= 10
}
if (ok) lastKept = fix
if (ok) {
kept.add(i + 1, fix)
// Keeps one fix before the window, so a late fix still has a predecessor.
while (kept.size > 1 && kept[1].timeMs < kept.last().timeMs - LATE_MS) kept.removeFirst()
}
return ok
}
@@ -148,6 +155,8 @@ class SamplingPolicy {
const val STALE_MS = 10 * 60_000L
const val MAX_ACCURACY = 50f
const val DEPARTURE_M = 200.0
/** Longer than the longest batch delay in [request]. */
const val LATE_MS = 5 * 60_000L
/** Metres. Equirectangular, which is exact enough at these distances. */
fun distance(a: Fix, b: Fix): Double {
Mandroid/app/src/main/java/org/opentracker/TrackerService.kt
@@ -41,6 +41,7 @@ class TrackerService : Service() {
private var online = true
private var backoffMs = 0L
private var uploadAtMs = Long.MAX_VALUE
private var maxUpload = MAX_UPLOAD
private var lastFixMs = 0L
/** Batching should deliver at least one fix in this time, else it is broken. 0 while not batching GNSS. */
private var batchWindowMs = 0L
@@ -204,7 +205,7 @@ class TrackerService : Service() {
val url = prefs.url
val token = prefs.token
if (url == null || token == null) return
val batch = outbox.peek(MAX_UPLOAD)
val batch = outbox.peek(maxUpload)
if (batch.isEmpty()) return
try {
Api(url, token).upload(batch.joinToString(",", "[", "]") { it.second })
@@ -217,13 +218,19 @@ class TrackerService : Service() {
if (outbox.count() > 0) handler.post(upload) else prefs.queued = 0
} catch (e: HttpError) {
when {
e.status == 401 -> return removed()
// The server will refuse this batch again. Keeping it would block every later point.
e.status in 400..499 && e.status != 429 -> {
// Proxies and captive portals also answer 401, so confirm it before the token goes.
e.status == 401 && tokenRevoked(url, token) -> return removed()
// The server rejects a malformed batch with these. A retry fails again and blocks every later point.
e.status == 400 || e.status == 422 -> {
outbox.removeUpTo(batch.last().first)
prefs.error = resources.getQuantityString(R.plurals.server_refused_points, batch.size, batch.size, e.message)
schedule(urgent = true)
}
// A proxy body limit. The server accepts a full batch.
e.status == 413 && maxUpload > 1 -> {
maxUpload /= 2
handler.post(upload)
}
else -> retry(getString(R.string.server_refused_upload, e.message))
}
} catch (e: IOException) {
@@ -239,6 +246,13 @@ class TrackerService : Service() {
prefs.queued = outbox.count()
}
private fun tokenRevoked(url: String, token: String): Boolean = try {
Api(url, token).device()
false
} catch (e: IOException) {
e is HttpError && e.status == 401
}
/** The device was deleted on the server. Its token is useless now. */
private fun removed() {
prefs.token = null
Mandroid/app/src/main/res/values-de/strings.xml
@@ -24,6 +24,7 @@
<string name="connect_failed">Verbinden fehlgeschlagen. Beginne erneut. (%1$s)</string>
<string name="token_title">Geräte-Token</string>
<string name="token_text">Erstelle ein Token in der Weboberfläche unter Einstellungen → Geräte.</string>
<string name="token_hint">Token, optional mit der Serveradresse</string>
<string name="connect">Verbinden</string>
<string name="not_a_token">Das ist kein Geräte-Token.</string>
<string name="token_refused">Der Server hat das Token nicht angenommen. (%1$s)</string>
Mandroid/app/src/main/res/values/strings.xml
@@ -25,6 +25,7 @@
<string name="connect_failed">Connecting failed. Start again. (%1$s)</string>
<string name="token_title">Device token</string>
<string name="token_text">Create a token in the web UI under Settings → Devices.</string>
<string name="token_hint">Token, optionally with the server address</string>
<string name="connect">Connect</string>
<string name="not_a_token">That is not a device token.</string>
<string name="token_refused">The server did not accept the token. (%1$s)</string>
@@ -62,5 +63,4 @@
<string name="mode_dwell">Stopped</string>
<string name="mode_stationary">Still</string>
<string name="url_hint" translatable="false">https://track.example.com</string>
<string name="token_hint" translatable="false">ot use-token https://… token</string>
</resources>
Mandroid/app/src/test/java/org/opentracker/SamplingPolicyTest.kt
@@ -86,6 +86,17 @@ class SamplingPolicyTest {
assertTrue(p.onFix(fix(60_000, 40.0, speed = 1.5f)))
}
@Test
fun a_late_gnss_batch_fills_the_gap_before_a_network_fix() {
val p = SamplingPolicy()
assertTrue(p.onFix(fix(0, speed = 1.5f)))
assertTrue(p.onFix(fix(120_000, 160.0, speed = null, acc = 20f)))
assertTrue(p.onFix(fix(30_000, 40.0, speed = 1.5f)))
assertTrue(p.onFix(fix(60_000, 80.0, speed = 1.5f)))
assertFalse(p.onFix(fix(90_000, 84.0, speed = 1.5f)))
assertFalse(p.onFix(fix(120_500, 130.0, speed = 1.5f)))
}
@Test
fun movement_counts_even_if_gnss_reports_no_speed() {
val p = SamplingPolicy()
Mdocs/android.md
@@ -49,7 +49,7 @@ The app gets a device token. It never stores the account password.
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.
Fallback: paste the server URL and a device token from Settings → Devices.
## Map screen
@@ -115,6 +115,7 @@ The policy is pure Kotlin with no Android types. It gets the time as a parameter
- 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.
- Network fixes arrive at once, so a GNSS batch can be older than the last kept fix. The policy compares each fix with the kept fix just before it in time, so the batch still fills the trail.
- **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
@@ -126,8 +127,9 @@ The policy is pure Kotlin with no Android types. It gets the time as a parameter
- 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.
- **Refused batches:** the app drops a batch only on 400 or 422, the server's answers to a malformed batch. A 413 comes from a proxy body limit, so the app halves the batch size. Other errors retry. A 401 removes the token only if `GET /api/device` also answers 401.
- **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.
- **Connection:** `HttpURLConnection` 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