Skip to main content

13 - Location

Recap​

12 - Permissions in Compose got SportMap the right to read the device location. This lecture actually reads it. It covers the fused location provider, location requests and their battery cost, and turning the callback API into a Flow (the callbackFlow pattern from 09 - Kotlin Flow). It also checks that location is switched on and shows how to test without running around the block. The second half is the math the final project's statistics need: travelled and direct distance, pace in minutes per kilometre for the three sections (overall, checkpoint, waypoint), and filtering out GPS fixes that can't be true. At the end TrackingRepository from lecture 10 gets its full implementation.

By the end of this lecture you should be able to:

  • Explain where a location comes from and what the fused location provider adds.
  • Request last, current and periodic locations with FusedLocationProviderClient and coroutines.
  • Choose LocationRequest priority, interval and distance, and explain their battery cost.
  • Wrap location updates in a callbackFlow behind the LocationClient interface.
  • Detect switched-off location services and let the user fix them with one tap.
  • Test location code on the emulator with points, routes and GPX files.
  • Compute travelled distance, direct distance, bearing and pace, and filter implausible fixes.
Demo code

Lecture demos: native-2026-fall

Where a location comes from​

SourceAccuracyPowerIndoorsNotes
GNSS satellites (GPS, Galileo, GLONASS, BeiDou)2–10 m in the openhighpoorneeds sky view; the first fix can take seconds to a minute
Wi-Fi access points10–50 mlowgoodposition looked up from a database of known networks
Cell towers100 m – several kmvery lowgoodalways available with a SIM
Motion sensorsrelative onlylowyessmooth and fill gaps between fixes

On Android you can read these yourself through the platform LocationManager (providers gps, network, passive, and fused on Android 12+). Most apps use the fused location provider (FLP) from Google Play services instead. It combines all sources, picks what's needed for the accuracy you ask for, and shares work between apps, which saves battery. You describe what you need ("high accuracy every 2 seconds"), and the FLP decides how.

Devices without Google Play services

Some phones (for example recent Huawei models) don't have Google Play services. There you'd use LocationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, ...) directly. Hide it behind the same LocationClient interface (below) and nothing else in the app changes. On emulators, use a system image with Google Play or Google APIs.

Setup​

# libs.versions.toml
[libraries]
play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" }
kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-play-services", version.ref = "coroutines" }
// app/build.gradle.kts
implementation(libs.play.services.location)
implementation(libs.kotlinx.coroutines.play.services) // Task.await()

Play services APIs return a Task with success and failure listeners. kotlinx-coroutines-play-services adds Task.await(), which turns it into a suspend call (lecture 08).

FusedLocationProviderClient​

val client = LocationServices.getFusedLocationProviderClient(context)

Last known and current location​

@SuppressLint("MissingPermission")   // the caller checked the permission (lecture 12)
suspend fun lastAndCurrent(context: Context): Pair<Location?, Location?> {
val client = LocationServices.getFusedLocationProviderClient(context)

// Immediate, no power cost - but may be minutes old, or null (fresh boot, location was off)
val last: Location? = client.lastLocation.await()

// Asks for one fresh fix and waits for it - may take a few seconds.
// Doesn't switch location on: null if location services are off or no fix is found
val current: Location? = client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, null).await()

return last to current
}

lastLocation is good for centring the map at startup. getCurrentLocation gives one fresh position, for example to drop a marker "here". Neither is enough for tracking, which needs a stream.

To cancel a pending getCurrentLocation together with the coroutine, pass a cancellation token:

@OptIn(ExperimentalCoroutinesApi::class)
@SuppressLint("MissingPermission")
suspend fun currentLocation(context: Context): Location? {
val client = LocationServices.getFusedLocationProviderClient(context)
val cts = CancellationTokenSource()
return client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, cts.token).await(cts)
}

Periodic updates: LocationRequest​

