The Foreground

Leejaywaggoner
3 min readMar 3, 2024

--

Photo by Dan Maisey on Unsplash

In the last article about my mileage app, I described what I did to detect the transition to driving so I could track the user’s drive. The next step is to create a service that runs in the foreground — but is hidden from the user. Seems like it should be called a background service, right? But what do I know?

Google defines a foreground service as a service that “performs some operation that is noticeable to the user.” In my case (I’ll get into this in a little bit) the user will have no idea that it’s running unless they go looking for the “hidden” notification while they’re driving. Don’t try it. Keep your eyes on the road!

My service is declared in the manifest thusly:

<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="com.google.android.gms.permission.ACTIVITY_RECOGNITION" />
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<application
...
<service
android:name=".data.services.MileageService"
android:foregroundServiceType="location"
android:exported="false">
</service>
</application>
</manifest>

I’m targeting SDK 34, so I need to request both the FOREGROUND_SERVICE permission and the FOREGROUND_SERVICE_LOCATION permission, and in the service definition, I had to add the attribute, android:foregroundServiceType="location". And since the only messages getting sent are from my app, I set android:exported="false".

Neither of those permissions requires runtime acceptance by the user, and I declined to add the POST_NOTIFICATIONS manifest and runtime permissions because I didn’t have to. It’s now only necessary if you want the foreground service to advertise itself, otherwise the user will have to hunt for the indicator.

If my user’s want to know if the service is running, then on stock Android there’s a button at the bottom of the quick settings pull-down to view the running services. On Samsung you can find it at the top after hitting the recent apps button. The average user will probably never find it, so I expect Google to change the rules again in a couple years. I’ll worry about it then, if that day ever comes. In the meantime, welcome back to the era of secret services!

The MileageService is written like this:

class MileageService : LifecycleService() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
Log.d("${MileageService::class.simpleName}", "onStartCommand")
intent?.let { mileageIntent ->
mileageIntent.extras?.let { extras ->
if (extras.containsKey(START_TRACKING_MILES)) {
val startTrackingMiles = extras.getBoolean(START_TRACKING_MILES)
if (startTrackingMiles) {
startTrackingMiles()
} else {
stopTrackingMiles()
}
}
}
}
return START_STICKY
}

private fun startTrackingMiles() {
Log.d("${MileageService::class.simpleName}", "Start Tracking Miles")
ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
getNotification(),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
} else {
0
}
)
}

private fun stopTrackingMiles() {
Log.d("${MileageService::class.simpleName}", "Stop Tracking Miles")
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
}

private fun getNotification(): Notification {
createServiceNotificationChannel()

val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.notification_text))
.setSmallIcon(R.mipmap.ic_launcher)
.setOngoing(true)

return builder.build()
}

private fun createServiceNotificationChannel() {
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(
CHANNEL_ID,
NOTIFICATION_CHANNEL_NAME,
NotificationManager.IMPORTANCE_DEFAULT
)
notificationManager.createNotificationChannel(channel)
}

companion object {
private const val CHANNEL_ID = "channel_01"
private const val NOTIFICATION_CHANNEL_NAME = "MileageApp"
private const val NOTIFICATION_ID = 90210
}
}

And launched from my ActionTransitionReceiver like this:

const val START_TRACKING_MILES = "start_tracking_miles"

class ActionTransitionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
Log.d("${ActionTransitionReceiver::class.simpleName}", "onReceive")
intent?.let { atIntent ->
if (ActivityTransitionResult.hasResult(intent)) {
val result = ActivityTransitionResult.extractResult(atIntent)
if (result != null && context != null) {
processTransitionResults(context, result.transitionEvents)
}
}
}
}

private fun processTransitionResults(
context: Context,
transitionEvents: List<ActivityTransitionEvent>
) {
//if driving, start location service
for (event in transitionEvents) {
val transition =
"${mapTransitionToString(event)} ${mapActivityToString(event)}"
Log.d(
"${MileageService::class.simpleName}",
"Transition: $transition"
)

val intent = Intent(context, MileageService::class.java)
if (event.activityType == DetectedActivity.IN_VEHICLE) {
when (event.transitionType) {
ActivityTransition.ACTIVITY_TRANSITION_ENTER -> {
intent.putExtra(START_TRACKING_MILES, true)
}
ActivityTransition.ACTIVITY_TRANSITION_EXIT -> {
intent.putExtra(START_TRACKING_MILES, false)
}
}
context.startForegroundService(intent)
}
}
}
}

And viola! [sic] It works! Next step is to track the user’s location during a drive so I can calculate the miles driven.

--

--

Leejaywaggoner

A seasoned software developer with decades of experience, sharing deep insights and industry wisdom.