Skip to main content

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 location foreground service with the permissions Android 14+ requires.
  • Start it from the UI and promote it with ServiceCompat.startForeground in time.
  • Write TrackingService as a Hilt LifecycleService that 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.
Demo code

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."

WorkTool
user-started, ongoing, noticeable: tracking a run, music, a callforeground service
deferrable, must eventually finish: upload unsynced data, periodic refreshWorkManager (lecture 19)
react while the app is visiblecoroutines 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_SERVICE and FOREGROUND_SERVICE_LOCATION are normal permissions, granted at install. Without them startForeground throws a SecurityException.
  • foregroundServiceType is 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 a SecurityException otherwise.
  • exported="false": no other app may start or bind your service.

The available types:

TypeFor
locationnavigation, location sharing, tracking a route
healthfitness and health sensors (heart rate, step counting)
dataSyncdata transfer (limited to 6 h per day since Android 15)
mediaPlayback, mediaProcessing, mediaProjectionaudio/video
camera, microphone, phoneCall, connectedDevice, remoteMessagingas the names say
shortServicecritical work that finishes within about 3 minutes
specialUseanything 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))
}
  • startForegroundService creates the service and promises the system that it will call startForeground shortly.
  • The permission check before startForegroundService is the real guard. The UI runs the permission flow from lecture 12 first, and start() refuses if something is still missing. Location (fine or approximate) is required, otherwise startForeground throws and the app crashes (see the 5-second rule). POST_NOTIFICATIONS exists 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 ForegroundServiceStartNotAllowedException right 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)
}
}
The 5-second rule

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:

  1. The GPS interval comes from settings (lecture 10), and distinctUntilChanged ignores unrelated settings changes.
  2. 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.
  3. toGpsLocation() converts each fix, and isPlausible() 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_DETACH would leave it visible but ordinary (dismissable).
  • stopSelf() destroys the service, and onDestroy unregisters the receiver and cancels lifecycleScope. That's why stopSelf() 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
}
RepositoryBound service
UI needs the service running to show datanoyes
connection handling (bind, unbind, null while connecting, rotation)noneall of it
ViewModel accessinject the repositoryneeds a binder from the UI layer, awkward
testabilityfake repositoryAndroid 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) MapViewModel is 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:

ReturnAfter the process is killed
START_STICKYrecreate the service later and call onStartCommand with a null intent
START_NOT_STICKYdon't recreate it
START_REDELIVER_INTENTrecreate 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:

VersionWhat 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_ACCURACY at 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 with ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS requires the REQUEST_IGNORE_BATTERY_OPTIMIZATIONS permission, 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.sportmap and look for isForeground=true and the location type.
  • Task Manager stop: adb shell cmd activity stop-app ee.taltech.sportmap, then reopen the app and check logExitReasons and the unfinished session.
  • Doze: adb shell dumpsys battery unplug, adb shell dumpsys deviceidle force-idle... then adb shell dumpsys deviceidle unforce and adb 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​

  • TrackingService is the "GPS listener and notification intents broadcast listener" in a service: a location foreground service, Hilt-injected, collecting LocationClient in lifecycleScope.
  • 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 observes TrackingRepository.
  • The notification is refreshed from TrackingRepository.state after 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​

  1. 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.
  2. Which permissions does a location foreground service need on Android 14+, and when? — FOREGROUND_SERVICE and FOREGROUND_SERVICE_LOCATION in the manifest, and the location runtime permission granted before startForeground, otherwise SecurityException. That is why TrackingCommands.start() checks permissions before startForegroundService.
  3. What happens if the service doesn't call startForeground within 5 seconds of startForegroundService? — The system crashes the app with ForegroundServiceDidNotStartInTimeException, and the same happens if startForeground throws, even if you catch it and stop. Call it synchronously first in onStartCommand, and start the service only after the permission check.
  4. 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.
  5. 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.
  6. Why is the notification refreshed via state.sample(1_000) and not on every StateFlow emission? — Android drops notification updates that come too often (many per second). sample emits the latest state at most once per second, and since StateFlow is conflated there's never a backlog of old states.
  7. What do START_STICKY and START_NOT_STICKY mean, and why does SportMap use the latter? — Sticky restarts the killed service with a null intent, 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.
  8. 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 ApplicationExitInfo reports REASON_USER_REQUESTED.
  9. Why is stopSelf() called inside the coroutine after finishSession and not right away? — stopSelf() destroys the service and cancels lifecycleScope, which would abort the database write.
  10. 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.