val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 2_000)   // priority, interval ms
.setMinUpdateIntervalMillis(1_000) // accept faster updates if they are available anyway
.setMinUpdateDistanceMeters(1f) // ignore updates closer than 1 m to the previous one
.setWaitForAccurateLocation(true) // at start, wait briefly for GPS instead of a coarse fix
.build()
SettingMeaning
intervalthe desired time between updates. A hint, not a guarantee: indoors you may get nothing for a minute
setMinUpdateIntervalMillisthe fastest rate you accept when other apps make the FLP compute locations more often. Defaults to the interval
setMinUpdateDistanceMetersdrops updates that moved less than this. 0 means every update
setWaitForAccurateLocationfor high-accuracy requests, delay the first delivery until a precise fix is available
setMaxUpdateDelayMillisbatching: deliver several locations together, up to this delay. Saves battery in background-only apps, but is wrong for a live map
setGranularityGRANULARITY_PERMISSION_LEVEL (default) or GRANULARITY_FINE / GRANULARITY_COARSE

Priorities:

PriorityTypical sourceAccuracyPowerUse for
PRIORITY_HIGH_ACCURACYGNSSa few metreshightracking, navigation: SportMap
PRIORITY_BALANCED_POWER_ACCURACYWi-Fi, cell~100 m (block)medium"nearby" features
PRIORITY_LOW_POWERcell~10 km (city)lowweather, region
PRIORITY_PASSIVEwhatever others requestvariesnone caused by youopportunistic logging

High accuracy with fine location and a 1–5 second interval gives metre-level positions, which is what a map with a live track needs. With only approximate location granted, even PRIORITY_HIGH_ACCURACY returns blurred positions (lecture 12).

LocationClient: updates as a Flow​

The FLP delivers updates to a LocationCallback, which you register and unregister. That's exactly the shape callbackFlow turns into a cold Flow: registration happens when collection starts, and unregistration when it's cancelled. Hide it behind an interface, as lecture 10 recommends for data sources:

// data/location/LocationClient.kt
interface LocationClient {
fun locationUpdates(intervalMs: Long): Flow<Location>
}

class FusedLocationClient @Inject constructor(
@ApplicationContext private val context: Context,
) : LocationClient {

private val client = LocationServices.getFusedLocationProviderClient(context)

@SuppressLint("MissingPermission") // checked at the start of the flow
override fun locationUpdates(intervalMs: Long): Flow<Location> = callbackFlow {
if (!context.hasLocationPermission()) throw SecurityException("Location permission not granted")

val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, intervalMs)
.setMinUpdateIntervalMillis(intervalMs / 2)
.setMinUpdateDistanceMeters(1f)
.setWaitForAccurateLocation(true)
.build()

val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
result.locations.forEach { trySend(it) } // may contain several (batching)
}
}

client.requestLocationUpdates(request, callback, Looper.getMainLooper())
.addOnFailureListener { e -> close(e) }

awaitClose { client.removeLocationUpdates(callback) } // runs when the collector is cancelled
}
}

fun Context.hasLocationPermission(): Boolean =
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
// data/location/LocationModule.kt
@Module
@InstallIn(SingletonComponent::class)
abstract class LocationModule {
@Binds
@Singleton
abstract fun bindLocationClient(impl: FusedLocationClient): LocationClient
}
  • No collector, no GPS. The flow is cold: nothing is requested until somebody collects, and cancelling the collecting coroutine removes the callback. You can't forget removeLocationUpdates.
  • @SuppressLint("MissingPermission") silences lint, which can't see the check inside the flow. Without the check, a revoked permission would crash with a SecurityException from inside Play services. With it, the collector gets a clean exception to catch.
  • trySend doesn't suspend. Location callbacks are slow (seconds), so the channel never fills up.
  • The callbacks run on the main looper. They only hand a Location to the channel, so that's cheap.
  • The interface makes the rest of the app testable: a FakeLocationClient that emits a list of Locations replays a recorded track in a unit test.

What's in a Location​

PropertyMeaning
latitude, longitudedegrees, WGS84
accuracyhorizontal accuracy in metres: 68 % probability that the true position is within this radius
altitude, hasAltitude()metres above the WGS84 ellipsoid (not sea level), often 10–20 m off
verticalAccuracyMeters, hasVerticalAccuracy()accuracy of the altitude
bearing, hasBearing()direction of movement in degrees (0 = north). Only meaningful while moving
speed, hasSpeed()metres per second, from the Doppler shift. Usually better than distance/time
timeUTC wall-clock time of the fix (ms since epoch). Use it for timestamps you store or send
elapsedRealtimeNanosmonotonic time since boot. Use it for intervals, because wall clocks can jump
isMock (API 31+)set when a mock-location app provided the position

