Add the Android app
Background tracking with a location foreground service, a sampling policy that turns GNSS off while the phone is still, GNSS batching with a fallback for chips that break it, a SQLite outbox with batched uploads, a Doze-proof watchdog and a boot receiver. The app pairs with an account through the browser and shows this device's map in a WebView. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Aandroid/.gitignore
@@ -0,0 +1,7 @@
/.gradle
/build
/app/build
/local.properties
/keystore.properties
*.keystore
*.jks
Aandroid/app/build.gradle.kts
@@ -0,0 +1,58 @@
plugins {
// AGP 9 has Kotlin built in.
id("com.android.application") version "9.4.1"
}
android {
namespace = "org.opentracker"
compileSdk = 36
defaultConfig {
applicationId = "org.opentracker"
// Android 12: GNSS batching and the modern LocationRequest.
minSdk = 31
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
// The emulator reaches a dev server on the host as http://10.0.2.2:8080.
manifestPlaceholders["cleartext"] = "true"
}
release {
isMinifyEnabled = false
manifestPlaceholders["cleartext"] = "false"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
lint {
disable += setOf(
// Not translated. The texts live next to the code that uses them.
"SetTextI18n",
// Upgrades are a decision, not a lint finding.
"OldTargetApi",
"GradleDependency",
"AndroidGradlePluginVersion",
"NewerVersionAvailable",
)
warningsAsErrors = true
}
testOptions {
unitTests.isReturnDefaultValues = true
}
}
dependencies {
implementation("androidx.browser:browser:1.10.0")
// Must match the Kotlin version that AGP bundles: ./gradlew :app:buildEnvironment | grep kotlin-gradle-plugin
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:2.2.10")
}
Aandroid/app/src/main/AndroidManifest.xml
@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Asking for FINE without COARSE lets the system dialog silently downgrade to COARSE. -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
<queries>
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
</queries>
<application
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@mipmap/ic_launcher"
android:label="opentracker"
android:supportsRtl="true"
android:theme="@android:style/Theme.DeviceDefault.DayNight"
android:usesCleartextTraffic="${cleartext}">
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="opentracker" android:host="paired" />
</intent-filter>
</activity>
<!-- stopWithTask=false: swiping the app away must not stop tracking. -->
<service
android:name=".TrackerService"
android:exported="false"
android:foregroundServiceType="location"
android:stopWithTask="false" />
<receiver android:name=".BootReceiver" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<receiver android:name=".Watchdog" android:exported="false" />
</application>
</manifest>
Aandroid/app/src/main/java/org/opentracker/Api.kt
@@ -0,0 +1,62 @@
package org.opentracker
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
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) {
/** A JSON array of points, as the server's `Point` type. */
fun upload(points: String) {
call("POST", "/api/points", points)
}
/** Checks the token. */
fun device() {
call("GET", "/api/device", null)
}
fun pair(code: String, verifier: String): String {
val body = JSONObject().put("code", code).put("verifier", verifier).toString()
return JSONObject(call("POST", "/api/devices/pair", body)).getString("token")
}
private fun call(method: String, path: String, body: String?): String {
val conn = URL(baseUrl + path).openConnection() as HttpURLConnection
try {
conn.requestMethod = method
conn.connectTimeout = 15_000
conn.readTimeout = 30_000
token?.let { conn.setRequestProperty("Authorization", "Bearer $it") }
if (body != null) {
conn.doOutput = true
conn.setRequestProperty("Content-Type", "application/json")
conn.outputStream.use { it.write(body.toByteArray()) }
}
val status = conn.responseCode
if (status !in 200..299) {
val msg = conn.errorStream?.bufferedReader()?.use { it.readText() }.orEmpty()
throw HttpError(status, msg.ifEmpty { "HTTP $status" })
}
return conn.inputStream.bufferedReader().use { it.readText() }
} finally {
conn.disconnect()
}
}
companion object {
/** `track.example.com` becomes `https://track.example.com`. Returns null for an unusable address. */
fun normalize(input: String): String? {
var url = input.trim().trimEnd('/')
if (url.isEmpty()) return null
if (!url.contains("://")) url = "https://$url"
val parsed = runCatching { URL(url) }.getOrNull() ?: return null
if (parsed.protocol !in listOf("http", "https") || parsed.host.isEmpty()) return null
return url
}
}
}
Aandroid/app/src/main/java/org/opentracker/BootReceiver.kt
@@ -0,0 +1,16 @@
package org.opentracker
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/** Restarts tracking after a reboot or an app update, if it was on. */
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED && intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return
val prefs = Prefs(context)
if (!prefs.tracking || prefs.token == null) return
// Without "Allow all the time" location, Android refuses to start the service from here.
runCatching { TrackerService.start(context) }
}
}
Aandroid/app/src/main/java/org/opentracker/LocationSource.kt
@@ -0,0 +1,67 @@
package org.opentracker
import android.annotation.SuppressLint
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.location.LocationRequest
import java.util.concurrent.Executor
/**
* Plain LocationManager, so the app works without Google Play Services.
* It passes on every fix. [SamplingPolicy] decides what to keep.
*/
class LocationSource(
private val lm: LocationManager,
private val executor: Executor,
private val onFixes: (List<Location>) -> Unit,
) {
private val listener = object : LocationListener {
override fun onLocationChanged(location: Location) = onFixes(listOf(location))
override fun onLocationChanged(locations: MutableList<Location>) = onFixes(locations)
override fun onProviderEnabled(provider: String) = Unit
override fun onProviderDisabled(provider: String) = Unit
}
private var current: Request? = null
/** Some GNSS chips, the emulator's among them, accept a batching request and then deliver nothing. */
var batching = true
set(value) {
field = value
current?.let {
current = null
apply(it)
}
}
/** LocationManager cannot change a running request, so a new one replaces it. Returns whether it changed. */
@SuppressLint("MissingPermission")
fun apply(req: Request): Boolean {
if (req == current) return false
stop()
current = req
val quality = if (req.gnss) LocationRequest.QUALITY_HIGH_ACCURACY else LocationRequest.QUALITY_LOW_POWER
// No minimum distance: the policy needs fixes from a still phone to notice that it stopped.
val lr = LocationRequest.Builder(req.intervalMs)
.setMaxUpdateDelayMillis(if (batching) req.maxDelayMs else 0)
.setQuality(quality)
.build()
val providers = lm.allProviders
val wanted = buildList {
if (req.gnss) add(LocationManager.GPS_PROVIDER)
if (LocationManager.NETWORK_PROVIDER in providers) add(LocationManager.NETWORK_PROVIDER)
// Phones without Google services often have no network provider. GNSS then has to cover it.
else if (!req.gnss) add(LocationManager.GPS_PROVIDER)
}
for (p in wanted.filter { it in providers }) lm.requestLocationUpdates(p, lr, executor, listener)
return true
}
fun stop() {
lm.removeUpdates(listener)
current = null
}
}
Aandroid/app/src/main/java/org/opentracker/MainActivity.kt
@@ -0,0 +1,393 @@
package org.opentracker
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.app.AlertDialog
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import android.text.InputType
import android.text.format.DateFormat
import android.util.TypedValue
import android.view.Gravity
import android.view.View
import android.view.WindowInsets
import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Button
import android.widget.EditText
import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.PopupMenu
import android.widget.ScrollView
import android.widget.Switch
import android.widget.TextView
import androidx.browser.customtabs.CustomTabsIntent
import java.security.MessageDigest
import java.security.SecureRandom
import kotlin.concurrent.thread
/** Shows the setup screen until the app has a device token, then this device's map and the tracking switch. */
class MainActivity : Activity() {
private lateinit var prefs: Prefs
private var webView: WebView? = null
private var status: TextView? = null
private var switch: Switch? = null
private var setupError: TextView? = null
/** The permission steps are running. The switch stays on meanwhile, although tracking has not started yet. */
private var starting = false
private val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == "token") render() else showStatus()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Our own top bar handles the edge-to-edge insets. The platform action bar would cover the content.
actionBar?.hide()
prefs = Prefs(this)
render()
handle(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handle(intent)
}
override fun onResume() {
super.onResume()
prefs.raw.registerOnSharedPreferenceChangeListener(listener)
webView?.onResume()
showStatus()
// Someone is looking, so waiting points go out now.
if (prefs.tracking && prefs.token != null) runCatching { TrackerService.start(this, TrackerService.ACTION_UPLOAD) }
}
override fun onPause() {
prefs.raw.unregisterOnSharedPreferenceChangeListener(listener)
webView?.onPause()
super.onPause()
}
private fun render() {
webView?.destroy()
webView = null
val content = if (prefs.token == null) setupView() else mainView()
content.setOnApplyWindowInsetsListener { v, insets ->
val bars = insets.getInsets(WindowInsets.Type.systemBars() or WindowInsets.Type.ime())
v.setPadding(bars.left, bars.top, bars.right, bars.bottom)
insets
}
setContentView(content)
}
private fun dp(value: Int) =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value.toFloat(), resources.displayMetrics).toInt()
private fun setupView(): View {
val url = EditText(this).apply {
hint = "https://track.example.com"
inputType = InputType.TYPE_TEXT_VARIATION_URI
setText(prefs.url ?: "")
}
val error = TextView(this).apply {
setTextColor(0xffdc2626.toInt())
text = prefs.error
}
setupError = error
val column = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(24), dp(24), dp(24), dp(24))
addView(TextView(this@MainActivity).apply {
text = "Connect to your server"
setTextSize(TypedValue.COMPLEX_UNIT_SP, 22f)
})
addView(TextView(this@MainActivity).apply {
text = "Enter the address of your opentracker server. You then sign in in the browser and confirm this phone."
setPadding(0, dp(8), 0, dp(16))
})
addView(url)
addView(Button(this@MainActivity).apply {
text = "Connect in browser"
setOnClickListener { pair(url.text.toString()) }
})
addView(Button(this@MainActivity).apply {
text = "Paste a device token"
setOnClickListener { pasteToken(url.text.toString()) }
})
addView(error)
}
return ScrollView(this).apply { addView(column) }
}
@SuppressLint("SetJavaScriptEnabled")
private fun mainView(): View {
val url = prefs.url!!
val bar = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(dp(16), dp(4), dp(8), dp(4))
}
val statusView = TextView(this)
val toggle = Switch(this).apply {
text = "Tracking"
// A click, not a checked-change listener: showStatus also sets the switch, and that must not start or stop anything.
setOnClickListener { if (isChecked) startTracking() else stopTracking() }
}
val more = ImageButton(this).apply {
setImageResource(R.drawable.ic_more)
background = null
contentDescription = "More"
setOnClickListener { showMenu(it) }
}
bar.addView(statusView, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
bar.addView(toggle)
bar.addView(more)
status = statusView
switch = toggle
val web = WebView(this).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
// The page reads the token from here. Navigation stays on the server, so no other page can ask.
addJavascriptInterface(Bridge(), "OtApp")
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
val target = request.url
val server = Uri.parse(url)
if (target.scheme == server.scheme && target.authority == server.authority) return false
runCatching { startActivity(Intent(Intent.ACTION_VIEW, target)) }
return true
}
}
loadUrl("$url/#device")
}
webView = web
showStatus()
return LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
addView(bar)
addView(web, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f))
}
}
private inner class Bridge {
@JavascriptInterface
fun token(): String? = prefs.token
}
private fun showStatus() {
switch?.isChecked = prefs.tracking || starting
setupError?.text = prefs.error
val view = status ?: return
val last = prefs.lastUploadMs
view.text = when {
!prefs.tracking -> "Tracking is off"
else -> listOfNotNull(
prefs.mode,
if (last > 0) "sent ${DateFormat.getTimeFormat(this).format(last)}" else "nothing sent yet",
prefs.queued.takeIf { it > 0 }?.let { "$it waiting" },
prefs.error,
).joinToString(" · ")
}
}
private fun showMenu(anchor: View) {
val menu = PopupMenu(this, anchor)
menu.menu.add("Web UI").setOnMenuItemClickListener {
if (!openInBrowser("${prefs.url}/")) showError("No browser found.")
true
}
menu.menu.add("Disconnect").setOnMenuItemClickListener {
AlertDialog.Builder(this)
.setTitle("Disconnect this phone?")
.setMessage("Tracking stops. The phone stays in Settings → Devices until you remove it there.")
.setPositiveButton("Disconnect") { _, _ ->
stopTracking()
prefs.token = null
}
.setNegativeButton("Cancel", null)
.show()
true
}
menu.show()
}
private fun openInBrowser(url: String): Boolean = try {
CustomTabsIntent.Builder().build().launchUrl(this, Uri.parse(url))
true
} catch (e: ActivityNotFoundException) {
false
}
// Pairing: the browser creates a one-time code for the hash of a secret that only this app knows.
private fun pair(input: String) {
val url = Api.normalize(input) ?: return showError("Enter the server address.")
val bytes = ByteArray(32).also { SecureRandom().nextBytes(it) }
val verifier = bytes.toHex()
prefs.url = url
prefs.pairVerifier = verifier
prefs.error = null
val challenge = MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray()).toHex()
val name = Uri.encode(Settings.Global.getString(contentResolver, Settings.Global.DEVICE_NAME) ?: Build.MODEL)
if (!openInBrowser("$url/#pair?challenge=$challenge&name=$name")) {
showError("No browser found. Paste a device token instead.")
}
}
private fun handle(intent: Intent?) {
val data = intent?.data ?: return
if (data.scheme != "opentracker" || data.host != "paired") return
setIntent(Intent())
val code = data.getQueryParameter("code") ?: return
val url = prefs.url
val verifier = prefs.pairVerifier
if (url == null || verifier == null) return showError("Start connecting in the app first.")
thread {
val result = runCatching { Api(url).pair(code, verifier) }
runOnUiThread {
result
.onSuccess { connected(it) }
.onFailure { showError("Connecting failed. Start again. (${it.message})") }
}
}
}
/** Accepts the `ot use-token <url> <token>` line from the web UI, or only the token. */
private fun pasteToken(typedUrl: String) {
val input = EditText(this).apply { hint = "ot use-token https://… token" }
AlertDialog.Builder(this)
.setTitle("Device token")
.setMessage("Create a token in the web UI under Settings → Devices.")
.setView(input)
.setPositiveButton("Connect") { _, _ ->
val words = input.text.toString().trim().split(Regex("\\s+"))
val token = words.lastOrNull { it.matches(Regex("[0-9a-f]{64}")) }
?: return@setPositiveButton showError("That is not a device token.")
val url = words.firstNotNullOfOrNull { w -> w.takeIf { it.contains("://") }?.let(Api::normalize) }
?: Api.normalize(typedUrl)
?: return@setPositiveButton showError("Enter the server address.")
thread {
val result = runCatching { Api(url, token).device() }
runOnUiThread {
result
.onSuccess {
prefs.url = url
connected(token)
}
.onFailure { showError("The server did not accept the token. (${it.message})") }
}
}
}
.setNegativeButton("Cancel", null)
.show()
}
private fun connected(token: String) {
prefs.pairVerifier = null
prefs.error = null
prefs.token = token
render()
}
private fun showError(message: String) {
prefs.error = message
setupError?.text = message
}
// Tracking: each permission is asked once, with an explanation first. Only foreground location is required.
private fun startTracking() {
starting = true
if (!granted(Manifest.permission.ACCESS_FINE_LOCATION)) {
requestPermissions(
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION),
REQUEST_LOCATION,
)
return
}
val notify = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
if (notify && !granted(Manifest.permission.POST_NOTIFICATIONS) && once("notifications")) {
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQUEST_OTHER)
return
}
if (!granted(Manifest.permission.ACCESS_BACKGROUND_LOCATION) && once("background")) {
explain(
"Allow location all the time",
"Choose \"Allow all the time\" on the next screen. Then tracking can restart by itself after the phone reboots.",
) { requestPermissions(arrayOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION), REQUEST_OTHER) }
return
}
val power = getSystemService(PowerManager::class.java)
if (!power.isIgnoringBatteryOptimizations(packageName) && once("battery")) {
explain(
"Keep tracking running",
"Some phones stop background apps to save battery. Allow opentracker to run in the background, so tracking does not stop.",
) {
@SuppressLint("BatteryLife")
val request = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, Uri.parse("package:$packageName"))
runCatching { startActivity(request) }
startTracking()
}
return
}
starting = false
prefs.tracking = true
prefs.error = null
TrackerService.start(this)
showStatus()
}
private fun stopTracking() {
starting = false
prefs.tracking = false
stopService(Intent(this, TrackerService::class.java))
showStatus()
}
private fun granted(permission: String) = checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
/** True the first time only. */
private fun once(step: String): Boolean {
if (prefs.asked(step)) return false
prefs.markAsked(step)
return true
}
private fun explain(title: String, message: String, next: () -> Unit) {
AlertDialog.Builder(this)
.setTitle(title)
.setMessage(message)
.setPositiveButton("Continue") { _, _ -> next() }
.setNegativeButton("Skip") { _, _ -> startTracking() }
.setCancelable(false)
.show()
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, results: IntArray) {
if (requestCode == REQUEST_LOCATION && !granted(Manifest.permission.ACCESS_FINE_LOCATION)) {
prefs.error = "Tracking needs the location permission."
starting = false
showStatus()
return
}
startTracking()
}
companion object {
private const val REQUEST_LOCATION = 1
private const val REQUEST_OTHER = 2
private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) }
}
}
Aandroid/app/src/main/java/org/opentracker/MotionDetector.kt
@@ -0,0 +1,30 @@
package org.opentracker
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorManager
import android.hardware.TriggerEvent
import android.hardware.TriggerEventListener
/**
* The significant-motion sensor. It runs in the sensor hub, not on the CPU, which makes
* turning GNSS off in STATIONARY safe. The callback comes on a sensor thread.
*/
class MotionDetector(context: Context, private val onMotion: () -> Unit) {
private val sm = context.getSystemService(SensorManager::class.java)
private val sensor: Sensor? = sm.getDefaultSensor(Sensor.TYPE_SIGNIFICANT_MOTION)
private val listener = object : TriggerEventListener() {
override fun onTrigger(event: TriggerEvent?) = onMotion()
}
/** The sensor is one-shot: it cancels itself when it fires, so arm it again after each trigger. */
fun arm() {
sensor ?: return
sm.cancelTriggerSensor(listener, sensor)
sm.requestTriggerSensor(listener, sensor)
}
fun disarm() {
sensor?.let { sm.cancelTriggerSensor(listener, it) }
}
}
Aandroid/app/src/main/java/org/opentracker/Outbox.kt
@@ -0,0 +1,47 @@
package org.opentracker
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
/**
* Points waiting for upload. It survives process death.
* The server ignores a point it already has, so resending after a lost reply is safe.
*/
class Outbox(context: Context) : SQLiteOpenHelper(context, "outbox.db", null, 1) {
override fun onCreate(db: SQLiteDatabase) {
db.execSQL("CREATE TABLE points (ts INTEGER PRIMARY KEY, json TEXT NOT NULL)")
}
override fun onUpgrade(db: SQLiteDatabase, old: Int, new: Int) = Unit
/** Drops the oldest points beyond [MAX], so a long offline time cannot fill the disk. */
fun add(ts: Long, json: String) {
val db = writableDatabase
db.execSQL("INSERT OR REPLACE INTO points (ts, json) VALUES (?, ?)", arrayOf(ts, json))
db.execSQL("DELETE FROM points WHERE ts < (SELECT ts FROM points ORDER BY ts DESC LIMIT 1 OFFSET $MAX)")
}
/** The oldest points, as (ts, json). */
fun peek(limit: Int): List<Pair<Long, String>> =
readableDatabase.rawQuery("SELECT ts, json FROM points ORDER BY ts LIMIT $limit", null).use { c ->
buildList { while (c.moveToNext()) add(c.getLong(0) to c.getString(1)) }
}
fun removeUpTo(ts: Long) {
writableDatabase.execSQL("DELETE FROM points WHERE ts <= ?", arrayOf(ts))
}
fun count(): Long = readableDatabase.compileStatement("SELECT COUNT(*) FROM points").simpleQueryForLong()
/** Unix seconds of the oldest waiting point, or null. */
fun oldest(): Long? =
readableDatabase.rawQuery("SELECT MIN(ts) FROM points", null).use { c ->
if (c.moveToFirst() && !c.isNull(0)) c.getLong(0) else null
}
companion object {
const val MAX = 50_000
}
}
Aandroid/app/src/main/java/org/opentracker/Prefs.kt
@@ -0,0 +1,46 @@
package org.opentracker
import android.content.Context
import android.content.SharedPreferences
/** 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 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 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()
fun asked(step: String): Boolean = raw.getBoolean("asked $step", false)
fun markAsked(step: String) = raw.edit().putBoolean("asked $step", true).apply()
}
Aandroid/app/src/main/java/org/opentracker/SamplingPolicy.kt
@@ -0,0 +1,160 @@
package org.opentracker
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.sqrt
/** A position, without Android types, so the policy runs in JVM tests. */
data class Fix(
val timeMs: Long,
val lat: Double,
val lon: Double,
val accuracy: Float? = null,
/** m/s, from GNSS. Network fixes have none. */
val speed: Float? = null,
)
enum class Mode(val label: String) {
VEHICLE("Driving"),
WALK("Moving"),
DWELL("Stopped"),
STATIONARY("Still"),
}
/** What to ask LocationManager for. */
data class Request(val gnss: Boolean, val intervalMs: Long, val maxDelayMs: Long)
/**
* Spends power in proportion to how fast the position changes.
*
* A faster mode starts with the first fix that shows the speed, so the start of a trip is not lost.
* A slower mode waits: DWELL bridges red lights and queues, and only then STATIONARY turns GNSS off.
* In STATIONARY, the significant-motion sensor ([wake]) or a network fix far away brings GNSS back.
*
* Not thread-safe. The tracker thread owns it.
*/
class SamplingPolicy {
var mode = Mode.WALK
private set
private var lastKept: Fix? = null
private var lastFix: Fix? = null
private var stillSinceMs = 0L
private var slowSinceMs: Long? = null
/** Updates the mode and returns whether the fix is worth uploading. */
fun onFix(fix: Fix): Boolean {
tick(fix.timeMs)
updateMode(fix, speedOf(fix))
lastFix = fix
return keep(fix)
}
/** Time-based transitions. Call it on every fix and from the watchdog. */
fun tick(nowMs: Long) {
if (mode == Mode.DWELL && nowMs - stillSinceMs >= DWELL_MS) mode = Mode.STATIONARY
}
/** The significant-motion sensor fired. */
fun wake(nowMs: Long) {
if (mode == Mode.STATIONARY || mode == Mode.DWELL) {
mode = Mode.WALK
stillSinceMs = nowMs
}
}
fun request(lowBattery: Boolean, charging: Boolean): Request {
val walk = Request(gnss = true, intervalMs = 30_000, maxDelayMs = 120_000)
val base = when (mode) {
Mode.VEHICLE -> Request(gnss = true, intervalMs = 5_000, maxDelayMs = 60_000)
Mode.WALK, Mode.DWELL -> walk
Mode.STATIONARY -> if (charging) walk else Request(gnss = false, intervalMs = HEARTBEAT_MS, maxDelayMs = 0)
}
return if (lowBattery && !charging) {
base.copy(intervalMs = base.intervalMs * 2, maxDelayMs = base.maxDelayMs * 2)
} else {
base
}
}
private fun updateMode(fix: Fix, speed: Float) {
val now = fix.timeMs
when {
speed >= VEHICLE_SPEED -> {
mode = Mode.VEHICLE
slowSinceMs = null
}
speed >= WALK_SPEED -> when (mode) {
Mode.VEHICLE -> {
val since = slowSinceMs ?: now.also { slowSinceMs = it }
if (now - since >= SLOWDOWN_MS) mode = Mode.WALK
}
else -> mode = Mode.WALK
}
mode == Mode.STATIONARY -> {
val last = lastKept
val accurate = (fix.accuracy ?: Float.MAX_VALUE) < DEPARTURE_M
if (last != null && accurate && distance(last, fix) > DEPARTURE_M) {
mode = Mode.WALK
stillSinceMs = now
}
}
mode != Mode.DWELL -> {
mode = Mode.DWELL
stillSinceMs = now
slowSinceMs = null
}
}
}
/** The higher of the GNSS speed and a cautious estimate from the previous fix. Some chips report 0 while moving. */
private fun speedOf(fix: Fix): Float = max(fix.speed ?: 0f, estimatedSpeed(fix))
private fun estimatedSpeed(fix: Fix): Float {
val prev = lastFix ?: return 0f
val dt = (fix.timeMs - prev.timeMs) / 1000.0
val acc = (fix.accuracy ?: return 0f) + (prev.accuracy ?: return 0f)
if (dt < 10 || acc > 2 * MAX_ACCURACY) return 0f
// Subtracting both accuracies keeps position noise from looking like movement.
return (max(0.0, distance(prev, fix) - acc) / dt).toFloat()
}
private fun keep(fix: Fix): Boolean {
val last = lastKept
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
mode == Mode.STATIONARY -> distance(last, fix) > DEPARTURE_M
mode == Mode.VEHICLE -> distance(last, fix) >= 20
else -> distance(last, fix) >= 10
}
if (ok) lastKept = fix
return ok
}
companion object {
/** About 25 km/h. */
const val VEHICLE_SPEED = 7f
/** GNSS speed noise on a still phone stays below this. */
const val WALK_SPEED = 0.8f
const val DWELL_MS = 5 * 60_000L
const val SLOWDOWN_MS = 2 * 60_000L
const val HEARTBEAT_MS = 15 * 60_000L
const val STALE_MS = 10 * 60_000L
const val MAX_ACCURACY = 50f
const val DEPARTURE_M = 200.0
/** Metres. Equirectangular, which is exact enough at these distances. */
fun distance(a: Fix, b: Fix): Double {
val k = 111_320.0
val dy = (b.lat - a.lat) * k
val dx = (b.lon - a.lon) * k * cos(Math.toRadians((a.lat + b.lat) / 2))
return sqrt(dx * dx + dy * dy)
}
}
}
Aandroid/app/src/main/java/org/opentracker/TrackerService.kt
@@ -0,0 +1,286 @@
package org.opentracker
import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.location.Location
import android.location.LocationManager
import android.net.ConnectivityManager
import android.net.Network
import android.os.BatteryManager
import android.os.Handler
import android.os.HandlerThread
import android.text.format.DateFormat
import android.util.Log
import java.io.IOException
import org.json.JSONObject
/**
* The location foreground service. One thread owns all tracking state:
*
* ```text
* LocationSource -> SamplingPolicy -> Outbox -> Api
* ```
*/
class TrackerService : Service() {
private lateinit var prefs: Prefs
private lateinit var thread: HandlerThread
private lateinit var handler: Handler
private lateinit var outbox: Outbox
private lateinit var source: LocationSource
private lateinit var motion: MotionDetector
private val policy = SamplingPolicy()
private var started = false
private var online = true
private var backoffMs = 0L
private var uploadAtMs = Long.MAX_VALUE
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
private val upload = Runnable { upload() }
private val batchCheck = Runnable { checkBatching(System.currentTimeMillis()) }
private val network = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(n: Network) {
handler.post {
online = true
backoffMs = 0
schedule(urgent = true)
}
}
override fun onLost(n: Network) {
handler.post { online = false }
}
}
override fun onBind(intent: Intent?) = null
override fun onCreate() {
prefs = Prefs(this)
thread = HandlerThread("tracker").apply { start() }
handler = Handler(thread.looper)
outbox = Outbox(this)
source = LocationSource(getSystemService(LocationManager::class.java), handler::post, ::onFixes)
motion = MotionDetector(this) { handler.post(::onMotion) }
getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(CHANNEL, "Tracking", NotificationManager.IMPORTANCE_LOW),
)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// From Android 14, starting a location service without the permission throws.
val granted = checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
if (!prefs.tracking || prefs.token == null || !granted) {
stopSelf()
return START_NOT_STICKY
}
startForeground(NOTIFICATION, notification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
Watchdog.arm(this)
val action = intent?.action
handler.post {
if (!started) {
started = true
begin()
}
when (action) {
ACTION_UPLOAD -> schedule(urgent = true)
ACTION_TICK -> {
checkBatching(System.currentTimeMillis())
policy.tick(System.currentTimeMillis())
afterChange(urgent = false)
// Handler delays stop in deep sleep, so a due upload may still be waiting.
if (System.currentTimeMillis() >= uploadAtMs) upload()
}
}
}
return START_STICKY
}
private fun begin() {
getSystemService(ConnectivityManager::class.java).registerDefaultNetworkCallback(network, handler)
afterChange(urgent = true)
}
override fun onDestroy() {
handler.post {
source.stop()
motion.disarm()
runCatching { getSystemService(ConnectivityManager::class.java).unregisterNetworkCallback(network) }
outbox.close()
thread.quitSafely()
}
// A low-memory kill also ends here. Then the watchdog has to stay armed to bring tracking back.
if (!prefs.tracking) Watchdog.cancel(this)
}
private fun onFixes(locations: List<Location>) {
lastFixMs = System.currentTimeMillis()
if (batchWindowMs > 0) {
handler.removeCallbacks(batchCheck)
handler.postDelayed(batchCheck, batchWindowMs)
}
val battery = getSystemService(BatteryManager::class.java).getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
val before = policy.mode
var kept = 0
for (l in locations) {
val fix = Fix(l.time, l.latitude, l.longitude, l.accuracyOrNull(), if (l.hasSpeed()) l.speed else null)
if (policy.onFix(fix)) {
outbox.add(l.time / 1000, json(l, battery))
kept++
}
}
Log.d(TAG, "${locations.size} fixes, $kept kept, ${policy.mode}")
afterChange(urgent = policy.mode != before)
}
private fun onMotion() {
policy.wake(System.currentTimeMillis())
afterChange(urgent = false)
}
/** Applies the policy's request and plans the next upload. */
private fun afterChange(urgent: Boolean) {
val bm = getSystemService(BatteryManager::class.java)
val low = bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) < LOW_BATTERY
val req = policy.request(lowBattery = low, charging = bm.isCharging)
if (source.apply(req)) {
batchWindowMs = if (req.gnss && req.maxDelayMs > 0 && source.batching) 2 * req.maxDelayMs + req.intervalMs else 0
handler.removeCallbacks(batchCheck)
if (batchWindowMs > 0) {
lastFixMs = System.currentTimeMillis()
handler.postDelayed(batchCheck, batchWindowMs)
}
}
if (policy.mode == Mode.STATIONARY) motion.arm() else motion.disarm()
if (prefs.mode != policy.mode.label) {
prefs.mode = policy.mode.label
updateNotification()
}
// A heartbeat point goes alone: one point is one radio wakeup either way.
schedule(urgent = urgent || policy.mode == Mode.STATIONARY)
}
/** Indoors, GNSS also stays silent. Then this turns batching off needlessly, but only until the service restarts. */
private fun checkBatching(now: Long) {
if (batchWindowMs > 0 && now - lastFixMs >= batchWindowMs) {
batchWindowMs = 0
Log.w(TAG, "no fixes with batching, turning it off")
source.batching = false
}
}
private fun schedule(urgent: Boolean) {
val count = outbox.count()
prefs.queued = count
if (count == 0L || !online) return
val now = System.currentTimeMillis()
val due = when {
backoffMs > 0 -> uploadAtMs
urgent || count >= BATCH -> now
else -> (outbox.oldest() ?: return) * 1000 + MAX_WAIT_MS
}
uploadAtMs = due
handler.removeCallbacks(upload)
handler.postDelayed(upload, (due - now).coerceAtLeast(0))
}
private fun upload() {
handler.removeCallbacks(upload)
val url = prefs.url
val token = prefs.token
if (url == null || token == null) return
val batch = outbox.peek(MAX_UPLOAD)
if (batch.isEmpty()) return
try {
Api(url, token).upload(batch.joinToString(",", "[", "]") { it.second })
outbox.removeUpTo(batch.last().first)
backoffMs = 0
uploadAtMs = Long.MAX_VALUE
prefs.lastUploadMs = System.currentTimeMillis()
prefs.error = null
updateNotification()
if (outbox.count() > 0) handler.post(upload) else prefs.queued = 0
} catch (e: HttpError) {
if (e.status == 401) return removed()
retry("The server refused the upload: ${e.message}")
} catch (e: IOException) {
retry("No connection to the server.")
}
}
private fun retry(error: String) {
prefs.error = error
backoffMs = (backoffMs * 2).coerceIn(MIN_BACKOFF_MS, MAX_BACKOFF_MS)
uploadAtMs = System.currentTimeMillis() + backoffMs
handler.postDelayed(upload, backoffMs)
prefs.queued = outbox.count()
}
/** The device was deleted on the server. Its token is useless now. */
private fun removed() {
prefs.token = null
prefs.tracking = false
prefs.error = "This phone was removed from your account. Connect it again."
stopSelf()
}
private fun updateNotification() {
getSystemService(NotificationManager::class.java).notify(NOTIFICATION, notification())
}
private fun notification(): Notification {
val open = PendingIntent.getActivity(
this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE,
)
val last = prefs.lastUploadMs
val text = listOfNotNull(
prefs.mode,
if (last > 0) "last upload ${DateFormat.getTimeFormat(this).format(last)}" else null,
).joinToString(" · ")
return Notification.Builder(this, CHANNEL)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("Sharing your location")
.setContentText(text)
.setContentIntent(open)
.setOngoing(true)
.build()
}
companion object {
const val ACTION_UPLOAD = "upload"
const val ACTION_TICK = "tick"
private const val TAG = "opentracker"
private const val CHANNEL = "tracking"
private const val NOTIFICATION = 1
private const val BATCH = 20
private const val MAX_UPLOAD = 1000
private const val MAX_WAIT_MS = 2 * 60_000L
private const val MIN_BACKOFF_MS = 30_000L
private const val MAX_BACKOFF_MS = 15 * 60_000L
private const val LOW_BATTERY = 15
fun start(context: Context, action: String? = null) {
context.startForegroundService(Intent(context, TrackerService::class.java).setAction(action))
}
private fun Location.accuracyOrNull() = if (hasAccuracy()) accuracy else null
private fun json(l: Location, battery: Int): String = JSONObject().apply {
put("ts", l.time / 1000)
put("lat", l.latitude)
put("lon", l.longitude)
if (l.hasAccuracy()) put("acc", l.accuracy.toDouble())
if (l.hasAltitude()) put("alt", l.altitude)
if (l.hasSpeed()) put("speed", l.speed.toDouble())
if (l.hasBearing()) put("bearing", l.bearing.toDouble())
if (battery in 0..100) put("battery", battery)
}.toString()
}
}
Aandroid/app/src/main/java/org/opentracker/Watchdog.kt
@@ -0,0 +1,45 @@
package org.opentracker
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.SystemClock
/**
* Wakes the tracker every 15 minutes, also in Doze. Handler delays stop counting in deep sleep; this alarm does not.
* Inexact on purpose: it needs no exact-alarm permission.
*/
class Watchdog : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (!Prefs(context).tracking) return
arm(context)
// Allowed while the service runs in the foreground. If the process died, START_STICKY restarts the service instead.
runCatching {
context.startService(Intent(context, TrackerService::class.java).setAction(TrackerService.ACTION_TICK))
}
}
companion object {
private const val INTERVAL_MS = 15 * 60_000L
/** One-shot and armed again on each run: a repeating alarm does not fire in Doze. */
fun arm(context: Context) {
context.getSystemService(AlarmManager::class.java).setAndAllowWhileIdle(
AlarmManager.ELAPSED_REALTIME_WAKEUP,
SystemClock.elapsedRealtime() + INTERVAL_MS,
pending(context),
)
}
fun cancel(context: Context) {
context.getSystemService(AlarmManager::class.java).cancel(pending(context))
}
private fun pending(context: Context) = PendingIntent.getBroadcast(
context, 0, Intent(context, Watchdog::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
}
Aandroid/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group android:scaleX="2" android:scaleY="2" android:translateX="22" android:translateY="22">
<path android:fillColor="#38bdf8" android:pathData="M22 21.5L16.1 12.27A7 7 0 1 1 27.9 12.27Z" />
<!-- The white outline stands in for the SVG's mask, which vector drawables do not support. -->
<path
android:fillColor="#2563eb"
android:strokeColor="#ffffff"
android:strokeWidth="3.5"
android:strokeLineJoin="round"
android:pathData="M12.5 30L4.78 17.13A9 9 0 1 1 20.22 17.13Z" />
<path android:fillColor="#2563eb" android:pathData="M12.5 30L4.78 17.13A9 9 0 1 1 20.22 17.13Z" />
<path android:fillColor="#ffffff" android:pathData="M8.9 12.5a3.6 3.6 0 1 0 7.2 0a3.6 3.6 0 1 0 -7.2 0" />
</group>
</vector>
Aandroid/app/src/main/res/drawable/ic_more.xml
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?android:attr/textColorPrimary"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#ffffff"
android:pathData="M12 8a2 2 0 1 0 0-4a2 2 0 1 0 0 4zM12 14a2 2 0 1 0 0-4a2 2 0 1 0 0 4zM12 20a2 2 0 1 0 0-4a2 2 0 1 0 0 4z" />
</vector>
Aandroid/app/src/main/res/drawable/ic_notification.xml
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="32"
android:viewportHeight="32">
<path
android:fillColor="#ffffff"
android:fillType="evenOdd"
android:pathData="M16 30L8.28 17.13A9 9 0 1 1 23.72 17.13Z M12.4 12.5a3.6 3.6 0 1 0 7.2 0a3.6 3.6 0 1 0 -7.2 0" />
</vector>
Aandroid/app/src/main/res/mipmap-anydpi/ic_launcher.xml
@@ -0,0 +1,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@android:color/white" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_notification" />
</adaptive-icon>
Aandroid/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Nothing leaves the phone: a copy of the token on another phone would clone this device. -->
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</cloud-backup>
<device-transfer>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</device-transfer>
</data-extraction-rules>
Aandroid/app/src/test/java/org/opentracker/SamplingPolicyTest.kt
@@ -0,0 +1,105 @@
package org.opentracker
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class SamplingPolicyTest {
private val min = 60_000L
/** About 1 m north of the start per unit of [north]. */
private fun fix(t: Long, north: Double = 0.0, speed: Float? = 0f, acc: Float = 5f) =
Fix(t, 48.0 + north / 111_320.0, 11.0, acc, speed)
@Test
fun speeding_up_is_immediate() {
val p = SamplingPolicy()
p.onFix(fix(0))
p.onFix(fix(5_000, 80.0, speed = 16f))
assertEquals(Mode.VEHICLE, p.mode)
assertEquals(5_000, p.request(lowBattery = false, charging = false).intervalMs)
}
@Test
fun slowing_from_a_vehicle_waits() {
val p = SamplingPolicy()
p.onFix(fix(0, speed = 16f))
p.onFix(fix(min, 100.0, speed = 1.5f))
assertEquals(Mode.VEHICLE, p.mode)
p.onFix(fix(3 * min, 300.0, speed = 1.5f))
assertEquals(Mode.WALK, p.mode)
}
@Test
fun stopping_turns_gnss_off_after_dwelling() {
val p = SamplingPolicy()
p.onFix(fix(0, speed = 1.5f))
p.onFix(fix(min, 60.0, speed = 1.5f))
p.onFix(fix(2 * min, 61.0, speed = 0.1f))
assertEquals(Mode.DWELL, p.mode)
p.tick(5 * min)
assertEquals(Mode.DWELL, p.mode)
p.tick(7 * min + 1)
assertEquals(Mode.STATIONARY, p.mode)
assertFalse(p.request(lowBattery = false, charging = false).gnss)
assertTrue(p.request(lowBattery = false, charging = true).gnss)
}
@Test
fun stationary_keeps_a_heartbeat_and_notices_departure() {
val p = SamplingPolicy()
assertTrue(p.onFix(fix(0, speed = 0f)))
p.tick(6 * min)
assertEquals(Mode.STATIONARY, p.mode)
// Network fixes wander by tens of metres without the phone moving.
assertFalse(p.onFix(fix(7 * min, 50.0, speed = null, acc = 30f)))
assertTrue(p.onFix(fix(16 * min, 40.0, speed = null, acc = 30f)))
assertEquals(Mode.STATIONARY, p.mode)
assertTrue(p.onFix(fix(31 * min, 600.0, speed = null, acc = 30f)))
assertEquals(Mode.WALK, p.mode)
}
@Test
fun significant_motion_wakes_gnss() {
val p = SamplingPolicy()
p.onFix(fix(0))
p.tick(6 * min)
p.wake(7 * min)
assertEquals(Mode.WALK, p.mode)
assertTrue(p.request(lowBattery = false, charging = false).gnss)
}
@Test
fun vague_fixes_wait_for_a_long_gap() {
val p = SamplingPolicy()
assertTrue(p.onFix(fix(0, speed = 1.5f)))
assertFalse(p.onFix(fix(min, 100.0, speed = 1.5f, acc = 300f)))
assertTrue(p.onFix(fix(11 * min, 200.0, speed = 1.5f, acc = 300f)))
}
@Test
fun small_steps_are_not_uploaded() {
val p = SamplingPolicy()
assertTrue(p.onFix(fix(0, speed = 1.5f)))
assertFalse(p.onFix(fix(30_000, 4.0, speed = 1.5f)))
assertTrue(p.onFix(fix(60_000, 40.0, speed = 1.5f)))
}
@Test
fun movement_counts_even_if_gnss_reports_no_speed() {
val p = SamplingPolicy()
p.onFix(fix(0, speed = 0f))
p.onFix(fix(30_000, 300.0, speed = 0f))
assertEquals(Mode.VEHICLE, p.mode)
}
@Test
fun low_battery_halves_the_rate() {
val p = SamplingPolicy()
val normal = p.request(lowBattery = false, charging = false)
val low = p.request(lowBattery = true, charging = false)
assertEquals(normal.intervalMs * 2, low.intervalMs)
assertEquals(normal, p.request(lowBattery = true, charging = true))
}
}
Aandroid/gradle.properties
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
org.gradle.caching=true
org.gradle.configuration-cache=true
android.useAndroidX=true
Aandroid/gradle/wrapper/gradle-wrapper.jar
Binary file — not shown
Aandroid/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Aandroid/gradlew
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Aandroid/gradlew.bat
@@ -0,0 +1,93 @@
@rem^M
@rem Copyright 2015 the original author or authors.^M
@rem^M
@rem Licensed under the Apache License, Version 2.0 (the "License");^M
@rem you may not use this file except in compliance with the License.^M
@rem You may obtain a copy of the License at^M
@rem^M
@rem https://www.apache.org/licenses/LICENSE-2.0^M
@rem^M
@rem Unless required by applicable law or agreed to in writing, software^M
@rem distributed under the License is distributed on an "AS IS" BASIS,^M
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.^M
@rem See the License for the specific language governing permissions and^M
@rem limitations under the License.^M
@rem^M
@rem SPDX-License-Identifier: Apache-2.0^M
@rem^M
^M
@if "%DEBUG%"=="" @echo off^M
@rem ##########################################################################^M
@rem^M
@rem Gradle startup script for Windows^M
@rem^M
@rem ##########################################################################^M
^M
@rem Set local scope for the variables with windows NT shell^M
if "%OS%"=="Windows_NT" setlocal^M
^M
set DIRNAME=%~dp0^M
if "%DIRNAME%"=="" set DIRNAME=.^M
@rem This is normally unused^M
set APP_BASE_NAME=%~n0^M
set APP_HOME=%DIRNAME%^M
^M
@rem Resolve any "." and ".." in APP_HOME to make it shorter.^M
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi^M
^M
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.^M
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"^M
^M
@rem Find java.exe^M
if defined JAVA_HOME goto findJavaFromJavaHome^M
^M
set JAVA_EXE=java.exe^M
%JAVA_EXE% -version >NUL 2>&1^M
if %ERRORLEVEL% equ 0 goto execute^M
^M
echo. 1>&2^M
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2^M
echo. 1>&2^M
echo Please set the JAVA_HOME variable in your environment to match the 1>&2^M
echo location of your Java installation. 1>&2^M
^M
goto fail^M
^M
:findJavaFromJavaHome^M
set JAVA_HOME=%JAVA_HOME:"=%^M
set JAVA_EXE=%JAVA_HOME%/bin/java.exe^M
^M
if exist "%JAVA_EXE%" goto execute^M
^M
echo. 1>&2^M
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2^M
echo. 1>&2^M
echo Please set the JAVA_HOME variable in your environment to match the 1>&2^M
echo location of your Java installation. 1>&2^M
^M
goto fail^M
^M
:execute^M
@rem Setup the command line^M
^M
^M
^M
@rem Execute Gradle^M
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*^M
^M
:end^M
@rem End local scope for the variables with windows NT shell^M
if %ERRORLEVEL% equ 0 goto mainEnd^M
^M
:fail^M
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of^M
rem the _cmd.exe /c_ return code!^M
set EXIT_CODE=%ERRORLEVEL%^M
if %EXIT_CODE% equ 0 set EXIT_CODE=1^M
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%^M
exit /b %EXIT_CODE%^M
^M
:mainEnd^M
if "%OS%"=="Windows_NT" endlocal^M
^M
:omega^M
Aandroid/settings.gradle.kts
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "opentracker"
include(":app")