This is the implementation material behind the run recording interview case. It covers:
- the local tables and the batched insert
- the Android location request and foreground service
- the iOS recorder
- a distance filter with haversine, and the tests for the 13 km example
- recovery after a kill
- an upload that is safe to repeat
- privacy notes and platform limits
None of it belongs in a 45-minute round (the length I assume). I draw the two tables, the states and the recorder, and write code only if asked. Checked on 16 October 2026:
- The SQL schema ran in SQLite. A batch written twice left one row per point, the recovery update found the run and kept its last point, a repeated chunk left one row, and a delete removed the points too. It was not run on Room.
- The distance logic ran in Python, a line-for-line copy of the Kotlin. A synthetic 10 km run in 3,000 steps read 12,999.9996 m raw and 10,000.002 m filtered. Four more checks passed (standing still, bad accuracy, a jump, a gap).
- The Kotlin was not compiled. There was no Kotlin compiler on the machine. The Swift was not compiled, because I had no Swift toolchain. Compile everything before you copy it.
- The platform facts that the case page took from the official pages are marked with their links. Other API names are from my own knowledge and I did not fetch their pages again.
1. The local database
Two tables, as in the case page, plus one more for the server in section 7. The key of track_point is (run_id, seq), so a batch that is written twice is harmless. Rows in track_point are only added.
The case page lists four states. I add interrupted here, because the state diagram in the case shows it. A kill leaves the row at recording, and the next start changes it to interrupted (section 6).
CREATE TABLE run (
run_id TEXT PRIMARY KEY,
state TEXT NOT NULL CHECK (state IN ('recording','paused','interrupted','finished','uploaded')),
started_at INTEGER NOT NULL,
ended_at INTEGER,
segment INTEGER NOT NULL DEFAULT 0,
next_seq INTEGER NOT NULL DEFAULT 0,
distance_m REAL,
chunks_sent INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE track_point (
run_id TEXT NOT NULL REFERENCES run(run_id) ON DELETE CASCADE,
seq INTEGER NOT NULL,
segment INTEGER NOT NULL,
t INTEGER NOT NULL,
lat REAL NOT NULL,
lon REAL NOT NULL,
accuracy REAL NOT NULL,
speed REAL,
PRIMARY KEY (run_id, seq)
) WITHOUT ROWID;
CREATE TABLE run_chunk (
run_id TEXT NOT NULL,
chunk_no INTEGER NOT NULL,
body TEXT NOT NULL,
PRIMARY KEY (run_id, chunk_no)
);What the SQLite checks showed:
- Ten points, then a second batch that repeats five of them: 15 rows, not 20.
- A state outside the five is refused by the
CHECK. - Deleting the run deletes its points (
ON DELETE CASCADE, with foreign keys turned on).
The same tables in Room. Not compiled.
@Entity(tableName = "run")
data class RunEntity(
@PrimaryKey @ColumnInfo(name = "run_id") val runId: String, // a UUID made on the phone
val state: String, // RunState name
@ColumnInfo(name = "started_at") val startedAt: Long,
@ColumnInfo(name = "ended_at") val endedAt: Long?,
val segment: Int,
@ColumnInfo(name = "next_seq") val nextSeq: Int,
@ColumnInfo(name = "distance_m") val distanceM: Double?,
@ColumnInfo(name = "chunks_sent") val chunksSent: Int,
)
@Entity(
tableName = "track_point",
primaryKeys = ["run_id", "seq"],
foreignKeys = [ForeignKey(RunEntity::class, ["run_id"], ["run_id"], onDelete = ForeignKey.CASCADE)],
)
data class TrackPointEntity(
@ColumnInfo(name = "run_id") val runId: String,
val seq: Int,
val segment: Int,
val t: Long,
val lat: Double,
val lon: Double,
val accuracy: Float,
val speed: Float?,
)
enum class RunState { RECORDING, PAUSED, INTERRUPTED, FINISHED, UPLOADED }
@Dao
abstract class RunDao {
// IGNORE: a batch that is written twice leaves one row per (run_id, seq)
@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insertPoints(points: List<TrackPointEntity>)
@Query("UPDATE run SET next_seq = :nextSeq WHERE run_id = :runId")
abstract suspend fun setNextSeq(runId: String, nextSeq: Int)
// One transaction per batch: the points and the counter move together.
@Transaction
open suspend fun saveBatch(runId: String, points: List<TrackPointEntity>) {
if (points.isEmpty()) return
insertPoints(points)
setNextSeq(runId, points.last().seq + 1)
}
@Query("UPDATE run SET state = :state WHERE run_id = :runId")
abstract suspend fun setState(runId: String, state: String)
@Query("SELECT * FROM run WHERE state = 'RECORDING'")
abstract suspend fun stillRecording(): List<RunEntity>
}Why it looks like this:
- One transaction per batch moves the points and
next_seqtogether. A crash keeps both or neither. - The batch is about 10 seconds of fixes, so about 10 rows and 360 writes an hour at one fix a second. This is the number from the case page.
segmentgoes up by one on resume. Distance is counted inside a segment only.
2. Android: request and service
2.1 Manifest
These are the permissions the case page names. POST_NOTIFICATIONS is a runtime permission on Android 13 and newer, so ask for it before Start. Not compiled; it is a fragment.
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_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" />
<application ...>
<service
android:name=".RecorderService"
android:exported="false"
android:foregroundServiceType="location" />
</application>
</manifest>2.2 Check, then start
// Called from the visible app, when the user taps Start.
fun canStartRecording(ctx: Context): Boolean {
val fine = ContextCompat.checkSelfPermission(ctx, Manifest.permission.ACCESS_FINE_LOCATION)
return fine == PackageManager.PERMISSION_GRANTED
// Coarse only means "approximate": ask again with a reason, or do not start.
// On Android 13 and newer, ask for POST_NOTIFICATIONS before this, so the user can see the notification.
}
suspend fun startRun(ctx: Context, dao: RunDao) {
val runId = UUID.randomUUID().toString()
dao.insert(RunEntity(runId, RunState.RECORDING.name, System.currentTimeMillis(), null, 0, 0, null, 0))
val intent = Intent(ctx, RecorderService::class.java).putExtra("runId", runId)
ContextCompat.startForegroundService(ctx, intent) // only while the app is visible
}The order matters. Write the row first, then start the service. If the service never gets a fix, the row says recording and the screen says "Waiting for GPS".
2.3 The foreground service
The location request is a LocationRequest.Builder with a priority and an interval, plus a minimum update distance. I ask for high accuracy at one fix a second. A hike could use a longer interval. The service type goes in the same call that shows the notification. Not compiled against the real libraries (Play services location, AndroidX core, coroutines).
class RecorderService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val fixes = Channel<Location>(Channel.UNLIMITED)
private lateinit var fused: FusedLocationProviderClient
private lateinit var dao: RunDao
private var runId = ""
private var segment = 0
private var seq = 0
private val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
result.locations.forEach { fixes.trySend(it) }
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
runId = intent?.getStringExtra("runId") ?: return START_NOT_STICKY
ServiceCompat.startForeground(
this, NOTIFICATION_ID, buildNotification(), // the notification has a Stop action
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION,
)
fused = LocationServices.getFusedLocationProviderClient(this)
dao = db(this).runDao()
val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1_000L) // about 1 fix a second
.setMinUpdateIntervalMillis(1_000L)
.setMinUpdateDistanceMeters(2f) // skip fixes that did not move
.build()
try {
fused.requestLocationUpdates(request, callback, Looper.getMainLooper())
} catch (e: SecurityException) {
stopSelf() // permission was taken away: mark the run INTERRUPTED and tell the user
return START_NOT_STICKY
}
scope.launch { writeInBatches() }
return START_NOT_STICKY // see section 6: I do not plan around a restart
}
// Collect fixes, write them every 10 seconds in one transaction.
private suspend fun writeInBatches() {
val batch = ArrayList<TrackPointEntity>()
var last = SystemClock.elapsedRealtime()
while (true) {
val fix = withTimeoutOrNull(1_000L) { fixes.receive() }
if (fix != null && fix.hasAccuracy()) { // raw points: filtering happens at read time
batch += TrackPointEntity(runId, seq++, segment, fix.time, fix.latitude, fix.longitude,
fix.accuracy, if (fix.hasSpeed()) fix.speed else null)
}
if (SystemClock.elapsedRealtime() - last >= 10_000L) {
dao.saveBatch(runId, batch.toList()); batch.clear()
last = SystemClock.elapsedRealtime()
}
}
}
override fun onDestroy() {
fused.removeLocationUpdates(callback)
// Flush the last batch here before cancelling the scope (not shown).
scope.cancel()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
}- The callback only puts fixes in a channel. The slow work (the database) happens on another thread.
- The service stores raw fixes and does not filter. The filter in section 4 runs when the run is read.
- If
requestLocationUpdatesthrowsSecurityException, the permission is gone. Mark the run interrupted and say so. - The Stop action on the notification calls
stopSelf()after a final flush.
3. iOS: the same job
The recorder is a class that owns the CLLocationManager. The Info.plist keys come first. The case page names the background mode and the background activity session; the rest of the names here are from my own knowledge. Not compiled.
<key>UIBackgroundModes</key>
<array><string>location</string></array>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Records the route and distance of your run.</string>
<key>NSLocationTemporaryUsageDescriptionDictionary</key>
<dict>
<key>RunRecording</key>
<string>A run needs your exact position to measure distance.</string>
</dict>import CoreLocation
struct Fix {
let time: Date, lat: Double, lon: Double, accuracy: Double, speed: Double?
}
protocol PointStore {
func append(_ fixes: [Fix], runId: String) async // one transaction per batch
}
final class Recorder: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
private var session: CLBackgroundActivitySession?
private let store: PointStore
private var runId = ""
private var buffer: [Fix] = []
private var flushTimer: Timer?
init(store: PointStore) {
self.store = store
super.init()
manager.delegate = self
manager.activityType = .fitness // lets the system pick sensible behaviour for a workout
manager.desiredAccuracy = kCLLocationAccuracyBest
manager.distanceFilter = 2 // metres, like the Android request
manager.pausesLocationUpdatesAutomatically = false // do not stop on its own when I stand at a light
manager.allowsBackgroundLocationUpdates = true // needs the "Location updates" background mode
manager.showsBackgroundLocationIndicator = true // the blue indicator
}
/// Call from the visible app, when the user taps Start.
func start(runId: String) {
self.runId = runId
session = CLBackgroundActivitySession() // create it while the app is in the foreground
manager.startUpdatingLocation()
flushTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in self?.flush() }
}
func stop() {
manager.stopUpdatingLocation()
flushTimer?.invalidate()
flush()
session?.invalidate()
session = nil
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
for l in locations where l.horizontalAccuracy >= 0 { // negative means the fix is not valid
buffer.append(Fix(time: l.timestamp, lat: l.coordinate.latitude, lon: l.coordinate.longitude,
accuracy: l.horizontalAccuracy, speed: l.speed >= 0 ? l.speed : nil))
}
}
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
switch manager.authorizationStatus {
case .denied, .restricted:
break // mark the run INTERRUPTED and tell the user
default:
if manager.accuracyAuthorization == .reducedAccuracy {
// A run needs full accuracy. Ask once, with a reason that is in Info.plist.
manager.requestTemporaryFullAccuracyAuthorization(withPurposeKey: "RunRecording")
}
}
}
private func flush() {
let out = buffer; buffer.removeAll()
guard !out.isEmpty else { return }
let id = runId
Task { await store.append(out, runId: id) }
}
}What each line is for:
activityType = .fitnesstells the system this is a workout.pausesLocationUpdatesAutomatically = falsestops the system from pausing updates when it thinks I stopped moving. A runner at a traffic light is still recording.allowsBackgroundLocationUpdatesneeds the background mode.showsBackgroundLocationIndicatorshows the blue indicator, so the user can see that the app uses location.CLBackgroundActivitySessionis created while the app is in the foreground. It also covers When In Use authorization, as the case page says.- A negative
horizontalAccuracymeans the fix is not valid. It is dropped before it reaches the buffer. - With reduced accuracy, the recorder asks once for temporary full accuracy. If the user says no, do not start a run, as on Android.
The newer API does the same job with an async sequence (iOS 17 and newer). Not compiled.
// iOS 17 and newer: the same job with an async sequence. Not compiled.
func record(runId: String, store: PointStore) async throws {
let session = CLBackgroundActivitySession()
defer { session.invalidate() }
for try await update in CLLocationUpdate.liveUpdates(.fitness) {
guard let l = update.location, l.horizontalAccuracy >= 0 else { continue }
// add to the buffer, flush every 10 seconds, as above
}
}4. The distance filter
This runs when a run is read, from the raw points. It has three filters and one rule about gaps, the same as the case page:
- Drop a fix with accuracy worse than 50 m, or with an invalid (negative) accuracy.
- Drop a fix that would need more than 12 m/s from the last kept fix. This is my starting number for a run.
- Count a move only if it is at least 5 m from the last counted fix.
- Never count a step between two segments.
Haversine is the distance along the surface of a sphere with radius 6,371,000 m. It is close enough for a run. It can be off by a fraction of a percent, because the Earth is not a sphere. Not compiled (Kotlin); the Python copy ran.
package run
import kotlin.math.*
data class Fix(val segment: Int, val tMs: Long, val lat: Double, val lon: Double, val accuracyM: Float)
const val EARTH_RADIUS_M = 6_371_000.0
const val MAX_ACCURACY_M = 50f // my starting values, to tune with real runs
const val MAX_SPEED_MPS = 12.0 // faster than a person can sprint, for a run
const val MIN_MOVE_M = 5.0
fun haversineM(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val p1 = Math.toRadians(lat1)
val p2 = Math.toRadians(lat2)
val dp = p2 - p1
val dl = Math.toRadians(lon2 - lon1)
val a = sin(dp / 2).pow(2) + cos(p1) * cos(p2) * sin(dl / 2).pow(2)
return 2 * EARTH_RADIUS_M * asin(sqrt(a))
}
/** Sum of every step, no filter. This is the number that reads 13 km. */
fun rawDistanceM(fixes: List<Fix>): Double {
var total = 0.0
for (i in 1 until fixes.size) {
val a = fixes[i - 1]; val b = fixes[i]
if (a.segment == b.segment) total += haversineM(a.lat, a.lon, b.lat, b.lon)
}
return total
}
/** Distance inside each segment, from the raw points, with the three filters. */
fun filteredDistanceM(fixes: List<Fix>): Double {
var total = 0.0
var last: Fix? = null
for (f in fixes) {
if (f.accuracyM < 0 || f.accuracyM > MAX_ACCURACY_M) continue // invalid or too vague
val prev = last
if (prev == null || prev.segment != f.segment) { last = f; continue } // a gap is never crossed
val d = haversineM(prev.lat, prev.lon, f.lat, f.lon)
val seconds = (f.tMs - prev.tMs) / 1000.0
if (seconds > 0 && d / seconds > MAX_SPEED_MPS) continue // impossible jump
if (d < MIN_MOVE_M) continue // smaller than the noise
total += d
last = f
}
return total
}5. Tests: the 13 km example
The case page says a 10 km run can read as 13 km: 3,000 segments, each with 1 m of noise, add 3,000 m. The test builds that run and checks both numbers. The test run is 10 km east in 3,000 steps of 3.33 m. Every other point is moved a little north or south, so every step is 1 m longer than the real one.
package run
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.sqrt
const val M_PER_DEG = 111_194.93 // metres in one degree of latitude on this sphere
fun near(actual: Double, expected: Double, tol: Double) =
check(abs(actual - expected) <= tol) { "expected $expected, got $actual" }
fun fix(seg: Int, tSec: Int, northM: Double, acc: Float = 8f) =
Fix(seg, tSec * 1000L, 45.0 + northM / M_PER_DEG, 7.0, acc)
/** 10 km east in 3,000 steps. Every other point sits a little north or south,
* so every step is 1 m longer than the real 3.33 m. */
fun noisyTenKm(): List<Fix> {
val step = 10_000.0 / 3_000
val a = sqrt((step + 1.0) * (step + 1.0) - step * step) / 2
return (0..3_000).map { i ->
val lon = 7.0 + i * step / (M_PER_DEG * cos(Math.toRadians(45.0)))
Fix(0, i * 1000L, 45.0 + (if (i % 2 == 1) a else -a) / M_PER_DEG, lon, 8f)
}
}
fun main() {
near(haversineM(0.0, 0.0, 1.0, 0.0), 111_195.0, 5.0)
val run = noisyTenKm()
near(rawDistanceM(run), 13_000.0, 5.0) // 3,000 steps x 1 m of noise: the 13 km run
near(filteredDistanceM(run), 10_000.0, 5.0) // back to 10 km
// standing still adds nothing
val still = (0 until 60).map { fix(0, it, (it % 2) * 0.5) }
check(filteredDistanceM(still) == 0.0)
// a vague fix and an invalid fix are dropped
val bad = listOf(fix(0, 0, 0.0), fix(0, 1, 200.0, 200f), fix(0, 2, 10.0), fix(0, 3, 20.0, -1f), fix(0, 4, 30.0))
near(filteredDistanceM(bad), 30.0, 0.5)
// a 200 m jump in one second is dropped; the next real fix counts from the last kept one
val jump = listOf(fix(0, 0, 0.0), fix(0, 1, 200.0), fix(0, 2, 8.0))
near(filteredDistanceM(jump), 8.0, 0.1)
// a pause or a kill starts segment 1: the 5 km hop between segments is not counted
val gap = listOf(fix(0, 0, 0.0), fix(0, 1, 10.0), fix(1, 600, 5_000.0), fix(1, 601, 5_010.0))
near(filteredDistanceM(gap), 20.0, 0.2)
println("all passed")
}What the Python copy printed on 16 October 2026:
- True line from first to last point: 10,000.0008 m.
- Raw sum of steps: 12,999.9996 m, so 13 km.
- Filtered: 10,000.002 m.
- One degree of latitude: 111,194.93 m.
- The four other checks and the haversine check passed.
Be careful with this result:
- The noise is a regular zigzag that I built so the sum comes out at exactly 13 km. Real noise is random and uneven.
- The filter returns exactly 10 km here because it keeps every second point, and those lie on a straight line. On a real run the filtered number will be close to the true one, not equal to it.
- The test shows the logic works. It does not show that 50 m, 12 m/s and 5 m are the right numbers. I tune those with real runs.
6. Recovery after a kill
6.1 Android
- The service returns
START_NOT_STICKYabove. I did not verify whether or when Android restarts a location service after a kill, and a restart of a foreground service from the background is limited on newer versions. So I do not plan around one. - The recovery is from the database, on the next app start. It is the path in the case page: find a row that says
recordingwhile no recorder is running. - At most the last batch (about 10 seconds) is lost.
// Run at every app start, before any screen reads a run.
suspend fun recoverRuns(dao: RunDao, serviceIsRunning: Boolean) {
if (serviceIsRunning) return
for (run in dao.stillRecording()) {
// Nothing is recording, but the row says it is: the process died.
dao.setState(run.runId, RunState.INTERRUPTED.name)
// The screen now shows the run up to its last point and offers Resume or Finish.
}
}
// Resume: the old segment ends at the last saved point. The new one starts at the next fix.
suspend fun resume(dao: RunDao, run: RunEntity) {
dao.setSegment(run.runId, run.segment + 1)
dao.setState(run.runId, RunState.RECORDING.name)
// then start the service again, from the visible app
}The SQLite update from recording to interrupted found the run, and its last point was still there. setSegment, and insert, get, pointsBetween and setChunksSent in section 7, are more small DAO methods that are not shown.
6.2 iOS
- The system can terminate the app at any time. On relaunch, restart the location APIs, and the system resumes delivery of queued updates (Apple, as the case page says).
- An app that was terminated is relaunched in the background only by a few location services. As I know it, those are significant-change monitoring and visit monitoring, not normal continuous updates. If you use them as a safety net, create the manager and the sessions again as soon as the app launches.
- If the user force quits the app, I understand that this relaunch stops as well. Treat a force quit as the end of the recording until the user opens the app again. Not verified on a device, so test it on each OS version.
- So the recovery is the same as on Android: the next start finds the
recordingrow and offers Resume or Finish.
7. Upload with idempotency
The run waits on the phone until it is uploaded. The recording id is the idempotency key, and the chunk number is the key of one chunk. A resend of the same chunk is a no-op on the server, as in the idempotency keys guide.
POST /v1/runs/{runId}/chunks/{chunkNo}
{ "points": [ { "seq": 0, "segment": 0, "t": 1760601600000, "lat": 45.0, "lon": 7.0, "acc": 8.0 }, ... ] }
200 { "stored": true } first time
200 { "stored": false } a repeat; same answer, nothing changed
503 try again later
POST /v1/runs/{runId}/complete
{ "chunkCount": 12, "startedAt": 1760601600000, "endedAt": 1760605200000, "distanceM": 10012.4 }
200 { "state": "uploaded" }
409 { "missing": [3, 7] } the server lacks these chunks; send them, then call complete againThe server side of one chunk. It ran in SQLite: two inserts of chunk 0 left one row.
-- Server. One row per (run, chunk). A repeat changes nothing.
INSERT INTO run_chunk (run_id, chunk_no, body)
VALUES (:run_id, :chunk_no, :body)
ON CONFLICT (run_id, chunk_no) DO NOTHING;The client is a WorkManager job. A retry sends the same bytes, because chunk n is always the points with seq in the same range. Not compiled.
class UploadWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result {
val runId = inputData.getString("runId") ?: return Result.failure()
val dao = db(applicationContext).runDao()
val api = Api.create()
return try {
val run = dao.get(runId)
val chunkCount = (run.nextSeq + CHUNK - 1) / CHUNK
for (n in run.chunksSent until chunkCount) {
// Chunk n is always the points with seq in [n * CHUNK, (n + 1) * CHUNK): same bytes on a retry.
api.putChunk(runId, n, dao.pointsBetween(runId, n * CHUNK, (n + 1) * CHUNK))
dao.setChunksSent(runId, n + 1) // only after a 200
}
api.complete(runId, chunkCount, run.startedAt, run.endedAt!!, run.distanceM!!)
dao.setState(runId, RunState.UPLOADED.name)
Result.success()
} catch (e: IOException) {
Result.retry() // no network or a 5xx: WorkManager tries again later
}
}
companion object { const val CHUNK = 300 }
}
fun enqueueUpload(ctx: Context, runId: String) {
val request = OneTimeWorkRequestBuilder<UploadWorker>()
.setInputData(workDataOf("runId" to runId))
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.build()
WorkManager.getInstance(ctx).enqueueUniqueWork("upload-$runId", ExistingWorkPolicy.KEEP, request)
}chunks_sentgoes up only after a 200. If the app dies after the server stored the chunk but before the counter moved, the next try sends it again and the server answers "stored: false".- The run is
finisheduntil the last call succeeds, and thenuploaded. - The 409 from
completelists missing chunks, so the server can ask for exactly what it lacks. - On iOS the same contract works. Use a background URLSession for the uploads, so the system can finish them when the app is not running. I did not write that code here.
8. Privacy
A route shows where someone lives and works. This is design advice, not legal advice. Rules differ by country.
- Privacy zones. Let the user mark places, such as home. Hide the first and last part of a run inside a zone (for example 200 m) from anything others can see. Cut it on the server before sharing, and keep the full run for the owner. The radius is a setting.
- Delete. One action deletes the run on the phone (the foreign key removes its points), on the server, and any copy made for sharing or analysis. Tell the user what stays, if anything.
- Retention. Keep raw points only as long as they are useful. My default: on the phone until the upload is done plus a short grace period, and on the server as long as the account has the run, unless the user deletes it. Say the period in the privacy text.
- Ask for less. Use When In Use on iOS and avoid the background permission on Android, as the case page does. Start the recording only when the user taps Start.
- Show it. The Android notification and the iOS blue indicator tell the user that location is in use.
- Keep it off the logs. No coordinates in crash reports or analytics events.
9. Platform limits
| Topic | Android | iOS | Source |
|---|---|---|---|
| Background updates | A background app gets location updates only a few times each hour. A foreground service counts as foreground access. | The system suspends most apps shortly after they leave the foreground. The Location updates background mode and a background activity session keep delivery going. | Android: background location limits; Apple: handling location updates in the background |
| Starting the recorder | A location foreground service cannot be started from the background without ACCESS_BACKGROUND_LOCATION. Start it from the visible app. | Create the background activity session and the service session while the app is in the foreground. After a background relaunch, create them again right away. | Android: foreground service types; Apple: handling location updates in the background |
| What the manifest or plist needs | Service type location, FOREGROUND_SERVICE, FOREGROUND_SERVICE_LOCATION, and the coarse or fine location permission. POST_NOTIFICATIONS on Android 13 and newer. | Background mode location in Info.plist and a When In Use usage string. A temporary full-accuracy purpose key is also needed if you ask for it. | Android: foreground service types; my knowledge of the Info.plist keys, not re-fetched |
| Approximate or reduced location | Android 12 and newer: the user can grant approximate only (about 3 square kilometres, against about 50 metres or better). A run needs precise. | accuracyAuthorization == .reducedAccuracy. Ask for temporary full accuracy with a purpose key, or do not start. | Android: location permissions; my knowledge for the iOS names, not re-fetched |
| Bad fixes | Check hasAccuracy() and the accuracy value. Filter at read time. | A negative horizontalAccuracy means the fix is not valid. Drop it. | My knowledge, not re-fetched |
| Battery Saver | If the location power saver mode is foreground only, a foreground service still gets updates with the screen off. | Not applicable. Low Power Mode behaviour not checked here. | Android: location permissions |
| After a kill | I do not plan around the system restarting the service. Recover from the database on the next start. | The system can terminate an app at any time. Only significant-change or visit monitoring can relaunch it. A force quit by the user stops even that, as I read it. Not tested on a device. | Apple: handling location updates in the background; the rest not verified |
| User sees it | A foreground service notification, with a Stop action. | The blue indicator when the app uses location in the background (showsBackgroundLocationIndicator). | My knowledge, not re-fetched |
The rows with a link come from the pages I read for the case page on 16 October 2026. The others are from my own knowledge, and I did not fetch their pages for this guide. Check every API name against the version you use.
10. More practice prompts
- Change the batch from 10 seconds to 30. What do you gain and what is the most you can lose in a kill?
- Add auto-pause. Which state does it use, and does the recorder keep running?
- The user rides for 3 hours at one fix a second. How big is the upload, and how many chunks of 300 points is that?
- A privacy zone must apply to old runs when the user adds it. Where do you cut, and what do you keep?
- Two runs both say
recordingafter a crash. What does the next start do?
Further reading (official docs)
- The interview part of this case: scope, decisions, failures, follow-ups and levels
- Guide: idempotency keys
- Android: location permissions
- Android: foreground service types
- Android: background location limits
- Apple: handling location updates in the background
Verification notes (last reviewed 16 Oct 2026)
Facts from the official pages (read for the case page on 2026-10-16, not fetched again):
- Android location permissions: foreground access includes a running foreground service; approximate is about 3 square kilometres and precise is usually about 50 metres or better; the foreground-only power saver mode keeps updates for a foreground service.
- Android foreground service types: the location type needs FOREGROUND_SERVICE_LOCATION and the base FOREGROUND_SERVICE permission; a location foreground service cannot be created from the background without ACCESS_BACKGROUND_LOCATION.
- Android background location limits: a background app gets updates only a few times each hour.
- Apple background updates: the Location updates mode, CLBackgroundActivitySession and CLServiceSession created in the foreground; the system can terminate an app at any time, and on relaunch the location APIs are restarted.
Code checks.
- The SQL schema, the batch insert with ignore, the recovery update, the chunk insert and the cascade delete ran in SQLite (Python sqlite3).
- The distance logic ran in Python: raw 12,999.9996 m and filtered 10,000.002 m for the synthetic run, and four more checks passed.
- The Kotlin has the same logic but was not compiled. The Room code, the service, the worker and all Swift were not compiled.
Not verified.
- Names and signatures of the Android location, Room, WorkManager and Core Location calls, which I wrote from memory. The ones I am least sure of:
CLLocationUpdate.liveUpdates(.fitness), the temporary full-accuracy key in Info.plist, and the Room@Transactionon an open suspend function. - Whether Android restarts the service, and what significant-change or visit monitoring does after a force quit on each iOS version.
- How phone makers add their own battery limits.
My own opinion or design.
- The schema, the interrupted state, the chunk contract, the 300-point chunk, the filter values and the privacy defaults are my design.
Get the free one-page cheat sheet
One page: the five steps, a 45-minute time plan and two checklists. Print it or keep it on your phone.
We email you the cheat sheet after you confirm your address. You can also open it now from the Start here page. You get a short note when a new case is published. Unsubscribe any time. Privacy.