Convert to the domain type at the edge of the data layer, so the rest of the app never sees android.location.Location:

fun Location.toGpsLocation(type: LocationType = LocationType.LOC): GpsLocation = GpsLocation(
latitude = latitude,
longitude = longitude,
accuracy = accuracy,
altitude = if (hasAltitude()) altitude else null,
verticalAccuracy = if (hasVerticalAccuracy()) verticalAccuracyMeters else null,
recordedAt = Instant.ofEpochMilli(time),
type = type,
)

val Location.isMockLocation: Boolean
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) isMock
else @Suppress("DEPRECATION") isFromMockProvider

The fields map one-to-one to the backend's GpsLocations payload (latitude, longitude, accuracy, altitude, verticalAccuracy, recordedAt) in lecture 19.

Is location switched on?​

Permission granted doesn't mean location works. The user can switch location off in quick settings, and then the FLP simply delivers nothing. The settings client checks whether the device settings satisfy your request. If they don't, it gives you a ready-made system dialog ("Turn on location?") to launch:

@Composable
fun StartWithLocationCheck(onReady: () -> Unit, onUnavailable: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val resolveLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartIntentSenderForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) onReady() else onUnavailable()
}

Button(onClick = {
scope.launch {
val settingsRequest = LocationSettingsRequest.Builder().addLocationRequest(request).build()
try {
LocationServices.getSettingsClient(context).checkLocationSettings(settingsRequest).await()
onReady() // settings are fine
} catch (e: ResolvableApiException) {
// location off - show the system dialog that switches it on
resolveLauncher.launch(IntentSenderRequest.Builder(e.resolution).build())
} catch (e: ApiException) {
onUnavailable() // can't be fixed from the app
}
}
}) { Text("Start") }
}

Combined with lecture 12, the Start button runs permission → location settings → start the service. Each step either passes or explains what is missing.

Testing without walking​

Location code is where emulator testing matters most. Nobody wants to walk around the block after every change.

  • Emulator → Extended controls (⋯) → Location

    • Single points: search or click on the map, then Set location.
    • Routes: create a route with Navigate/Add destination, Save route, then Play route. A Playback speed menu and Repeat playback are available. A 3 km route at 5x replays a run in a few minutes.
    • Load GPX/KML: replay a real recorded track (for example your own GPX export from lecture 18) at 1X–5X speed.
  • Command line (note the order: longitude first):

    adb emu geo fix 24.74535 59.43696        # Tallinn, Town Hall Square
  • Real device: the real test. GPS behaves differently near buildings, under trees and in pockets. Go outside before the defence: the final exam is in a forest.

Mock-location apps (Developer options → Select mock location app) also work on a device, and such fixes have isMock == true.

Distances, bearing and pace​

The final project shows three sections under the map:

SectionDistance coveredDirect distanceTimePace
Overallalong the track since startstraight line start → nowsince startoverall min/km
Checkpointalong the track since the last CPstraight line CP → nowsince the CPmin/km since the CP
Waypointalong the track since the WPstraight line WP → nowsince the WPmin/km since the WP
  • Travelled distance is the sum of the segments between consecutive fixes.
  • Direct distance is the straight line ("as the crow flies") between two points. In orienteering it tells you how far the checkpoint you just left is, regardless of the detours.
  • Pace is time per distance, the runner's inverse of speed: pace [s/km] = duration [s] / (distance [m] / 1000). 1 h 35 min 47 s for 15 km is 6:23 min/km.

The spec asks for direct distance from start, from the last CP and from the WP, so all three sections carry it in SectionStats.directM. The overall section can show all four numbers: distance covered, duration, direct distance and pace.

TrackMath​

