Use HTTP/3 on Android 14+ and link vendor battery guides
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mandroid/app/src/main/java/org/opentracker/Api.kt
@@ -1,5 +1,9 @@
package org.opentracker
import android.content.Context
import android.net.http.HttpEngine
import android.os.Build
import java.io.File
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
@@ -8,7 +12,7 @@ import org.json.JSONObject
class HttpError(val status: Int, message: String) : IOException(message)
/** Blocking HTTP calls. Run them off the main thread. Connections stay open between calls (keep-alive). */
class Api(private val baseUrl: String, private val token: String? = null) {
class Api(private val context: Context, private val baseUrl: String, private val token: String? = null) {
/** A JSON array of points, as the server's `Point` type. */
fun upload(points: String) {
@@ -26,7 +30,7 @@ class Api(private val baseUrl: String, private val token: String? = null) {
}
private fun call(method: String, path: String, body: String?): String {
val conn = URL(baseUrl + path).openConnection() as HttpURLConnection
val conn = open(context, URL(baseUrl + path))
try {
conn.requestMethod = method
conn.connectTimeout = 15_000
@@ -49,6 +53,20 @@ class Api(private val baseUrl: String, private val token: String? = null) {
}
companion object {
private var engine: HttpEngine? = null
/** Android 14+ has Cronet built in, with HTTP/2 and HTTP/3. The older HttpURLConnection speaks only HTTP/1.1. */
@Synchronized
private fun open(context: Context, url: URL): HttpURLConnection {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return url.openConnection() as HttpURLConnection
val e = engine ?: HttpEngine.Builder(context.applicationContext)
// Keeps the server's Alt-Svc answer across restarts, so the first request can already try HTTP/3.
.setStoragePath(File(context.noBackupFilesDir, "http").apply { mkdirs() }.path)
.build()
.also { engine = it }
return e.openConnection(url) as HttpURLConnection
}
/** `track.example.com` becomes `https://track.example.com`. Returns null for an unusable address. */
fun normalize(input: String): String? {
var url = input.trim().trimEnd('/')
Mandroid/app/src/main/java/org/opentracker/MainActivity.kt
@@ -262,7 +262,7 @@ class MainActivity : Activity() {
val verifier = prefs.pairVerifier
if (url == null || verifier == null) return showError(getString(R.string.start_in_app))
thread {
val result = runCatching { Api(url).pair(code, verifier) }
val result = runCatching { Api(this, url).pair(code, verifier) }
runOnUiThread {
result
.onSuccess { connected(it) }
@@ -286,7 +286,7 @@ class MainActivity : Activity() {
?: Api.normalize(typedUrl)
?: return@setPositiveButton showError(getString(R.string.enter_address))
thread {
val result = runCatching { Api(url, token).device() }
val result = runCatching { Api(this, url, token).device() }
runOnUiThread {
result
.onSuccess {
@@ -487,6 +487,20 @@ class MainActivity : Activity() {
})
}
}
rows.addView(TextView(this).apply {
setText(R.string.vendor_settings)
setPadding(0, dp(16), 0, 0)
})
rows.addView(Button(this).apply {
setText(R.string.vendor_guide)
setOnClickListener { runCatching { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(vendorGuide()))) } }
})
}
/** dontkillmyapp.com explains the battery settings that vendors add on top of Android. */
private fun vendorGuide(): String {
val vendor = Build.MANUFACTURER.lowercase().replace(' ', '-')
return "https://dontkillmyapp.com/" + if (vendor in GUIDE_VENDORS) vendor else "general"
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, results: IntArray) {
@@ -509,6 +523,12 @@ class MainActivity : Activity() {
private const val REQUEST_OTHER = 2
private const val REQUEST_FIX = 3
/** The vendor pages on dontkillmyapp.com, as `Build.MANUFACTURER` names them. */
private val GUIDE_VENDORS = setOf(
"asus", "blackview", "google", "hmd-global", "htc", "huawei", "lenovo", "meizu", "motorola", "nokia",
"oneplus", "oppo", "realme", "samsung", "sony", "tecno", "ulefone", "unihertz", "vivo", "wiko", "xiaomi",
)
private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) }
}
}
Mandroid/app/src/main/java/org/opentracker/TrackerService.kt
@@ -208,7 +208,7 @@ class TrackerService : Service() {
val batch = outbox.peek(maxUpload)
if (batch.isEmpty()) return
try {
Api(url, token).upload(batch.joinToString(",", "[", "]") { it.second })
Api(this, url, token).upload(batch.joinToString(",", "[", "]") { it.second })
outbox.removeUpTo(batch.last().first)
backoffMs = 0
uploadAtMs = Long.MAX_VALUE
@@ -247,7 +247,7 @@ class TrackerService : Service() {
}
private fun tokenRevoked(url: String, token: String): Boolean = try {
Api(url, token).device()
Api(this, url, token).device()
false
} catch (e: IOException) {
e is HttpError && e.status == 401
Mandroid/app/src/main/res/values-de/strings.xml
@@ -42,6 +42,8 @@
<string name="perm_notifications_loss">Ohne sie ist die Ortungs-Benachrichtigung ausgeblendet.</string>
<string name="perm_background">Im Hintergrund laufen</string>
<string name="perm_background_loss">Ohne sie beenden manche Handys die Ortung, um Akku zu sparen.</string>
<string name="vendor_settings">Viele Handys haben zusätzliche Akku-Einstellungen, die die Ortung beenden. Eine Anleitung zeigt sie für dein Handy.</string>
<string name="vendor_guide">Anleitung öffnen</string>
<string name="close">Schließen</string>
<string name="allowed">Erlaubt</string>
<string name="not_allowed">Nicht erlaubt. %1$s</string>
Mandroid/app/src/main/res/values/strings.xml
@@ -43,6 +43,8 @@
<string name="perm_notifications_loss">Without it the tracking notification is hidden.</string>
<string name="perm_background">Run in the background</string>
<string name="perm_background_loss">Without it some phones stop tracking to save battery.</string>
<string name="vendor_settings">Many phones have extra battery settings that stop tracking. A guide shows them for your phone.</string>
<string name="vendor_guide">Open the guide</string>
<string name="close">Close</string>
<string name="allowed">Allowed</string>
<string name="not_allowed">Not allowed. %1$s</string>
Mdocs/android.md
@@ -36,7 +36,7 @@ BootReceiver: starts TrackerService after a reboot, if tracking was on
- **App ID:** `org.opentracker`, and `org.opentracker.debug` for 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's `org.json`, and the UI uses plain platform views.
- **Dependencies:** only AndroidX Browser, for Custom Tabs. HTTP uses `HttpEngine` or `HttpURLConnection`, JSON uses the platform's `org.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 `LocationManager` and sensors.
## Connecting the app to an account
@@ -129,7 +129,7 @@ The policy is pure Kotlin with no Android types. It gets the time as a parameter
- **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:** `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.
- **Connection:** keep-alive, so a batch reuses the open TLS connection. From Android 14 the platform's `HttpEngine` (Cronet) adds HTTP/2 and HTTP/3. Older versions use `HttpURLConnection`, HTTP/1.1 only.
### Doze and process death