Notifications, Firebase Cloud Messaging and App Widgets (Glance)
|
This content was generated with the assistance of AI and should be verified against the official documentation of Android before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
This page covers how TrailMate talks to the user outside its own UI: local notifications posted with
NotificationCompat.Builder, the POST_NOTIFICATIONS runtime permission introduced in Android 13, push
notifications delivered through Firebase Cloud Messaging (FCM), and a home-screen app widget built with Glance.
Notifications and widgets are both entry points back into the app, so several examples below build on
RecordHikeService, the foreground service, and the background-sync worker covered in full on
Background Work: WorkManager, Services and Alarms; the
POST_NOTIFICATIONS permission request flow itself, shared with every other runtime permission, is covered on
Permissions, Privacy and Security. Navigating from a
notification or a widget tap into a Compose destination reuses the routes introduced on
Navigation in Compose (Navigation Compose and Navigation 3). Kotlin
coroutines used by the FCM service and the widget’s provideContent are not re-explained here; see
Coroutines Basics.
Anatomy of a Notification
A notification is a UI TrailMate does not draw itself: the system decides where and how to render it (status bar icon, heads-up banner, lock screen, notification shade, wearable), based on a small, structured description the app hands over. That description has a few recurring parts:
| Part | Purpose |
|---|---|
Small icon |
A required, single-color glyph shown in the status bar and as the notification’s badge; TrailMate uses a
monochrome trail-marker icon ( |
Title and text |
The collapsed content ( |
Channel |
Which |
Priority / importance |
Inherited from the channel on Android 8 (API 26) and above; the deprecated per-notification
|
Content intent |
A |
Actions |
Up to a handful of |
Style |
An optional |
Notification Channels and Importance
Since Android 8 (API 26), every notification must belong to a notification channel: a named category the
user can individually mute, silence, or reassign a sound to from system settings, without touching the app’s own
in-app preferences. TrailMate creates one channel for the notifications it posts about a hike in progress and
about background sync results, trail_updates, at application startup:
// app/src/main/kotlin/com/example/trailmate/notifications/NotificationChannels.kt
package com.example.trailmate.notifications
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import androidx.core.content.getSystemService
object NotificationChannels {
const val TRAIL_UPDATES = "trail_updates"
fun register(context: Context) {
val channel = NotificationChannel(
TRAIL_UPDATES,
"Trail updates",
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Hike recording status and trail sync results"
}
val manager = context.getSystemService<NotificationManager>()
manager?.createNotificationChannel(channel)
}
}
NotificationChannels.register(this) runs once, from TrailMateApplication.onCreate(); calling
createNotificationChannel again with the same id is a no-op, and once a channel exists its importance can
only be lowered by the app — raising it back up, or changing its sound, is a setting only the user can change
from system settings, by design. The importance level chosen at creation time controls how intrusive the
channel’s notifications are:
| Importance | Behavior |
|---|---|
|
The highest level, shown to users as "Urgent": heads-up banner, sound; reserved for time-critical items (TrailMate does not currently use this for
|
|
Sound, but no heads-up banner. This is what |
|
No sound; still visible in the shade. Appropriate for the ongoing foreground-service notification described below. |
|
Minimized into the shade, collapsed by default. |
|
The channel is blocked; nothing is shown. |
A single app can register many channels — one per distinct kind of alert — so that muting "trail updates" never silences, say, a future payment-related channel. See Android Developers — Create and manage notification channels.
The POST_NOTIFICATIONS Runtime Permission
Starting with Android 13 (API 33), posting any notification — including a foreground-service notification — requires the runtime permission android.permission.POST_NOTIFICATIONS. Unlike most dangerous permissions, this
one is requested with the same ActivityResultContracts.RequestPermission() flow used for any other runtime
permission (the full request-and-rationale pattern is covered on
Permissions, Privacy and Security); the notable
differences are:
-
it must be declared with
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />in the manifest; -
on API levels below 33, the system silently treats it as granted, so TrailMate must guard the request with an API-level check;
-
if the user denies it,
NotificationManagerCompat.notify()silently does nothing rather than throwing, so a denied permission degrades gracefully instead of crashing the app.
// app/src/main/kotlin/com/example/trailmate/notifications/NotificationPermission.kt
package com.example.trailmate.notifications
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
fun hasNotificationPermission(context: Context): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
}
TrailMate requests this permission the first time it is actually about to post something the user asked for — starting a hike recording — rather than at first launch, following the request-in-context guidance. See Android Developers — Notification runtime permission.
Building Notifications with NotificationCompat.Builder
NotificationCompat.Builder is the AndroidX compatibility wrapper around Notification.Builder that back-ports
newer notification features to older platform versions and is what every example on this page uses. A basic
"sync finished" notification looks like this:
// core/data/src/main/kotlin/com/example/trailmate/core/data/sync/SyncNotifier.kt
package com.example.trailmate.core.data.sync
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.example.trailmate.MainActivity
import com.example.trailmate.notifications.NotificationChannels
import com.example.trailmate.notifications.hasNotificationPermission
class SyncNotifier(private val context: Context) {
fun notifySyncFinished(newTrailCount: Int) {
if (!hasNotificationPermission(context)) return
val contentIntent = PendingIntent.getActivity(
context,
REQUEST_CODE_OPEN_APP,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(context, NotificationChannels.TRAIL_UPDATES)
.setSmallIcon(com.example.trailmate.R.drawable.ic_notification_trail)
.setContentTitle("Trails updated")
.setContentText("$newTrailCount new trails synced")
.setContentIntent(contentIntent)
.setAutoCancel(true)
.build()
NotificationManagerCompat.from(context).notify(NOTIFICATION_ID_SYNC, notification)
}
private companion object {
const val REQUEST_CODE_OPEN_APP = 100
const val NOTIFICATION_ID_SYNC = 1
}
}
setAutoCancel(true) dismisses the notification once tapped; NotificationManagerCompat.notify() takes an
integer id that TrailMate reuses to update a still-visible notification (posting again with the same id
replaces it in place) or to later cancel() it. See
Android Developers — Create
a notification and
Android Developers — NotificationCompat.Builder reference.
Expanded Styles
A style replaces the collapsed one- or two-line layout with a richer expanded view once the user pulls the notification open, without changing anything else about how the notification is built or posted.
| Style | Use in TrailMate |
|---|---|
|
Shows the full text of a long message instead of truncating it — used for the sync notification when it also reports which trails changed. |
|
Shows a large image in the expanded view — used when a hike finishes, to show the last photo taken during it. |
|
Shows up to several separate lines, like an inbox — used to summarize multiple newly synced trails at once instead of a single collapsed line. |
Progress notification |
|
// core/data/src/main/kotlin/com/example/trailmate/core/data/sync/SyncNotifier.kt (continued)
fun notifySyncFinished(updatedTrailNames: List<String>) {
if (!hasNotificationPermission(context)) return
val style = NotificationCompat.InboxStyle()
.setBigContentTitle("Trails updated")
updatedTrailNames.take(MAX_INBOX_LINES).forEach { style.addLine(it) }
if (updatedTrailNames.size > MAX_INBOX_LINES) {
style.setSummaryText("+${updatedTrailNames.size - MAX_INBOX_LINES} more")
}
val notification = NotificationCompat.Builder(context, NotificationChannels.TRAIL_UPDATES)
.setSmallIcon(com.example.trailmate.R.drawable.ic_notification_trail)
.setContentTitle("Trails updated")
.setContentText("${updatedTrailNames.size} trails synced")
.setStyle(style)
.setAutoCancel(true)
.build()
NotificationManagerCompat.from(context).notify(NOTIFICATION_ID_SYNC, notification)
}
private const val MAX_INBOX_LINES = 5
A hike-in-progress notification with a determinate progress bar, updated as RecordHikeService reports new
distance readings, looks like this:
// app/src/main/kotlin/com/example/trailmate/hike/RecordHikeNotifier.kt
package com.example.trailmate.hike
import androidx.core.app.NotificationCompat
import com.example.trailmate.notifications.NotificationChannels
fun buildHikeProgressNotification(
context: android.content.Context,
distanceMeters: Int,
goalMeters: Int,
): android.app.Notification =
NotificationCompat.Builder(context, NotificationChannels.TRAIL_UPDATES)
.setSmallIcon(com.example.trailmate.R.drawable.ic_notification_trail)
.setContentTitle("Recording hike")
.setContentText("${distanceMeters / 1000.0} km so far")
.setProgress(goalMeters, distanceMeters.coerceAtMost(goalMeters), false)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
BigPictureStyle needs the bitmap decoded ahead of time (it cannot load a Uri lazily), so TrailMate decodes
the hike’s last photo off the main thread before calling setStyle. See
Android Developers — Create an
expanded notification.
Actions and Direct Reply
NotificationCompat.Action adds a button to a notification, each backed by its own PendingIntent; TrailMate
adds a "Stop recording" action to the hike-in-progress notification so the user can end the hike without opening
the app:
// app/src/main/kotlin/com/example/trailmate/hike/RecordHikeNotifier.kt (continued)
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
private fun stopRecordingAction(context: Context): NotificationCompat.Action {
val stopIntent = Intent(context, RecordHikeService::class.java).apply {
action = RecordHikeService.ACTION_STOP
}
val stopPendingIntent = PendingIntent.getForegroundService(
context,
REQUEST_CODE_STOP,
stopIntent,
PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Action.Builder(
com.example.trailmate.R.drawable.ic_stop,
"Stop",
stopPendingIntent,
).build()
}
private const val REQUEST_CODE_STOP = 200
An action can also collect free text inline, without opening the app at all, through RemoteInput: TrailMate
does not currently ship a messaging feature, but the same mechanism used for a chat app’s inline reply would
attach a RemoteInput to an Action and read the typed text back from the Intent extras that the action’s
PendingIntent delivers, using RemoteInput.getResultsFromIntent(intent). See
Android Developers — Create an
expanded notification (actions and direct reply are documented alongside expanded styles).
PendingIntent Deep Links
Tapping a notification (or an action) should take the user to the specific screen the notification is about,
not just the app’s launcher entry point. TrailMate’s sync notification instead opens TrailDetail(trailId)
directly, using a deep link into the same Navigation Compose graph covered on
Navigation in Compose (Navigation Compose and Navigation 3):
// core/data/src/main/kotlin/com/example/trailmate/core/data/sync/SyncNotifier.kt (deep link variant)
import android.app.TaskStackBuilder
import androidx.navigation.NavDeepLinkBuilder
private fun trailDetailPendingIntent(context: Context, trailId: String): PendingIntent =
NavDeepLinkBuilder(context)
.setGraph(com.example.trailmate.R.navigation.nav_graph)
.setDestination(com.example.trailmate.R.id.trailDetailFragment)
.setArguments(androidx.core.os.bundleOf("trailId" to trailId))
.createPendingIntent()
NavDeepLinkBuilder is the Navigation-with-Fragments equivalent shown here for completeness; TrailMate’s
Compose screens instead read the target route straight off an explicit Intent extra and hand it to
NavController.navigate() from MainActivity, since Navigation Compose has no NavDeepLinkBuilder
counterpart. Either way, PendingIntent.FLAG_IMMUTABLE is required unless the target genuinely needs to fill in
extras later (FLAG_MUTABLE), because the platform rejects a mutable PendingIntent handed to another
process without one of the two flags on API 31 and above. A PendingIntent built from Intent.FLAG_ACTIVITY_NEW_TASK
with TaskStackBuilder.create(context).addNextIntentWithParentStack(intent) reproduces a normal back stack (so
pressing back from the deep-linked screen goes to the trail list, not out of the app), which matters more for a
TrailDetail deep link than for one that simply reopens MainActivity.
Foreground-Service Notifications
Every foreground service must post a notification for as long as it runs — this is what makes a foreground
service visibly different from a background one, and it is enforced by the platform, not just a convention.
RecordHikeService, TrailMate’s location-type foreground service introduced on
Background Work: WorkManager, Services and Alarms, calls
startForeground() with the progress notification built above:
// app/src/main/kotlin/com/example/trailmate/hike/RecordHikeService.kt (excerpt)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val notification = buildHikeProgressNotification(this, distanceMeters = 0, goalMeters = 5_000)
startForeground(
NOTIFICATION_ID_HIKE,
notification,
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION,
)
// ... begin location updates
return START_STICKY
}
private companion object {
const val NOTIFICATION_ID_HIKE = 2
}
Because the service updates the same notification id (NOTIFICATION_ID_HIKE) on every location fix by calling
NotificationManagerCompat.notify() again rather than startForeground() again, the notification’s progress
bar advances in place instead of re-appearing as a new alert each time. setOngoing(true) no longer guarantees
that the notification stays put: since Android 14, users can dismiss setOngoing(true) notifications, including
this foreground-service one (it stays non-dismissible only while the device is locked, or when the user taps
Clear all). Dismissing it does not stop RecordHikeService, which keeps recording until it calls
stopForeground()/stopSelf(); TrailMate re-posts the notification on the next location fix. See
Android Developers — Behavior changes: all
apps (Android 14).
Groups and Badges
When several notifications belong together — several trail updates arriving close together, for instance — setGroup("trail_updates_group") on each one, plus one additional summary notification built with
setGroup(…).setGroupSummary(true), tells the system to collapse them into a single expandable stack in the
shade instead of listing every one separately. The launcher badge (the small dot or count shown on the app
icon) is derived automatically from unread, still-visible notifications and their channel’s
setShowBadge() setting; TrailMate does not customize badge counts beyond leaving setShowBadge(true) (the
default) on trail_updates.
Firebase Cloud Messaging
Local notifications above are all triggered by code already running inside TrailMate (a finished sync, an ongoing hike). Firebase Cloud Messaging (FCM) instead lets a server push a message to a specific device even when TrailMate is not running, which is how TrailMate would announce, say, a newly opened trail in the user’s saved region.
Setup Overview
Adding FCM to TrailMate means:
-
creating a Firebase project and registering the app’s package name and SHA-1 signing certificate in the Firebase console;
-
adding
google-services.jsonto the:appmodule and applying the Google Services Gradle plugin; -
adding the Firebase BoM and
firebase-messagingto:app’s dependencies (the `-ktxvariants were removed from the BoM in v34.0.0, and their Kotlin extensions now live in the main modules); -
declaring a
FirebaseMessagingServicesubclass in the manifest.
// app/build.gradle.kts (excerpt)
dependencies {
implementation(platform("com.google.firebase:firebase-bom:34.7.0"))
implementation("com.google.firebase:firebase-messaging")
}
FirebaseMessagingService
TrailMate implements FirebaseMessagingService to handle both an incoming message and a refreshed registration
token:
// app/src/main/kotlin/com/example/trailmate/push/TrailMateMessagingService.kt
package com.example.trailmate.push
import com.example.trailmate.notifications.NotificationChannels
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.example.trailmate.notifications.hasNotificationPermission
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class TrailMateMessagingService : FirebaseMessagingService() {
@Inject lateinit var tokenUploader: TrailRegistrationTokenUploader
override fun onNewToken(token: String) {
// Send the refreshed token to TrailMate's own backend so it can target this device.
tokenUploader.upload(token)
}
override fun onMessageReceived(message: RemoteMessage) {
message.notification?.let { notificationPayload ->
showLocalNotification(
title = notificationPayload.title.orEmpty(),
body = notificationPayload.body.orEmpty(),
)
}
message.data["trailId"]?.let { trailId ->
// A data-only (or combined) message: TrailMate decides for itself what to do,
// e.g. refresh the cached trail, or build its own notification.
TrailSyncWorker.enqueueOneOff(applicationContext, trailId)
}
}
private fun showLocalNotification(title: String, body: String) {
if (!hasNotificationPermission(this)) return
val notification = NotificationCompat.Builder(this, NotificationChannels.TRAIL_UPDATES)
.setSmallIcon(com.example.trailmate.R.drawable.ic_notification_trail)
.setContentTitle(title)
.setContentText(body)
.setAutoCancel(true)
.build()
NotificationManagerCompat.from(this).notify(System.currentTimeMillis().toInt(), notification)
}
}
Registration Tokens
FCM identifies a specific app install on a specific device with an opaque registration token, obtained with
FirebaseMessaging.getInstance().token (a suspending-friendly Task, or awaited with .await() from
kotlinx-coroutines-play-services) and re-delivered to onNewToken() whenever it changes — for example after
the app is restored on a new device, or the existing token expires. TrailMate uploads the current token to its
own backend, TrailRegistrationTokenUploader, both from onNewToken() and once at first successful sign-in, so
the backend always has a token to target when it wants to push to that device:
// app/src/main/kotlin/com/example/trailmate/push/TrailRegistrationTokenUploader.kt
package com.example.trailmate.push
import android.content.Context
import com.example.trailmate.core.network.TrailApi
import com.google.firebase.messaging.FirebaseMessaging
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
class TrailRegistrationTokenUploader @Inject constructor(
@ApplicationContext private val context: Context,
private val trailApi: TrailApi,
) {
suspend fun uploadCurrentToken() {
val token = FirebaseMessaging.getInstance().token.await()
upload(token)
}
fun upload(token: String) {
// POST to TrailMate's own backend, e.g. PUT /v1/devices/{deviceId} { "fcmToken": token }
}
}
Data Messages vs. Notification Messages
FCM’s RemoteMessage can carry either or both of two independent payloads, and which one is present changes
who is responsible for showing something to the user:
| Message type | Who builds the notification |
|---|---|
Notification message ( |
The FCM SDK itself displays a system notification automatically while the app is in the background; while
the app is in the foreground, |
Data message ( |
Always delivered to |
TrailMate’s backend sends data-only messages for anything the app itself must react to (a new trail in a
watched region), so its behavior does not silently change depending on whether the app happens to be in the
foreground when the push arrives. A server-side send (from TrailMate’s own backend, using the Firebase Admin SDK
or the HTTP v1 API) supplies its own service-account credentials — never a value that looks like
"<YOUR_SERVER_KEY>" hardcoded into the client, which the FCM SDK never needs or accepts on the Android side.
See Firebase — Set up a Firebase Cloud
Messaging client app on Android and
Firebase — About FCM messages for the full
message-type comparison.
App Widgets
An app widget places a small, always-visible piece of TrailMate on the user’s home screen — a summary of the active hike, or a shortcut to start one — without opening the app.
Glance
Glance is the modern, Compose-based toolkit for building app widgets: it uses composable functions, but
renders to RemoteViews under the hood, since a widget host process cannot run the app’s own Compose runtime.
A Glance widget has three moving parts:
| Type | Role |
|---|---|
|
Declares the widget’s UI as a composable |
|
A thin |
State |
Per-widget-instance data (which trail is being shown), persisted with a |
// app/src/main/kotlin/com/example/trailmate/widget/TrailWidget.kt
package com.example.trailmate.widget
import androidx.compose.runtime.Composable
import androidx.glance.GlanceId
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.provideContent
import androidx.glance.currentState
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.Text
class TrailWidget : GlanceAppWidget() {
override val stateDefinition = PreferencesGlanceStateDefinition
override suspend fun provideGlance(context: android.content.Context, id: GlanceId) {
provideContent {
val prefs = currentState<androidx.datastore.preferences.core.Preferences>()
val activeTrailName = prefs[ACTIVE_TRAIL_NAME_KEY] ?: "No hike in progress"
Text(text = activeTrailName)
}
}
companion object {
val ACTIVE_TRAIL_NAME_KEY =
androidx.datastore.preferences.core.stringPreferencesKey("active_trail_name")
}
}
// app/src/main/kotlin/com/example/trailmate/widget/TrailWidgetReceiver.kt
package com.example.trailmate.widget
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetReceiver
class TrailWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget: GlanceAppWidget = TrailWidget()
}
<!-- app/src/main/AndroidManifest.xml (excerpt) -->
<receiver
android:name=".widget.TrailWidgetReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/trail_widget_info" />
</receiver>
Updating a Widget via WorkManager
A widget cannot poll for new data on its own; something else has to push a fresh value into its state and then
ask the widget to redraw. TrailMate’s TrailSyncWorker — the same periodic CoroutineWorker covered on
Background Work: WorkManager, Services and Alarms — writes
the active trail’s name into every widget instance’s Glance state and calls update() once its sync completes:
// core/data/src/main/kotlin/com/example/trailmate/core/data/sync/TrailSyncWorker.kt (widget update excerpt)
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.appwidget.updateAll
import com.example.trailmate.widget.TrailWidget
private suspend fun refreshWidgets(context: android.content.Context, activeTrailName: String) {
val manager = GlanceAppWidgetManager(context)
manager.getGlanceIds(TrailWidget::class.java).forEach { glanceId ->
updateAppWidgetState(context, glanceId) { prefs ->
prefs[TrailWidget.ACTIVE_TRAIL_NAME_KEY] = activeTrailName
}
}
TrailWidget().updateAll(context)
}
Because a CoroutineWorker already runs off the main thread and can be constrained (network-connected, battery
not low), it is a better fit for periodic widget refreshes than the classic updatePeriodicMillis on
AppWidgetProviderInfo below, which the platform throttles to no more than roughly once every 30 minutes
regardless of the value configured. See
Android Developers — Build app widgets with Glance.
The Classic AppWidgetProvider with RemoteViews (Legacy)
Before Glance, an app widget was built by subclassing AppWidgetProvider (itself a BroadcastReceiver
specialization) and constructing its layout imperatively with RemoteViews, XML-inflated much like a classic
View layout, since a widget host renders a limited, host-process-safe subset of Views:
// Legacy pattern, superseded by Glance above -- shown for recognition only.
class LegacyTrailWidgetProvider : android.appwidget.AppWidgetProvider() {
override fun onUpdate(
context: android.content.Context,
manager: android.appwidget.AppWidgetManager,
widgetIds: IntArray,
) {
widgetIds.forEach { widgetId ->
val views = android.widget.RemoteViews(context.packageName, R.layout.widget_trail)
views.setTextViewText(R.id.widget_trail_name, "No hike in progress")
manager.updateAppWidget(widgetId, views)
}
}
}
RemoteViews only exposes a small, fixed set of setter methods (setTextViewText, setImageViewResource,
setOnClickPendingIntent, and a handful of others) rather than arbitrary View mutation, which is precisely the
constraint Glance’s composable API hides behind ordinary Compose code while still compiling down to the same
RemoteViews mechanism. New widgets should use Glance; AppWidgetProvider remains relevant mainly for
maintaining an existing widget or interoperating with a legacy Views codebase. See
Android Developers — App widgets overview.