// domain/TrackMath.kt - pure Kotlin, no Android imports
object TrackMath {
private const val EARTH_RADIUS_M = 6_371_008.8

/** Great-circle (haversine) distance in metres. */
fun distanceM(a: GpsLocation, b: GpsLocation): Double {
val dLat = Math.toRadians(b.latitude - a.latitude)
val dLon = Math.toRadians(b.longitude - a.longitude)
val h = sin(dLat / 2) * sin(dLat / 2) +
cos(Math.toRadians(a.latitude)) * cos(Math.toRadians(b.latitude)) * sin(dLon / 2) * sin(dLon / 2)
return 2 * EARTH_RADIUS_M * asin(sqrt(h))
}

/** Sum of all segments. */
fun trackDistanceM(track: List<GpsLocation>): Double =
track.zipWithNext { a, b -> distanceM(a, b) }.sum()

/** Initial bearing from a to b, 0..360, 0 = north, 90 = east. */
fun bearingDeg(a: GpsLocation, b: GpsLocation): Float {
val lat1 = Math.toRadians(a.latitude)
val lat2 = Math.toRadians(b.latitude)
val dLon = Math.toRadians(b.longitude - a.longitude)
val y = sin(dLon) * cos(lat2)
val x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon)
return ((Math.toDegrees(atan2(y, x)) + 360) % 360).toFloat()
}

/** Pace of one segment in seconds per km, null if it is too short to mean anything. */
fun segmentPaceSecPerKm(a: GpsLocation, b: GpsLocation): Double? {
val meters = distanceM(a, b)
val seconds = Duration.between(a.recordedAt, b.recordedAt).toMillis() / 1000.0
if (meters < 1.0 || seconds <= 0.0) return null
return seconds / (meters / 1000.0)
}

/** Statistics from sectionStart (start, CP or WP) to the last track point. */
fun sectionStats(track: List<GpsLocation>, sectionStart: GpsLocation, now: Instant): SectionStats {
val points = listOf(sectionStart) + track.filter { it.recordedAt.isAfter(sectionStart.recordedAt) }
return SectionStats(
distanceM = trackDistanceM(points),
directM = distanceM(sectionStart, points.last()),
durationMs = Duration.between(sectionStart.recordedAt, now).toMillis(),
)
}
}

android.location.Location.distanceBetween() would also work. It uses the WGS84 ellipsoid and is slightly more exact. But it's an Android API: in a plain JVM unit test it's only a stub that throws "not mocked". Haversine on a sphere differs from the ellipsoid by less than 0.5 %, which is far below GPS noise. In exchange, the domain layer stays pure Kotlin and testable without a device. bearingDeg is what lecture 16 uses for "direction up" and lecture 17 compares with the compass. segmentPaceSecPerKm colours the track by speed.

sectionStats recomputes a section from scratch. It's handy for old sessions (lecture 18) and in tests. The overall section is sectionStats(track, track.first(), now), with the duration counted from startedAt rather than the first fix. For the live session, TrackingRepository (below) updates the numbers incrementally, one segment per fix.

Formatting​

// domain/Format.kt
fun formatDistance(m: Double): String = "${m.roundToLong()} m"

fun formatDuration(ms: Long): String {
val totalSec = ms / 1000
val h = totalSec / 3600
val m = (totalSec % 3600) / 60
val s = totalSec % 60
return if (h > 0) String.format(Locale.ROOT, "%d:%02d:%02d", h, m, s)
else String.format(Locale.ROOT, "%d:%02d", m, s)
}

fun formatPace(secPerKm: Double?): String {
if (secPerKm == null || !secPerKm.isFinite() || secPerKm >= 60 * 60) return "--:--" // slower than 60 min/km = standing
val total = secPerKm.roundToInt()
return String.format(Locale.ROOT, "%d:%02d", total / 60, total % 60)
}

formatDuration(5_747_000) is "1:35:47", and formatPace(383.0) is "6:23". Right after a CP the section has no distance yet, SectionStats.paceSecPerKm (lecture 10) is null, and the UI shows --:-- instead of a division by zero.

Filtering bad fixes​

GPS lies sometimes: a fix with 150 m accuracy after leaving a building, a jump of 300 m between two seconds, an old cached fix delivered after a newer one. Each one adds a spike to the track and to the distance. The final project requires that "obvious wrong coordinates are filtered out":

