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.