Notifications & Channels

A notification is how TrailLog reaches the user when the app itself isn't on screen — a reminder to log today's hike, a sync error that needs attention, or an ongoing status update while a hike is actively being tracked. Modern Android requires every notification to be organized into a channel before it can be shown at all, which is the first thing this lesson covers.

Notification Channels Are Required on Android 8.0+ (API 26+)

Since Android 8.0 (API 26), every notification an app posts must belong to a NotificationChannel. This isn't just internal bookkeeping — channels are visible, individually controllable settings the user sees in the system Settings app, grouped under TrailLog's own app entry. That's a genuinely user-respecting piece of design: it lets someone mute "Hike Reminders" specifically, while leaving "Sync Errors" notifications turned on, instead of an all-or-nothing switch for the whole app. A channel, once created with a given importance level, is largely under the user's control from then on — your app can create it, but it can't override a user's choice to silence it.

Creating a Channel for TrailLog

Channels only need to be created once — typically in Application.onCreate() — since re-creating an existing channel with the same id is a harmless no-op:

class TrailLogApp : Application() {

    companion object {
        const val CHANNEL_REMINDERS = "hike_reminders"
    }

    override fun onCreate() {
        super.onCreate()
        createNotificationChannels()
    }

    private fun createNotificationChannels() {
        val remindersChannel = NotificationChannel(
            CHANNEL_REMINDERS,
            "Hike Reminders",
            NotificationManager.IMPORTANCE_DEFAULT
        ).apply {
            description = "Reminders to log your next hike"
        }

        val manager = getSystemService(NotificationManager::class.java)
        manager.createNotificationChannel(remindersChannel)
    }
}

The channel id ("hike_reminders") is a permanent identifier — every notification you post later references this exact id to say which channel it belongs to. The human-readable name ("Hike Reminders") is what the user actually sees in system settings.

Building and Showing a Basic Notification

With a channel in place, NotificationCompat.Builder assembles the notification's content, and NotificationManagerCompat.notify() posts it:

fun showHikeReminder(context: Context) {
    val openAppIntent = Intent(context, TrailListActivity::class.java)
    val contentIntent = PendingIntent.getActivity(
        context, 0, openAppIntent,
        PendingIntent.FLAG_IMMUTABLE
    )

    val notification = NotificationCompat.Builder(context, TrailLogApp.CHANNEL_REMINDERS)
        .setSmallIcon(R.drawable.ic_trail_notification)
        .setContentTitle("Time for a hike?")
        .setContentText("You haven't logged a trail in 5 days.")
        .setContentIntent(contentIntent)
        .setAutoCancel(true)
        .build()

    NotificationManagerCompat.from(context).notify(1001, notification)
}

setContentIntent(contentIntent) is what makes tapping the notification actually navigate into TrailLog — the wrapped PendingIntent is what the system fires on the user's behalf when they tap it. setAutoCancel(true) dismisses the notification automatically once tapped, which is the expected behavior for a one-off reminder like this.

The POST_NOTIFICATIONS Runtime Permission (Android 13+)

Here's a genuinely recent change that plenty of older tutorials still miss: starting with Android 13 (API 33), posting any notification requires the POST_NOTIFICATIONS runtime permission, requested and handled exactly like the runtime permissions covered in Runtime Permissions — declare it in the manifest, then request it at the right moment with the same ActivityResultContracts.RequestPermission() pattern. Before Android 13, notifications simply worked without asking; skipping this permission check on a modern target SDK means notify() silently does nothing, with no crash and no obvious signal that anything went wrong.

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

Expandable and Big-Text Styles

A single-line notification often isn't enough room for a longer message. NotificationCompat .BigTextStyle lets the user expand the notification to see the full text, without changing anything else about how it's built:

.setStyle(
    NotificationCompat.BigTextStyle()
        .bigText("You haven't logged a trail in 5 days. Your longest streak this year was 12 days — keep it going!")
)

A Persistent Notification for an Active Hike

The Foreground Service covered in Services & Foreground Service — the one that keeps tracking a hike's GPS location while TrailLog runs in the background — is required by the system to show an ongoing, non-dismissible notification for as long as it runs. That notification is built with the exact same NotificationCompat.Builder API, just with setOngoing(true) so the user can't swipe it away while tracking is active:

fun buildTrackingNotification(context: Context, distanceSoFarKm: Double): Notification {
    return NotificationCompat.Builder(context, TrailLogApp.CHANNEL_TRACKING)
        .setSmallIcon(R.drawable.ic_trail_notification)
        .setContentTitle("Tracking your hike")
        .setContentText("${distanceSoFarKm} km so far")
        .setOngoing(true)
        .setPriority(NotificationCompat.PRIORITY_LOW)
        .build()
}

setOngoing(true) is exactly what removes the swipe-to-dismiss gesture — it's the system's way of showing the user that a real background process is running and can be checked on, not just an informational message they can flick away. This is the same notification object the Foreground Service passes to startForeground(), tying this lesson directly back to that one.

Note: Every snippet above is correct, real Kotlin/Android code, but this website's own project doesn't run a live Android Studio build, so nothing here executes on the page itself — copy it into a real TrailLog project to see the notifications appear.
Up next: TrailLog's photos so far have come from the device gallery. CameraX covers capturing a trail photo directly from within the app itself.