// domain/TrackMath.kt
fun isPlausible(
prev: GpsLocation?,
next: GpsLocation,
maxAccuracyM: Float = 30f,
maxSpeedMps: Double = 12.0,
): Boolean {
if (next.accuracy > maxAccuracyM) return false // too vague
if (prev == null) return true
val seconds = Duration.between(prev.recordedAt, next.recordedAt).toMillis() / 1000.0
if (seconds <= 0.0) return false // duplicate or out of order
val speed = TrackMath.distanceM(prev, next) / seconds
return speed <= maxSpeedMps // 12 m/s = 43 km/h: a jump, not a runner
}
  • Accuracy threshold. Fixes worse than the threshold are dropped. Under dense forest, 30 m may drop too much, so make it a setting if your test runs show that.
  • Implied speed. Compare with the last accepted point. A real gap (a tunnel, a phone in a pocket) is fine, because time grows too. Only impossible speeds are rejected.
  • Time order catches duplicates and late, stale deliveries.
  • Mock locations (isMockLocation) can be rejected too, if you want the field test to be honest.

Filtering belongs in the pipeline before the repository, so every consumer (map, statistics, database, backend) sees the same cleaned track.

TrackingRepository, full version​

// data/tracking/TrackingRepository.kt
@Singleton
class TrackingRepository @Inject constructor() {
private val _state = MutableStateFlow(TrackingState())
val state: StateFlow<TrackingState> = _state.asStateFlow()

fun start(sessionId: Long, startedAt: Instant) {
_state.value = TrackingState(isTracking = true, sessionId = sessionId, startedAt = startedAt)
}

fun onLocation(location: GpsLocation) {
_state.update { state ->
if (!state.isTracking) return@update state
val start = state.track.firstOrNull() ?: location
val step = state.track.lastOrNull()?.let { TrackMath.distanceM(it, location) } ?: 0.0
state.copy(
track = state.track + location,
overall = SectionStats(
distanceM = state.overall.distanceM + step,
directM = TrackMath.distanceM(start, location),
durationMs = Duration.between(state.startedAt ?: start.recordedAt, location.recordedAt).toMillis(),
),
fromCheckpoint = state.checkpoints.lastOrNull()?.let { cp -> state.fromCheckpoint?.advance(step, cp, location) },
fromWaypoint = state.waypoint?.let { wp -> state.fromWaypoint?.advance(step, wp, location) },
)
}
}

/** Marks the last known position as a checkpoint. Returns it, or null without a fix. */
fun addCheckpoint(): GpsLocation? {
var added: GpsLocation? = null
_state.update { state ->
val last = state.track.lastOrNull() ?: return@update state
val cp = last.copy(type = LocationType.CP).also { added = it }
state.copy(checkpoints = state.checkpoints + cp, fromCheckpoint = SectionStats())
}
return added
}

/** Replaces the waypoint with the last known position. */
fun addWaypoint(): GpsLocation? {
var added: GpsLocation? = null
_state.update { state ->
val last = state.track.lastOrNull() ?: return@update state
val wp = last.copy(type = LocationType.WP).also { added = it }
state.copy(waypoint = wp, fromWaypoint = SectionStats())
}
return added
}

fun stop() {
_state.update { it.copy(isTracking = false) }
}

private fun SectionStats.advance(step: Double, start: GpsLocation, current: GpsLocation) = copy(
distanceM = distanceM + step,
directM = TrackMath.distanceM(start, current),
durationMs = Duration.between(start.recordedAt, current.recordedAt).toMillis(),
)
}
  • Incremental. Each fix adds one segment to the running totals. Recomputing the whole track on every fix would be O(n) per update, and after 3 hours at one fix per second that's 10 000 points, every second.
  • CP vs WP. Checkpoints accumulate, and each new CP restarts the checkpoint section. There is exactly one waypoint, and a new WP replaces it and restarts the waypoint section. The CP/WP position is the last accepted fix, which is the best position the app knows.
  • The functions return the added point, so the service (lecture 15) can store it in the database (lecture 18) and send it to the backend (lecture 19). The spec says WPs aren't saved, but the GPX export needs the WP coordinates. So SportMap stores each waypoint as a type = WP row too. Waypoints are never shown as permanent markers and don't count as checkpoints, and TrackingState still holds only the current one.
  • durationMs is the time as of the last fix. Under trees, fixes can be seconds apart, so the clock on screen should tick on its own, every second, from startedAt:
