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 (R.drawable.ic_notification_trail).

Title and text

The collapsed content (setContentTitle, setContentText); most styles below expand this into something richer once the user pulls the notification open.

Channel

Which NotificationChannel the notification belongs to (see below) — this is what the user actually controls in system settings, not the individual notification.

Priority / importance

Inherited from the channel on Android 8 (API 26) and above; the deprecated per-notification setPriority() only matters below API 26.

Content intent

A PendingIntent fired when the user taps the notification body, usually a deep link back into the app.

Actions

Up to a handful of NotificationCompat.Action buttons rendered alongside the notification, each with its own PendingIntent.

Style

An optional NotificationCompat.Style that changes how the expanded notification looks (big text, an image, a list of lines, a progress bar, or a messaging thread).

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

IMPORTANCE_HIGH

The highest level, shown to users as "Urgent": heads-up banner, sound; reserved for time-critical items (TrailMate does not currently use this for trail_updates).

IMPORTANCE_DEFAULT

Sound, but no heads-up banner. This is what trail_updates uses.

IMPORTANCE_LOW

No sound; still visible in the shade. Appropriate for the ongoing foreground-service notification described below.

IMPORTANCE_MIN

Minimized into the shade, collapsed by default.

IMPORTANCE_NONE

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

BigTextStyle

Shows the full text of a long message instead of truncating it — used for the sync notification when it also reports which trails changed.

BigPictureStyle

Shows a large image in the expanded view — used when a hike finishes, to show the last photo taken during it.

InboxStyle

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

setProgress(max, progress, indeterminate) on a plain builder (there is no separate "progress style") — used while a hike recording is running, showing distance covered so far.

// 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).

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:

  1. creating a Firebase project and registering the app’s package name and SHA-1 signing certificate in the Firebase console;

  2. adding google-services.json to the :app module and applying the Google Services Gradle plugin;

  3. adding the Firebase BoM and firebase-messaging to :app’s dependencies (the `-ktx variants were removed from the BoM in v34.0.0, and their Kotlin extensions now live in the main modules);

  4. declaring a FirebaseMessagingService subclass 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 (message.notification)

The FCM SDK itself displays a system notification automatically while the app is in the background; while the app is in the foreground, onMessageReceived() still fires and the app must build the notification itself if it wants one shown. This makes a notification-only message unreliable for anything that must always reach onMessageReceived() with app logic attached.

Data message (message.data, a Map<String, String>)

Always delivered to onMessageReceived(), foreground or background, and never shown automatically — the app decides what to do (refresh data quietly, or build its own NotificationCompat notification, as TrailMateMessagingService does above with trailId).

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

GlanceAppWidget

Declares the widget’s UI as a composable provideContent { …​ } block; conceptually the widget’s "activity".

GlanceAppWidgetReceiver

A thin BroadcastReceiver subclass the manifest registers as the app widget provider; it just exposes the GlanceAppWidget instance to the platform.

State

Per-widget-instance data (which trail is being shown), persisted with a GlanceStateDefinition — TrailMate uses PreferencesGlanceStateDefinition so each widget instance keeps its own small key-value state across process death.

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