step 11: login screen, live map, home screen and permission flow
Aandroid/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Debug-only overlay. The one thing it changes is cleartext HTTP.
The main manifest sets android:usesCleartextTraffic="false" on purpose, and
that stays true for every release build. But the emulator reaches a server
running on the developer's own machine as http://10.0.2.2:7372, and with the
flag off the platform blocks that connection outright, before any code runs.
There is no certificate to install and no hostname to except, so the flag has
to be flipped for the debug build.
tools:replace is required: manifest merger keeps the stricter value from the
main manifest otherwise, and reports a conflict rather than overriding it.
This cannot leak into a release: the merger only reads src/debug for the debug
build type, which also carries its own applicationIdSuffix.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:usesCleartextTraffic="true"
tools:replace="android:usesCleartextTraffic" />
</manifest>
Mandroid/app/src/main/java/net/lexcom/opentracker/MainActivity.kt
@@ -1,62 +1,105 @@
package net.lexcom.opentracker
import android.Manifest
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import net.lexcom.opentracker.crypto.Sealer
import androidx.compose.runtime.setValue
import net.lexcom.opentracker.store.Prefs
import net.lexcom.opentracker.ui.HomeScreen
import net.lexcom.opentracker.ui.LoginScreen
/**
* The app's single activity. Three screens will hang off it — login, live map,
* settings — and it owns the permission and battery-exemption launchers, because
* only an Activity can.
* The app's single activity. It owns the two things only an Activity can do:
* launch the permission prompt, and decide which screen is on screen.
*
* Right now it renders the crypto self-test, which is the one thing worth
* knowing before any of the rest is built.
* Which screen that is comes from [Prefs]: no credentials means login, anything
* else means home. It is held as Compose state rather than re-read on every
* resume, so signing in and signing out swap the screen without an Activity
* restart and without a flash of the wrong one.
*
* There is no ViewModel. The only state that outlives this Activity is
* [TrackerState], which the service writes and which survives a rotation on its
* own because it is a process-wide object.
*/
class MainActivity : ComponentActivity() {
/** Recomputed after every launcher result so the home banner can update. */
private var missing by mutableStateOf(emptyList<String>())
private val permissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
// The result map is ignored on purpose. It reports the permissions
// that were asked for, and what the UI needs is what is still
// missing overall, which includes the ones this round never asked.
missing = PermissionGate.missing(this)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
missing = PermissionGate.missing(this)
setContent {
// One read per sign-in state change, not one per recomposition.
// Holding the Credentials rather than a boolean also gives
// HomeScreen its server address without a second load().
var credentials by remember { mutableStateOf(Prefs(this).load()) }
// Local copy so the null check smart-casts; a delegated var cannot.
val current = credentials
MaterialTheme {
Surface {
SelfTestScreen()
if (current == null) {
LoginScreen(onLoggedIn = { credentials = Prefs(this).load() })
} else {
val state by TrackerState.state.collectAsState()
HomeScreen(
state = state,
missingPermissions = missing,
serverHost = "${current.udpHost}:${current.udpPort}",
onGrantPermissions = ::requestNextPermissions,
onStart = { TrackerService.start(this) },
onStop = { TrackerService.stop(this) },
onSignOut = {
// Stop first. Clearing the credentials under a
// running service leaves it sending with a token
// the user just gave up.
TrackerService.stop(this)
Prefs(this).clear()
credentials = null
},
)
}
}
}
}
}
}
@Composable
private fun SelfTestScreen() {
// Runs once per composition of this screen; the real app runs it once per
// process start from Deps.
val report = remember { Sealer.selfTest() }
Column(
Modifier
.padding(24.dp)
.verticalScroll(rememberScrollState()),
) {
Text("opentracker", style = MaterialTheme.typography.headlineSmall)
Text(
"OTP/1 crypto self-test",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(top = 16.dp),
)
Text(
report.summary,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 8.dp),
)
/**
* Requests the first group of missing permissions, never all of them.
*
* `ACCESS_BACKGROUND_LOCATION` must be asked for alone and only after
* foreground location is already granted. Android drops it silently when it
* arrives in the same request as foreground location, so the user is never
* prompted and the grant never appears. See [PermissionGate.missing], which
* puts it last for exactly this reason.
*
* The result callback recomputes the list, so a second tap on the same
* button asks for the next group.
*/
private fun requestNextPermissions() {
val pending = missing
if (pending.isEmpty()) return
// Everything before background location, or background location alone
// when it is all that is left.
val group = pending.takeWhile { it != Manifest.permission.ACCESS_BACKGROUND_LOCATION }
.ifEmpty { listOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION) }
permissionLauncher.launch(group.toTypedArray())
}
}
Mandroid/app/src/main/java/net/lexcom/opentracker/TrackerService.kt
@@ -101,6 +101,7 @@ class TrackerService : Service() {
override fun onDestroy() {
if (!running) return
running = false
TrackerState.reportStopped()
// Before anything else: a callback left registered outlives the service
// and goes on waking the process on every network change.
watcher?.stop()
@@ -123,6 +124,7 @@ class TrackerService : Service() {
* missing thing and spin. */
private fun refuse(startId: Int, reason: String): Int {
Log.w(TAG, "cannot track: $reason")
TrackerState.reportStopped()
postAlert(ALERT_TITLE_STOPPED, reason)
stopSelf(startId)
return START_NOT_STICKY
@@ -144,6 +146,12 @@ class TrackerService : Service() {
source = AospLocationSource(this, thread.looper)
running = true
// Before the first round, so the UI's start/stop button is right as soon
// as this returns. It matters after a low-memory kill: the system
// restarts the service in a fresh process where TrackerState is back at
// its defaults, and without this the button would offer to start a
// service that is already running until the first round reports in.
TrackerState.report(STATUS_STARTING, policy.motion, queue.size)
handler.post {
source.start(policy.request, ::onFix)
round()
@@ -168,7 +176,9 @@ class TrackerService : Service() {
private fun onFix(fix: Fix) {
val decision = policy.offer(fix, SystemClock.elapsedRealtime())
decision.keep?.let {
queue.append(withBattery(it))
val point = withBattery(it)
queue.append(point)
TrackerState.reportFix(point)
scheduleIn(0)
}
if (decision.requestChanged) source.update(policy.request)
@@ -293,6 +303,9 @@ class TrackerService : Service() {
private fun updateNotification(state: String) {
val text = "$state - ${policy.motion.name.lowercase()} - ${queue.size} queued"
notifier().notify(Notifications.ID_TRACKING, Notifications.tracking(this, text))
// Same three facts, same moment, for the UI. Every path that changes what
// the user sees already goes through here, so there is nothing else to hook.
TrackerState.report(state, policy.motion, queue.size)
}
private fun postAlert(title: String, text: String) {
Aandroid/app/src/main/java/net/lexcom/opentracker/TrackerState.kt
@@ -0,0 +1,59 @@
package net.lexcom.opentracker
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import net.lexcom.opentracker.loc.Motion
import net.lexcom.opentracker.wire.Point
/**
* What the service is doing right now, for the UI to read.
*
* A process-wide singleton, not a binder. The UI and the service live in the
* same process, so a bound service would add a connection lifecycle, an
* interface and a reconnect path to move four fields across a boundary that is
* not there.
*
* This is display state only. Nothing here is durable and nothing reads it back
* to make a decision: the service's own state lives in [store.Prefs] and in the
* point queue. If the process dies, this resets to "not sharing", which is what
* a fresh UI should show anyway until the service reports again.
*
* Written from the tracker thread, read from the main thread. [MutableStateFlow]
* is safe for that.
*/
object TrackerState {
data class Snapshot(
val running: Boolean = false,
/** The same short string the ongoing notification shows. */
val status: String = STATUS_STOPPED,
val motion: Motion = Motion.STATIONARY,
val queued: Int = 0,
/** The last point actually kept for sending, or null since process start. */
val last: Point? = null,
)
const val STATUS_STOPPED = "Not sharing"
private val _state = MutableStateFlow(Snapshot())
val state: StateFlow<Snapshot> = _state.asStateFlow()
internal fun report(status: String, motion: Motion, queued: Int) {
_state.value = _state.value.copy(
running = true,
status = status,
motion = motion,
queued = queued,
)
}
/** The last kept point is left in place: it is still the best position known. */
internal fun reportStopped() {
_state.value = _state.value.copy(running = false, status = STATUS_STOPPED, queued = 0)
}
internal fun reportFix(point: Point) {
_state.value = _state.value.copy(last = point)
}
}
Aandroid/app/src/main/java/net/lexcom/opentracker/ui/HomeScreen.kt
@@ -0,0 +1,176 @@
package net.lexcom.opentracker.ui
import android.Manifest
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import net.lexcom.opentracker.TrackerState
import net.lexcom.opentracker.wire.Point
/**
* The main screen: permission banner, live map, status, start/stop.
*
* Stateless by design. Everything it draws arrives as [TrackerState.Snapshot]
* and everything it triggers leaves through a lambda, so the screen has no
* opinion on whether the service is bound, started or dead. That keeps the one
* piece worth testing — the formatting below — reachable from a JVM test.
*
* Strings are Kotlin constants because this app has no res/values/strings.xml
* and is not localized. See the lint block in build.gradle.kts.
*/
private const val TITLE_MISSING_PERMS = "Not everything is allowed yet"
private const val LABEL_GRANT = "Grant"
private const val LABEL_START = "Start sharing"
private const val LABEL_STOP = "Stop sharing"
private const val LABEL_SIGN_OUT = "Sign out"
/** How often the "last fix" age is recomputed. */
private const val AGE_TICK_MS = 10_000L
@Composable
fun HomeScreen(
state: TrackerState.Snapshot,
missingPermissions: List<String>,
serverHost: String,
onGrantPermissions: () -> Unit,
onStart: () -> Unit,
onStop: () -> Unit,
onSignOut: () -> Unit,
) {
Column(Modifier.fillMaxSize()) {
if (missingPermissions.isNotEmpty()) {
PermissionBanner(missingPermissions, onGrantPermissions)
}
// The map takes whatever height the rest leaves.
MapView(state.last, Modifier.fillMaxWidth().weight(1f))
StatusCard(state, serverHost)
Button(
onClick = if (state.running) onStop else onStart,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
Text(if (state.running) LABEL_STOP else LABEL_START)
}
TextButton(
onClick = onSignOut,
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
Text(LABEL_SIGN_OUT)
}
}
}
@Composable
private fun PermissionBanner(missing: List<String>, onGrant: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth().padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(Modifier.weight(1f)) {
Text(TITLE_MISSING_PERMS, style = MaterialTheme.typography.titleSmall)
Text(
"Still needed: ${describeMissing(missing)}.",
style = MaterialTheme.typography.bodyMedium,
)
}
TextButton(onClick = onGrant) { Text(LABEL_GRANT) }
}
}
}
@Composable
private fun StatusCard(state: TrackerState.Snapshot, serverHost: String) {
// Recomposition is driven by the service reporting, which stops when nothing
// moves. Without this tick the age would freeze at whatever it read last and
// claim a two hour old fix is seconds old.
val nowMs by produceState(System.currentTimeMillis()) {
while (true) {
delay(AGE_TICK_MS)
value = System.currentTimeMillis()
}
}
Card(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Column(Modifier.padding(16.dp)) {
Text(state.status, style = MaterialTheme.typography.titleMedium)
Text(
"Motion ${state.motion.name.lowercase()} · ${state.queued} queued",
style = MaterialTheme.typography.bodyMedium,
)
Text(serverHost, style = MaterialTheme.typography.bodySmall)
state.last?.let {
Text(describeFix(it, nowMs), style = MaterialTheme.typography.bodySmall)
}
}
}
}
/**
* The age of a fix, as a person would say it.
*
* [tsSeconds] is [Point.ts], a Unix timestamp in *seconds* (an unsigned u32 on
* the wire, widened to a Long here). [nowMs] is milliseconds, because that is
* what the platform clock gives.
*
* A future timestamp is clamped to zero rather than rendered. The device clock
* and the fix clock are not the same clock, so a few seconds of skew is normal,
* and "-3s ago" would read as a bug in the app rather than in the clock.
*/
internal fun formatAge(tsSeconds: Long, nowMs: Long): String {
val seconds = (nowMs / 1000 - tsSeconds).coerceAtLeast(0)
return when {
seconds < 60 -> "${seconds}s ago"
seconds < 3600 -> "${seconds / 60}m ago"
else -> "${seconds / 3600}h ago"
}
}
/** [accDm] is decimetres, the wire unit. Rounded to whole metres for display. */
internal fun formatAccuracy(accDm: Int?): String? =
accDm?.let { "±${(it + 5) / 10} m" }
internal fun describeFix(point: Point, nowMs: Long): String =
listOfNotNull("Last fix ${formatAge(point.ts, nowMs)}", formatAccuracy(point.accDm))
.joinToString(" · ")
/**
* Plain words for the permissions still missing.
*
* The raw `android.permission.*` strings mean nothing to a user, and the two
* location permissions are requested as a pair, so naming both would read as a
* duplicate.
*/
internal fun describeMissing(missing: List<String>): String =
missing.map(::permissionLabel).distinct().joinToString(", ")
private fun permissionLabel(permission: String): String = when (permission) {
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
-> "your location"
Manifest.permission.ACCESS_BACKGROUND_LOCATION -> "location while the app is closed"
Manifest.permission.POST_NOTIFICATIONS -> "notifications"
else -> permission.substringAfterLast('.').lowercase().replace('_', ' ')
}
Aandroid/app/src/main/java/net/lexcom/opentracker/ui/LoginScreen.kt
@@ -0,0 +1,197 @@
package net.lexcom.opentracker.ui
import android.os.Build
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.lexcom.opentracker.store.LoginResult
import net.lexcom.opentracker.store.Prefs
import net.lexcom.opentracker.store.login
/**
* The first screen, shown while [Prefs.load] returns null.
*
* It exists once per install in the normal case: a successful login writes
* credentials the server never issues again, and everything after it is UDP.
* So this screen has no "remember me", no account list and no session refresh.
*
* All four fields are plain Compose state. Nothing here survives the process,
* on purpose: the only field worth keeping is the server URL, and keeping it
* would mean a new [Prefs] field that exists solely for a retry that takes one
* more second to type.
*/
@Composable
fun LoginScreen(onLoggedIn: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var serverUrl by remember { mutableStateOf("") }
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
// The model name is what the user would type anyway, and it is what the
// device list on the server shows. They can still overwrite it.
var deviceName by remember { mutableStateOf(Build.MODEL ?: "") }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
// Whitespace-only input would otherwise be sent and come back as a URL
// parse failure from the server round trip. Reject it here instead.
val canSubmit = !busy &&
serverUrl.isNotBlank() &&
username.isNotBlank() &&
password.isNotBlank() &&
deviceName.isNotBlank()
Column(
Modifier
.padding(24.dp)
.verticalScroll(rememberScrollState()),
) {
Text("opentracker", style = MaterialTheme.typography.headlineSmall)
Text(
"Sign in to mint this device's token.",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 4.dp, bottom = 16.dp),
)
Field(
value = serverUrl,
onValueChange = { serverUrl = it; error = null },
label = "Server URL",
placeholder = "https://track.example.net",
// Uri, not Text: it gives the "/" and "." keys without autocorrect
// mangling a hostname.
keyboardType = KeyboardType.Uri,
enabled = !busy,
)
Field(
value = username,
onValueChange = { username = it; error = null },
label = "Username",
enabled = !busy,
)
Field(
value = password,
onValueChange = { password = it; error = null },
label = "Password",
keyboardType = KeyboardType.Password,
enabled = !busy,
visualTransformation = PasswordVisualTransformation(),
)
Field(
value = deviceName,
onValueChange = { deviceName = it; error = null },
label = "Device name",
enabled = !busy,
imeAction = ImeAction.Done,
)
Spacer(Modifier.height(16.dp))
Button(
onClick = {
busy = true
error = null
scope.launch {
// login() opens a socket and blocks until the reply or the
// read timeout, which is up to 20 s. On the main thread that
// is an ANR.
val result = withContext(Dispatchers.IO) {
login(serverUrl.trim(), username, password, deviceName.trim())
}
busy = false
when (result) {
is LoginResult.Ok -> {
Prefs(context).save(result.credentials)
onLoggedIn()
}
LoginResult.BadCredentials ->
error = "Wrong username or password."
is LoginResult.RateLimited -> error = result.retryAfterSeconds
?.let { "Too many attempts. Try again in $it seconds." }
?: "Too many attempts. Try again later."
// Already carries the useful detail: the HTTP status,
// the server's own error text or the exception class.
is LoginResult.Failed -> error = result.message
}
}
},
enabled = canSubmit,
modifier = Modifier.fillMaxWidth(),
) {
Text("Sign in")
}
if (busy) {
// Also the only feedback during a 20-second read timeout on a dead
// server, where a disabled button alone looks like a frozen app.
CircularProgressIndicator(Modifier.padding(top = 16.dp))
}
error?.let {
Text(
it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 16.dp),
)
}
}
}
/** Four fields with the same shape. Written once rather than four times. */
@Composable
private fun Field(
value: String,
onValueChange: (String) -> Unit,
label: String,
enabled: Boolean,
placeholder: String? = null,
keyboardType: KeyboardType = KeyboardType.Text,
imeAction: ImeAction = ImeAction.Next,
visualTransformation: VisualTransformation = VisualTransformation.None,
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
placeholder = placeholder?.let { { Text(it) } },
singleLine = true,
enabled = enabled,
visualTransformation = visualTransformation,
keyboardOptions = KeyboardOptions(keyboardType = keyboardType, imeAction = imeAction),
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
)
}
Aandroid/app/src/main/java/net/lexcom/opentracker/ui/MapView.kt
@@ -0,0 +1,127 @@
package net.lexcom.opentracker.ui
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import net.lexcom.opentracker.wire.Point
import org.osmdroid.config.Configuration
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.CustomZoomButtonsController
import org.osmdroid.views.overlay.CopyrightOverlay
import org.osmdroid.views.overlay.Marker
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
import org.osmdroid.views.MapView as OsmMapView
/**
* The live map: an osmdroid [OsmMapView] hosted in Compose.
*
* osmdroid is a plain Android View, so there is no Compose-native alternative
* that does not pull in Play Services or a vector tile renderer. [AndroidView]
* is the whole integration.
*
* The one rule that is easy to get wrong: osmdroid must be configured *before*
* its first view is constructed. The view reads the global [Configuration] in
* its constructor to build the tile downloader, so configuring afterwards
* silently leaves the old values in place and tiles never load.
*/
/** Zoom used once a real position is known. Roughly a few streets across. */
private const val FOLLOW_ZOOM = 16.0
/** Zoom used while there is no position at all. A continent, not the ocean. */
private const val DEFAULT_ZOOM = 3.0
private const val TILE_CACHE_DIR = "osmdroid-tiles"
@Composable
fun MapView(point: Point?, modifier: Modifier = Modifier) {
val context = LocalContext.current
val map = remember { createMapView(context) }
val marker = remember { Marker(map) }
// Not Compose state on purpose. The update block below reads it, and a
// snapshot state read there would make the block re-run when it flips.
val centred = remember { AtomicBoolean(false) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
// osmdroid's onResume/onPause start and stop the tile downloader and the
// (optional) compass sensor. Skipping them keeps threads running while
// the app is in the background, which on a tracker is exactly the wrong
// place to burn battery.
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> map.onResume()
Lifecycle.Event.ON_PAUSE -> map.onPause()
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
AndroidView(
factory = { map },
modifier = modifier,
update = {
if (point == null) {
map.overlays.remove(marker)
} else {
// latE7/lonE7 are degrees scaled by 1e7, the wire representation.
val here = GeoPoint(point.latE7 / 1e7, point.lonE7 / 1e7)
marker.position = here
if (!map.overlays.contains(marker)) map.overlays.add(marker)
// First fix jumps and zooms in; after that the map follows.
if (centred.compareAndSet(false, true)) map.controller.setZoom(FOLLOW_ZOOM)
map.controller.setCenter(here)
}
map.invalidate()
},
// osmdroid holds a tile downloader thread pool, a tile cache and overlay
// references. onDetach() is the only thing that frees them; without it
// every visit to this screen leaks a pool.
onRelease = { it.onDetach() },
)
}
private fun createMapView(context: Context): OsmMapView {
val config = Configuration.getInstance()
// OSM's tile servers block the library's default "osmdroid" user agent
// outright, so an unset UA means every tile request returns 403.
config.userAgentValue = context.packageName
// Deliberately not Configuration.load(context, PreferenceManager...): that
// form needs the androidx.preference dependency and defaults the cache to
// external storage, which this app holds no permission for. Setting the two
// paths by hand keeps everything in the app's own cache directory, which
// the system may also reclaim under storage pressure.
config.osmdroidBasePath = context.cacheDir
config.osmdroidTileCache = File(context.cacheDir, TILE_CACHE_DIR)
return OsmMapView(context).apply {
// ponytail: step 14 repoints this at the server's own /tiles proxy, so
// the map does not leak the user's viewport to a third party. Hitting
// OSM's public servers directly is acceptable for development only.
setTileSource(TileSourceFactory.MAPNIK)
setMultiTouchControls(true)
// Pinch covers zooming, so the overlaid +/- buttons only cost screen.
// This is what setBuiltInZoomControls(false) does; that call is
// deprecated and only forwards here.
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
controller.setZoom(DEFAULT_ZOOM)
// Not decoration. The OSM tile usage policy requires visible
// attribution, and it stays required in step 14: proxying the tiles
// through our own server changes who fetches them, not who made them.
// The overlay reads the credit line from the tile source, so it also
// stays correct if that source changes.
overlays.add(CopyrightOverlay(context))
}
}
Aandroid/app/src/test/java/net/lexcom/opentracker/ui/HomeFormatTest.kt
@@ -0,0 +1,48 @@
package net.lexcom.opentracker.ui
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* Only the age formatter. It is the one part of the home screen with a branch
* that can be wrong, and the only one that can be wrong *quietly*: a bad unit
* still renders a plausible looking string.
*/
class HomeFormatTest {
/** Fixed so the test never depends on the machine's clock. */
private val nowMs = 1_785_000_000_000L
private val nowS = nowMs / 1000
private fun ageAt(secondsAgo: Long) = formatAge(nowS - secondsAgo, nowMs)
@Test
fun `an age below a minute is shown in seconds`() {
assertEquals("0s ago", ageAt(0))
assertEquals("59s ago", ageAt(59))
}
@Test
fun `an age of a minute or more is shown in minutes`() {
assertEquals("1m ago", ageAt(60))
assertEquals("59m ago", ageAt(3599))
}
@Test
fun `an age of an hour or more is shown in hours`() {
assertEquals("1h ago", ageAt(3600))
assertEquals("25h ago", ageAt(25 * 3600))
}
@Test
fun `a timestamp from the future reads as zero, never as a negative age`() {
assertEquals("0s ago", ageAt(-90))
}
@Test
fun `accuracy is rounded from decimetres to metres`() {
assertEquals("±1 m", formatAccuracy(12))
assertEquals("±2 m", formatAccuracy(15))
assertEquals(null, formatAccuracy(null))
}
}
Mjustfile
@@ -120,3 +120,41 @@ logcat:
# Run this after any AGP bump and update app/build.gradle.kts to match.
android-kotlin-version:
cd android && ANDROID_HOME={{sdk}} ./gradlew :app:buildEnvironment | grep -i kotlin-gradle-plugin
# --- emulator ----------------------------------------------------------------
# The AVDs, oldest to newest. ot29 is minSdk and the one that finds the
# API-level bugs; ot36 is targetSdk and the one that finds the policy bugs.
avd := "ot36"
# Create the AVD. Needs the matching system image installed first:
# sdkmanager "system-images;android-36;default;x86_64"
#
# Create an AVD if `just emu` says there is none.
emu-create name=avd api="36":
{{sdk}}/cmdline-tools/latest/bin/avdmanager create avd -n {{name}} -k "system-images;android-{{api}};default;x86_64" -d pixel_6
# -no-snapshot so every run is a cold boot: a snapshot restores stale
# permission grants and a stale credentials file, which is exactly what you are
# trying to test.
#
# Start the emulator and wait until it has booted.
emu name=avd:
{{sdk}}/emulator/emulator -avd {{name}} -no-boot-anim -no-snapshot &
adb wait-for-device shell 'while [ "$(getprop sys.boot_completed)" != 1 ]; do sleep 1; done'
@echo "booted"
# Defaults to Marienplatz, Munich. Repeat with new values to simulate walking.
#
# Move the emulated GPS. Longitude first, that is the order `geo fix` wants.
emu-geo lon="11.5754" lat="48.1372":
adb emu geo fix {{lon}} {{lat}}
# 10.0.2.2 is the emulator's alias for the host's loopback, so the server itself
# still binds 127.0.0.1 and stays off the LAN.
#
# The dev server, handing out the UDP address the emulator can reach.
dev-emu:
OT_SECRET_KEY={{scratch}}/secret.key OT_ADMIN_EMAIL=ops@example.net OT_DB_PATH={{scratch}}/ot.db \
OT_HTTP_ADDR=127.0.0.1:7372 OT_UDP_ADDR=127.0.0.1:7373 OT_PUBLIC_UDP_HOST=10.0.2.2 \
cargo run -p otserver -- --dev