@Composable
fun rememberElapsedMs(startedAt: Instant?): Long {
var now by remember { mutableStateOf(Instant.now()) }
LaunchedEffect(startedAt) {
while (startedAt != null) {
now = Instant.now()
delay(1_000)
}
}
return startedAt?.let { Duration.between(it, now).toMillis() } ?: 0L
}

Configurable GPS frequency​

The final project requires a configurable GPS update frequency. Settings.gpsIntervalMs (lecture 10) lives in DataStore. When it changes, the location request must be replaced. flatMapLatest (lecture 09) does that: it cancels the old locationUpdates flow, which unregisters the callback through awaitClose, and starts a new one with the new interval:

// data/location/TrackedLocations.kt
@OptIn(ExperimentalCoroutinesApi::class)
fun trackedLocations(
settings: Flow<Settings>,
locationClient: LocationClient,
trackingRepository: TrackingRepository,
): Flow<GpsLocation> = settings
.map { it.gpsIntervalMs }
.distinctUntilChanged() // ignore changes to other settings
.flatMapLatest { interval -> locationClient.locationUpdates(interval) }
.map { it.toGpsLocation() }
.filter { isPlausible(trackingRepository.state.value.track.lastOrNull(), it) }

It's a top-level function in ee.taltech.sportmap.data.location: trackedLocations(settings: Flow<Settings>, locationClient: LocationClient, trackingRepository: TrackingRepository): Flow<GpsLocation>. The flow is cold: GPS runs only while someone collects it. Lecture 15 collects exactly this inside the foreground service (trackedLocations(settingsRepository.settings, locationClient, trackingRepository)) and calls trackingRepository.onLocation(it) for every accepted fix. Offer a few sensible values in the settings screen, such as 1, 2, 5 and 10 seconds, rather than a free text field.

Testing TrackMath​

Because TrackMath is pure Kotlin, a plain JUnit test in app/src/test runs in milliseconds:

class TrackMathTest {
private val t0 = Instant.parse("2026-10-01T10:00:00Z")

private fun loc(lat: Double, lon: Double, sec: Long, accuracy: Float = 5f) =
GpsLocation(lat, lon, accuracy, altitude = null, verticalAccuracy = null, recordedAt = t0.plusSeconds(sec))

private val raekoda = loc(59.43696, 24.74535, 0) // Town Hall Square
private val vabaduse = loc(59.43357, 24.74437, 150) // Freedom Square
private val viru = loc(59.43627, 24.75076, 330) // Viru Gate

@Test
fun distanceBetweenSquares() {
assertEquals(381.0, TrackMath.distanceM(raekoda, vabaduse), 1.0)
}

@Test
fun bearingToViruGateIsEastSouthEast() {
assertEquals(104.1f, TrackMath.bearingDeg(raekoda, viru), 0.5f)
}

@Test
fun sectionStatsAlongTrack() {
val track = listOf(raekoda, vabaduse, viru)
val stats = TrackMath.sectionStats(track, sectionStart = raekoda, now = viru.recordedAt)
assertEquals(381.0 + 469.8, stats.distanceM, 2.0) // travelled
assertEquals(315.4, stats.directM, 1.0) // straight line
assertEquals(330_000, stats.durationMs)
assertEquals("6:28", formatPace(stats.paceSecPerKm)) // 330 s / 0.851 km
}

@Test
fun implausibleJumpRejected() {
val jump = loc(59.39487, 24.67130, 10) // TalTech, 6 km in 10 s
assertFalse(isPlausible(raekoda, jump))
assertTrue(isPlausible(raekoda, vabaduse))
assertFalse(isPlausible(raekoda, vabaduse.copy(accuracy = 80f)))
assertFalse(isPlausible(vabaduse, raekoda)) // older than the previous one
}

@Test
fun formatting() {
assertEquals("1:35:47", formatDuration((1 * 3600 + 35 * 60 + 47) * 1000L))
assertEquals("8:45", formatDuration((8 * 60 + 45) * 1000L))
assertEquals("--:--", formatPace(null))
}
}

