15 - Foreground Service
Recap
14 - Notifications built TrackingNotification: the custom lock-screen layout, CP/WP broadcast buttons, and a Stop button that opens the app for confirmation. 13 - Location gave you LocationClient, and 10 - App Architecture, ViewModel & Hilt gave you TrackingRepository. One piece is missing: something that keeps collecting GPS fixes for two or three hours while the screen is off and the activity may be long gone. That's a foreground service. You met plain services in 02 - Android App. This lecture makes one that modern Android doesn't kill, and wires it to the UI and the notification.
By the end of this lecture you should be able to:
- Explain why background work is restricted and when a foreground service is the right tool.
- Declare a
locationforeground service with the permissions Android 14+ requires. - Start it from the UI and promote it with
ServiceCompat.startForegroundin time. - Write
TrackingServiceas a HiltLifecycleServicethat collects locations with coroutines and keeps the notification up to date. - Share the live session with the UI through a singleton repository, and compare that with a bound service.
- Implement the stop-confirmation handshake between notification, activity and ViewModel.
- Choose a start mode, handle restarts and user-initiated stops, and know the restrictions per Android version.
- Test a multi-hour session.
Lecture demos: native-2026-fall
Why a foreground service
When the user switches apps or locks the screen, your activity stops. Its process becomes a background process, and Android limits what such a process may do:
- Background service limits (Android 8+): a started service in a background app is stopped after about a minute.
- Background location limits (Android 8+): background apps get location updates only a few times per hour.
- Doze and App Standby: when the device is idle, background apps lose network access, jobs and alarms are deferred, and wake locks are ignored.
- Process death: background processes are the first to be killed when memory is low.
A foreground service tells the system: the user started this and is aware of it. In exchange for a mandatory, visible notification, the process keeps foreground priority. It isn't put into App Standby and isn't killed to free memory, and a location-type service keeps receiving location updates with the screen off.
The final project spells it out: "GPS listener and notification intents broadcast listener has to be implemented in service, otherwise app cannot be kept running in background."
| Work | Tool |
|---|---|
| user-started, ongoing, noticeable: tracking a run, music, a call | foreground service |
| deferrable, must eventually finish: upload unsynced data, periodic refresh | WorkManager (lecture 19) |
| react while the app is visible | coroutines in viewModelScope / lifecycleScope |
Declaring the service
<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 ...>
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop">
...
</activity>
<service
android:name=".service.TrackingService"
android:exported="false"
android:foregroundServiceType="location" />
</application>
</manifest>
FOREGROUND_SERVICEandFOREGROUND_SERVICE_LOCATIONare normal permissions, granted at install. Without themstartForegroundthrows aSecurityException.foregroundServiceTypeis mandatory from Android 14. The type must match what the service does, and each type has its own permission.- The runtime location permission (lecture 12) must already be granted before you call
startForeground. Android 14+ checks it and throws aSecurityExceptionotherwise. exported="false": no other app may start or bind your service.
The available types:
| Type | For |
|---|---|
location | navigation, location sharing, tracking a route |
health | fitness and health sensors (heart rate, step counting) |
dataSync | data transfer (limited to 6 h per day since Android 15) |
mediaPlayback, mediaProcessing, mediaProjection | audio/video |
camera, microphone, phoneCall, connectedDevice, remoteMessaging | as the names say |
shortService | critical work that finishes within about 3 minutes |
specialUse | anything else, with a written justification for Google Play |
SportMap reads GPS, so its type is location. Google Play asks you to declare and justify foreground service types when you publish. A fitness tracker with real heart-rate sensors would add health (location|health).
The big picture
Note who talks to whom. The UI never calls the service's methods, and the service never touches the UI. Both talk to TrackingRepository, and commands travel as intents.
Starting from the UI
Commands are plain intents. A small injectable class keeps the intent plumbing out of ViewModels, and it's also where the permission guard lives:
class TrackingCommands @Inject constructor(
@ApplicationContext private val context: Context
) {
/** Permissions the service needs; empty when tracking can start. */
fun missingPermissions(): List<String> = buildList {
val location = listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
if (location.none { isGranted(it) }) add(Manifest.permission.ACCESS_FINE_LOCATION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !isGranted(Manifest.permission.POST_NOTIFICATIONS)) {
add(Manifest.permission.POST_NOTIFICATIONS)
}
}
/** Starts tracking. Starts nothing and returns false if a permission is missing. */
fun start(): Boolean {
if (missingPermissions().isNotEmpty()) return false
ContextCompat.startForegroundService(context, serviceIntent(TrackingActions.START))
return true
}
fun stop() {
context.startService(serviceIntent(TrackingActions.STOP))
}
fun addCheckpoint() = broadcast(TrackingActions.ADD_CP)
fun addWaypoint() = broadcast(TrackingActions.ADD_WP)
private fun isGranted(permission: String) =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
private fun serviceIntent(action: String) =
Intent(context, TrackingService::class.java).setAction(action)
private fun broadcast(action: String) =
context.sendBroadcast(Intent(action).setPackage(context.packageName))
}
startForegroundServicecreates the service and promises the system that it will callstartForegroundshortly.- The permission check before
startForegroundServiceis the real guard. The UI runs the permission flow from lecture 12 first, andstart()refuses if something is still missing. Location (fine or approximate) is required, otherwisestartForegroundthrows and the app crashes (see the 5-second rule).POST_NOTIFICATIONSexists only on Android 13+. Without it the service would run, but the lock-screen controls the project needs would be invisible. - The application context is fine, because the app is visible when the user taps Start. Starting a foreground service from the background has been forbidden since Android 12 and throws
ForegroundServiceStartNotAllowedExceptionright here, in the caller. That's why Start lives only in the app UI. - The UI's CP/WP buttons send the same broadcasts as the notification buttons. The service is the single place where checkpoints and waypoints are created and saved, whoever pressed the button.
TrackingService
LifecycleService (from androidx.lifecycle:lifecycle-service) is a Service with a lifecycleScope. Its coroutines are cancelled in onDestroy, just like viewModelScope in onCleared.
androidx-lifecycle-service = { group = "androidx.lifecycle", name = "lifecycle-service", version.ref = "lifecycle" }
SessionRepository
The service saves the session as it goes. The repository is implemented with Room in lecture 18. Here only its interface matters:
interface SessionRepository {
fun sessions(): Flow<List<GpsSession>>
fun sessionDetail(id: Long): Flow<SessionDetail?>
suspend fun startSession(name: String, startedAt: Instant): Long
suspend fun addLocation(sessionId: Long, location: GpsLocation)
suspend fun finishSession(sessionId: Long, endedAt: Instant, overall: SectionStats)
suspend fun rename(id: Long, name: String)
suspend fun setPaceRange(id: Long, paceMinSecPerKm: Int?, paceMaxSecPerKm: Int?) // null = use Settings
suspend fun delete(id: Long)
suspend fun unfinishedSession(): SessionDetail? // endedAt == null
}
The service uses startSession, addLocation (for regular fixes, CPs and WPs alike, told apart by GpsLocation.type) and finishSession. The rest is for the sessions screens.
Dependencies and the CP/WP receiver
@AndroidEntryPoint
class TrackingService : LifecycleService() {
companion object {
private const val TAG = "TrackingService"
}
@Inject lateinit var trackingRepository: TrackingRepository
@Inject lateinit var locationClient: LocationClient
@Inject lateinit var settingsRepository: SettingsRepository
@Inject lateinit var sessionRepository: SessionRepository
@Inject lateinit var trackingNotification: TrackingNotification
private var trackingJob: Job? = null
private val notificationActionReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
TrackingActions.ADD_CP -> addCheckpoint()
TrackingActions.ADD_WP -> addWaypoint()
}
}
}
override fun onCreate() {
super.onCreate()
trackingNotification.createChannel()
val filter = IntentFilter().apply {
addAction(TrackingActions.ADD_CP)
addAction(TrackingActions.ADD_WP)
}
ContextCompat.registerReceiver(this, notificationActionReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
}
override fun onDestroy() {
unregisterReceiver(notificationActionReceiver)
super.onDestroy()
}
The system creates the service, so Hilt can't use constructor injection. @AndroidEntryPoint fills the @Inject lateinit var fields in super.onCreate(). The receiver is registered for exactly as long as the service exists: it's the "notification intents broadcast listener in the service".
Dispatching commands
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
when (intent?.action) {
TrackingActions.START -> start()
TrackingActions.STOP -> stop()
else -> stopSelf() // null intent or unknown action: nothing to do
}
return START_NOT_STICKY
}
Every startService/startForegroundService call ends up in onStartCommand. The first call also runs onCreate. LifecycleService requires the super call. The return value is explained in Start modes.
Starting: foreground first, work second
private fun start() {
if (trackingJob != null) return // already tracking
// 1. Promote to foreground immediately - within 5 seconds of startForegroundService()
try {
ServiceCompat.startForeground(
this,
TrackingNotification.NOTIFICATION_ID,
trackingNotification.build(trackingRepository.state.value),
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
)
} catch (e: Exception) { // logging and cleanup only - see the warning below
Log.e(TAG, "Cannot start foreground service", e)
stopSelf()
return
}
// 2. The actual work runs in coroutines tied to the service lifecycle
trackingJob = lifecycleScope.launch {
val startedAt = Instant.now()
val name = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
val sessionId = sessionRepository.startSession(name, startedAt)
trackingRepository.start(sessionId, startedAt)
launch { updateNotification() }
collectLocations(sessionId)
}
}
After startForegroundService() the service has 5 seconds to call startForeground() successfully. Otherwise the system crashes the app with ForegroundServiceDidNotStartInTimeException ("Context.startForegroundService() did not then call Service.startForeground()"). Call it synchronously in onStartCommand, before any database or network work.
The catch above does not save you. If startForeground throws, for example a SecurityException because location permission is missing on Android 14+, the promise is still unfulfilled. Stopping the service early doesn't cancel it: the platform still crashes the app with the same message. The catch is there to log the real cause and clean up, nothing more. The only real protection is not calling startForegroundService unless startForeground will succeed, which is why TrackingCommands.start() checks the permissions first.
ServiceCompat.startForeground passes the type on Android 10+ and works on all versions. The notification id must not be 0. FOREGROUND_SERVICE_IMMEDIATE (set in TrackingNotification) and the action buttons make the notification appear at once. Otherwise Android 12+ may delay a new service's notification by up to 10 seconds.
Collecting locations
private suspend fun collectLocations(sessionId: Long) {
trackedLocations(settingsRepository.settings, locationClient, trackingRepository)
.collect { location ->
trackingRepository.onLocation(location)
sessionRepository.addLocation(sessionId, location)
}
}
trackedLocations(...) is the pipeline from lecture 13:
- The GPS interval comes from settings (lecture 10), and
distinctUntilChangedignores unrelated settings changes. flatMapLatest(lecture 09) cancels the old location request and starts a new one when the user changes the interval mid-session. That's the "possible to change gps update frequency" requirement.toGpsLocation()converts each fix, andisPlausible()drops impossible jumps before they reach the track.
The service only decides where each accepted fix goes: to memory (live statistics for the UI and the notification) and to the database (history, lecture 18).
Cancelling trackingJob cancels the callbackFlow inside LocationClient, and its awaitClose removes the location request. You don't need any cleanup code.
Updating the notification
@OptIn(FlowPreview::class)
private suspend fun updateNotification() {
val manager = NotificationManagerCompat.from(this)
trackingRepository.state
.sample(1_000) // at most one update per second
.collect { state ->
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
) {
manager.notify(TrackingNotification.NOTIFICATION_ID, trackingNotification.build(state))
}
}
}
sample(1_000) (lecture 09) emits the latest state once per second, and only if it changed in that second. You get "every position update" with a 1–2 s GPS interval, capped at one notification per second, which stays under the system's update rate limit (lecture 14). The initial notification was already posted by startForeground. A StateFlow is conflated by itself: a slow collector always gets the latest value, never a backlog. Adding .conflate() to it is a compile error, because there's nothing to conflate.
Checkpoints and waypoints
private fun addCheckpoint() {
val sessionId = trackingRepository.state.value.sessionId ?: return
trackingRepository.addCheckpoint()?.let { cp ->
lifecycleScope.launch { sessionRepository.addLocation(sessionId, cp) }
}
}
private fun addWaypoint() {
val sessionId = trackingRepository.state.value.sessionId ?: return
trackingRepository.addWaypoint()?.let { wp ->
lifecycleScope.launch { sessionRepository.addLocation(sessionId, wp) } // type = WP, needed for GPX
}
}
addCheckpoint() and addWaypoint() (lecture 13) mark the last known position and return it, already with type = CP or WP. They return null before the first fix. onReceive runs on the main thread, so only the database write goes to a coroutine.
Both are saved. The spec says "WPs do not get saved to DB", but the GPX export (lecture 18) must contain the WP coordinates, so the course decision is to store every waypoint as a type = WP row. What the spec really means still holds: a waypoint is temporary. The repository keeps only the current one, the map shows only the current one, and old waypoints are never shown as permanent markers. Only checkpoints are.
Stopping
private fun stop() {
trackingJob?.cancel()
trackingJob = null
val state = trackingRepository.state.value
trackingRepository.stop()
lifecycleScope.launch {
state.sessionId?.let { sessionRepository.finishSession(it, Instant.now(), state.overall) }
ServiceCompat.stopForeground(this@TrackingService, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
}
stopForeground(STOP_FOREGROUND_REMOVE)leaves the foreground state and removes the notification.STOP_FOREGROUND_DETACHwould leave it visible but ordinary (dismissable).stopSelf()destroys the service, andonDestroyunregisters the receiver and cancelslifecycleScope. That's whystopSelf()comes after the database write inside the coroutine.- A forgotten
stopSelf()means a service that runs forever with a notification nobody can explain. That's a classic defence question.
Talking to the UI
Through the repository (SportMap)
The service writes TrackingRepository, and the map screen reads it:
data class MapUiState(
val tracking: TrackingState = TrackingState(),
val showStopConfirmation: Boolean = false,
)
@HiltViewModel
class MapViewModel @Inject constructor(
trackingRepository: TrackingRepository,
private val trackingCommands: TrackingCommands,
) : ViewModel() {
private val showStopConfirmation = MutableStateFlow(false)
val uiState: StateFlow<MapUiState> =
combine(trackingRepository.state, showStopConfirmation) { tracking, confirm ->
MapUiState(tracking = tracking, showStopConfirmation = confirm && tracking.isTracking)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), MapUiState())
fun onStartClick() { trackingCommands.start() } // UI ran the permission flow first (lecture 12)
fun onStopRequested() { showStopConfirmation.value = true }
fun onStopConfirmed() {
showStopConfirmation.value = false
trackingCommands.stop()
}
fun onStopDismissed() { showStopConfirmation.value = false }
fun onAddCheckpoint() = trackingCommands.addCheckpoint()
fun onAddWaypoint() = trackingCommands.addWaypoint()
}
This is the start/stop and confirmation part of MapViewModel. Lecture 16 adds the camera to the same class (mapState, onUserMovedMap(), onRecenter()), and lecture 17 adds the compass (compassDeg). Lecture 18 shows the complete class.
- The UI shows data whether or not it was alive when the session started. After rotation, unlocking or reopening from Recents, it simply collects the current state again. That's "UI is restored correctly after unlock" for free.
- The service doesn't know the UI exists.
- Both depend on a
@Singleton. If it weren't one, they'd see different objects (lecture 10).
Alternative: a bound service
A bound service hands out a reference to itself through an IBinder:
class BoundTrackingService : LifecycleService() {
inner class LocalBinder : Binder() {
fun service(): BoundTrackingService = this@BoundTrackingService
}
private val binder = LocalBinder()
val state: StateFlow<TrackingState> = MutableStateFlow(TrackingState())
override fun onBind(intent: Intent): IBinder {
super.onBind(intent)
return binder
}
}
@Composable
fun rememberBoundService(): BoundTrackingService? {
val context = LocalContext.current
var service by remember { mutableStateOf<BoundTrackingService?>(null) }
DisposableEffect(context) {
val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, binder: IBinder) {
service = (binder as BoundTrackingService.LocalBinder).service()
}
override fun onServiceDisconnected(name: ComponentName) { service = null }
}
context.bindService(Intent(context, BoundTrackingService::class.java), connection, Context.BIND_AUTO_CREATE)
onDispose { context.unbindService(connection) }
}
return service
}
| Repository | Bound service | |
|---|---|---|
| UI needs the service running to show data | no | yes |
| connection handling (bind, unbind, null while connecting, rotation) | none | all of it |
| ViewModel access | inject the repository | needs a binder from the UI layer, awkward |
| testability | fake repository | Android service required |
Binding is the right tool when another process or app must call your service, or when the service is the API (a music player's controller). For SportMap the repository is simpler. Use one or the other, not both.
The stop confirmation handshake
The notification's Stop button is an activity intent with action CONFIRM_STOP (lecture 14). It must end with the map screen showing a dialog. MainActivity turns the intent into state:
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val launchAction = MutableStateFlow<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState == null) launchAction.value = intent?.action // not again after rotation
enableEdgeToEdge()
setContent {
val action by launchAction.collectAsStateWithLifecycle()
SportMapTheme {
// lecture 11: puts MapKey on top for SHOW_MAP / CONFIRM_STOP and passes the action to MapRoute
SportMapNavigation(launchAction = action, onLaunchActionHandled = { launchAction.value = null })
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
launchAction.value = intent.action // singleTop: app already open
}
}
@Composable
fun MapRoute(
onOpenSessions: () -> Unit,
onOpenSettings: () -> Unit,
launchAction: String? = null,
onLaunchActionHandled: () -> Unit = {},
viewModel: MapViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(launchAction) {
if (launchAction == TrackingActions.CONFIRM_STOP) {
viewModel.onStopRequested()
onLaunchActionHandled()
}
}
if (state.showStopConfirmation) {
AlertDialog(
onDismissRequest = viewModel::onStopDismissed,
title = { Text("Stop session?") },
text = { Text("The session will be saved and tracking stops.") },
confirmButton = { TextButton(onClick = viewModel::onStopConfirmed) { Text("Stop") } },
dismissButton = { TextButton(onClick = viewModel::onStopDismissed) { Text("Continue") } },
)
}
// ... map, stats and the Start/Stop button (Stop also calls onStopRequested)
}
- The activity doesn't call the ViewModel. With Navigation 3 (lecture 11)
MapViewModelis scoped to the map entry, and the activity has no business holding it. - The action is consumed (
onLaunchActionHandled) so the dialog doesn't come back after rotation. - The UI's own Stop button calls
onStopRequested()too. There's one confirmation path for both.
Start modes, restarts and user stops
onStartCommand's return value tells the system what to do if it kills the service's process:
| Return | After the process is killed |
|---|---|
START_STICKY | recreate the service later and call onStartCommand with a null intent |
START_NOT_STICKY | don't recreate it |
START_REDELIVER_INTENT | recreate it and redeliver the last intent |
For a tracker, a sticky restart is less useful than it looks. The in-memory TrackingState is gone. The restart happens without any UI, and while-in-use location access from a background start is restricted (see the table below), so the restarted service may not get locations at all. SportMap therefore returns START_NOT_STICKY and handles null defensively by stopping. Durability comes from the database: every fix is saved as it arrives, so after a crash or kill the session is still there with endedAt == null. On the next app start, sessionRepository.unfinishedSession() finds it, and the app offers to finish it (lecture 18).
Since Android 13 users can stop any app with a foreground service from the Task Manager in the notification drawer. The system removes the whole app from memory, without any callback. On the next start you can find out why the process died, using an API that exists on all our devices (API 30):
fun logExitReasons(context: Context) {
val am = context.getSystemService(ActivityManager::class.java)
am.getHistoricalProcessExitReasons(context.packageName, 0, 5).forEach { info ->
val reason = when (info.reason) {
ApplicationExitInfo.REASON_USER_REQUESTED -> "stopped by the user (Task Manager / force stop)"
ApplicationExitInfo.REASON_LOW_MEMORY -> "low memory"
ApplicationExitInfo.REASON_CRASH -> "crash"
ApplicationExitInfo.REASON_ANR -> "ANR"
else -> "other (${info.reason}): ${info.description}"
}
Log.i("ExitInfo", "${Instant.ofEpochMilli(info.timestamp)} $reason")
}
}
An aggressive OEM battery manager typically shows up as "other" with a vendor description. That's a valuable clue after a failed field test.
Restrictions by Android version
minSdk 30 means everything up to Android 11 always applies. The rest depends on the device:
| Version | What changed for foreground services |
|---|---|
| 8 (26) | background service limits, startForegroundService, background location limits |
| 9 (28) | FOREGROUND_SERVICE permission |
| 10 (29) | foregroundServiceType="location" needed for location in a foreground service |
| 11 (30) | while-in-use permissions (location, camera, microphone) don't apply to a service started from the background |
| 12 (31) | no starting foreground services from the background (exemptions: notification actions, exact alarms, etc.), ForegroundServiceStartNotAllowedException; notifications may be delayed up to 10 s |
| 13 (33) | POST_NOTIFICATIONS; the Task Manager can stop apps with foreground services |
| 14 (34) | types mandatory, FOREGROUND_SERVICE_<TYPE> permissions, runtime prerequisites checked (location granted); ongoing notifications become dismissable |
| 15 (35) | dataSync and mediaProcessing limited to 6 h per 24 h (onTimeout), location has no such limit; BOOT_COMPLETED receivers can't start dataSync, camera, mediaPlayback, phoneCall, mediaProjection or microphone services; the SYSTEM_ALERT_WINDOW exemption needs a visible overlay |
| 16 (36) | jobs started while a foreground service runs obey normal job quotas. Upload in-session data from the service itself, not via a stream of WorkManager jobs (lecture 19) |
| 17 (37) | no new foreground-service rules at the time of writing. Check the changes page each year |
Battery and app killers
- Battery use is dominated by GPS. Respect the user's chosen interval and don't request
PRIORITY_HIGH_ACCURACYat 1 s "just in case". - Don't hold a partial
WakeLock. The foreground service plus the fused location provider already deliver locations with the screen off. Wake locks drain the battery, and Google Play's Android vitals flag apps that hold them excessively. - Don't start tracking at boot or keep the service alive after the session.
stopSelf(). - Doze needs a stationary, unplugged device with the screen off. That rarely happens during a run, but it does when you test with the phone lying on the desk. Test it explicitly (below).
- Some manufacturers (Samsung, Xiaomi, Huawei, OnePlus...) kill foreground services anyway with their own battery managers. dontkillmyapp.com lists the per-vendor settings. Show the user a hint and open the system list with
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS). Directly prompting withACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONSrequires theREQUEST_IGNORE_BATTERY_OPTIMIZATIONSpermission, and Google Play restricts it to apps whose core function would otherwise break. Don't add it by reflex.
Testing a 2–3 hour session
- Emulator: Extended controls → Location → Routes. Import a GPX/KML route (lecture 13), set the playback speed and let it run with the screen off.
- Is it in the foreground? Run
adb shell dumpsys activity services ee.taltech.sportmapand look forisForeground=trueand the location type. - Task Manager stop:
adb shell cmd activity stop-app ee.taltech.sportmap, then reopen the app and checklogExitReasonsand the unfinished session. - Doze:
adb shell dumpsys battery unplug,adb shell dumpsys deviceidle force-idle... thenadb shell dumpsys deviceidle unforceandadb shell dumpsys battery reset. - Logs that survive: Logcat's ring buffer doesn't hold three hours. Append important events to a file and read it with
adb shell run-as ee.taltech.sportmap cat files/tracking.log:
class FileLog(context: Context) {
private val file = File(context.filesDir, "tracking.log")
fun log(message: String) {
file.appendText("${Instant.now()} $message\n")
}
}
- Real world: walk around the block with the phone locked in your pocket and add a CP and a WP from the lock screen. Then go out for an hour. The defence takes place in a forest, not in the emulator.
- Memory isn't a concern for the track itself: 3 h at one fix per second is about 11 000
GpsLocations.
In the final project
TrackingServiceis the "GPS listener and notification intents broadcast listener" in a service: alocationforeground service, Hilt-injected, collectingLocationClientinlifecycleScope.- The service is the single writer of the session. It creates it, appends filtered locations, checkpoints and waypoints (
type = WP, for GPX), and finishes it. The UI only sends commands (TrackingCommands) and observesTrackingRepository. - The notification is refreshed from
TrackingRepository.stateafter every fix, at most once per second. - Start only from the app, stop only after unlock and confirmation (
CONFIRM_STOP→MainActivity→MapViewModel), CP/WP also from the lock screen. - The GPS interval setting restarts the location request live (
flatMapLatest). - The session survives rotation, screen lock and the UI being destroyed. Unfinished sessions are recoverable from the database.
Self preparation QA
- Why can't SportMap just collect locations in
MapViewModel? — When the app goes to the background, the process loses priority: background location updates come only a few times per hour, and the process can be killed. A foreground service keeps foreground priority while the session runs. - Which permissions does a
locationforeground service need on Android 14+, and when? —FOREGROUND_SERVICEandFOREGROUND_SERVICE_LOCATIONin the manifest, and the location runtime permission granted beforestartForeground, otherwiseSecurityException. That is whyTrackingCommands.start()checks permissions beforestartForegroundService. - What happens if the service doesn't call
startForegroundwithin 5 seconds ofstartForegroundService? — The system crashes the app withForegroundServiceDidNotStartInTimeException, and the same happens ifstartForegroundthrows, even if you catch it and stop. Call it synchronously first inonStartCommand, and start the service only after the permission check. - Why can't the notification offer a Start button? — The spec forbids it, and since Android 12 apps can't start foreground services from the background in general. Starting is an app-UI action.
- Why does SportMap use a singleton repository instead of binding to the service? — UI and service stay decoupled. The UI shows state without a connection, survives rotation without rebinding, and the ViewModel gets the data by injection.
- Why is the notification refreshed via
state.sample(1_000)and not on everyStateFlowemission? — Android drops notification updates that come too often (many per second).sampleemits the latest state at most once per second, and sinceStateFlowis conflated there's never a backlog of old states. - What do
START_STICKYandSTART_NOT_STICKYmean, and why does SportMap use the latter? — Sticky restarts the killed service with anullintent, not-sticky doesn't. A sticky restart has no UI, no in-memory state and restricted location access, so durability comes from saving every fix to the database instead. - What happens when the user taps Stop in the Task Manager? — The whole app is removed from memory with no callbacks. On the next start
ApplicationExitInforeportsREASON_USER_REQUESTED. - Why is
stopSelf()called inside the coroutine afterfinishSessionand not right away? —stopSelf()destroys the service and cancelslifecycleScope, which would abort the database write. - Why not hold a wake lock to be safe? — The foreground service and the fused location provider already deliver updates with the screen off. A wake lock only drains the battery, and Play's vitals flag it.