The walk from the Town Hall Square via Freedom Square to Viru Gate is 851 m travelled, but Viru Gate is only 315 m from the start in a straight line. That's exactly the difference between the "covered" and "direct" numbers on the CP and WP sections.

Battery​

GPS is one of the most power-hungry parts of a phone. For a 2–3 hour session:

  • Use high accuracy only while tracking. The cold flow guarantees that: stop collecting and the GPS turns off.
  • Don't go below 1 second. 2–5 seconds is plenty for running, and a minimum distance of 1–5 m avoids storing points while you stand at a CP.
  • Don't call getCurrentLocation in a loop. That's what requestLocationUpdates is for.
  • Don't keep the screen on. The foreground service (lecture 15) keeps tracking with the screen off, and the screen costs more than the GPS.
  • Test a full-length session on a real phone before the field test, with the battery percentage noted at start and end.

In the final project​

  • FusedLocationClient behind LocationClient (LocationModule) is the only place that talks to the FLP. The foreground service (lecture 15) collects trackedLocations(settingsRepository.settings, locationClient, trackingRepository).
  • Start runs permission (lecture 12) → location settings check → service start.
  • TrackMath, isPlausible and the formatters produce the numbers under the map: covered distance, direct distance from start, CP and WP, elapsed time, and pace in min/km for all three sections.
  • TrackingRepository holds the live session: track, checkpoints, the single waypoint and incrementally updated statistics. The map (lecture 16) and the notification (lecture 14) both observe it.
  • Settings.gpsIntervalMs is applied live through flatMapLatest. Implausible fixes are filtered before they reach the track.
  • Test with emulator routes and GPX replays, then on a real phone outdoors.

Self preparation QA​

  1. What does the fused location provider add over reading GPS directly? — It combines GNSS, Wi-Fi, cell and sensors, picks sources based on the requested priority, shares work between apps, and saves battery.
  2. When do you use lastLocation, getCurrentLocation and requestLocationUpdates? — lastLocation for an instant, possibly stale or null position (initial map centre). getCurrentLocation for one fresh fix. requestLocationUpdates for a stream, which is what tracking needs.
  3. What do the interval, setMinUpdateIntervalMillis and setMinUpdateDistanceMeters control? — The desired update period, the fastest rate you accept when locations are computed anyway, and the minimum movement between delivered updates.
  4. Why is locationUpdates written with callbackFlow, and what does awaitClose do? — It converts the register/unregister callback API into a cold flow. awaitClose keeps it open and removes the callback when the collector is cancelled, so updates can't leak.
  5. The user granted location, but nothing arrives. What is a likely cause, and how does the app handle it? — Location services are switched off. SettingsClient.checkLocationSettings throws a ResolvableApiException, whose resolution is launched with StartIntentSenderForResult to show "turn on location".
  6. What is the difference between travelled and direct distance? — Travelled is the sum of the track segments from the section start. Direct is the straight-line distance from the section start (start, CP or WP) to the current position.
  7. How is pace in min/km computed, and why can it be undefined? — Pace = duration / distance in km, e.g. 330 s / 0.851 km = 6:28 min/km. With no distance yet (right after a CP) there's nothing to divide by, so the UI shows --:--.
  8. Which fixes does isPlausible reject? — Fixes with poor accuracy, fixes not newer than the previous accepted one, and fixes that would require an impossible speed from the previous accepted point.
  9. Why is TrackMath implemented with haversine instead of Location.distanceBetween? — Haversine keeps the domain layer pure Kotlin and unit-testable on the JVM. distanceBetween is an Android stub there. The error is under 0.5 %, well below GPS noise.
  10. How does a change of the GPS interval setting reach the running location request? — The settings flow is mapped to the interval, distinctUntilChanged drops unrelated changes, and flatMapLatest cancels the old locationUpdates flow (unregistering its callback) and starts a new one.