MyFamilyTracker
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
Explore how Android's notification system evolved in 2026, covering channel management, runtime permission changes, and proven techniques to ensure reliable delivery across devices.
On this page
By 2026, Android notifications are no longer a simple fire-and-forget mechanism; they are a critical engagement channel that demands precise channel configuration, permission handling, and reliability testing. Users expect timely, relevant alerts, and OEMs impose stricter background limits that can silently drop your messages. If you treat notifications as an afterthought, you risk losing user trust and missing key conversion opportunities.
Android’s notification framework has matured, but the ecosystem has grown more fragmented. Android 14 introduced the
POST_NOTIFICATIONSPOST_NOTIFICATIONSSolving these issues requires a blend of declarative channel design, permission‑aware UI, and proactive reliability testing. The following sections walk through each area with concrete Kotlin examples, tables, and callouts you can apply today.
Channels are the foundation of Android’s notification system. In 2026, the guidance is to treat each channel as a product feature: give it a clear purpose, set the right importance, and allow users to tweak its behavior.
When you create a channel, you specify its importance, description, and whether it should show a badge or lock‑screen visibility. The table below summarizes the importance levels that are still relevant after Android 14’s changes and the typical use‑cases for each.
| Importance Level | User‑Visible Effect | Typical Use‑Case | Notes for 2026 |
|---|---|---|---|
code | No sound, no heads‑up, appears only in shade | Background sync status | Use sparingly; system may still group with other low‑priority alerts |
code | No sound, appears in shade | Low‑priority info (e.g., weather updates) | Safe for battery‑saver modes |
code | No sound, may appear in shade | Regular updates (e.g., new feed items) | Still subject to app standby buckets |
code | Sound, heads‑up possible | Primary user actions (e.g., message received) | Recommended for most interactive channels |
code | Sound, heads‑up, full‑screen intent allowed | Time‑sensitive alerts (e.g., alarms, calls) | Requires code |
code | Same as HIGH but with higher priority in shade | Critical system alerts (rare for third‑party apps) | Generally unavailable to third‑party apps |
[!IMPORTANT]
Always setonly for channels that contain no personal data. Otherwise, usecodesetLockscreenVisibility(Visibility.PUBLIC)to protect user privacy on the lock screen.codeVisibility.PRIVATE
Below is a self‑contained Kotlin function you can call from your
Application.onCreate()SideEffectchatpromoreminder@OptIn(ExperimentalMaterial3Api::class)
fun createNotificationChannels(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(NotificationManager::class.java)
val chatChannel = NotificationChannel(
"chat_channel",
"Chat Messages",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Direct messages from friends or teammates"
setLockscreenVisibility(Notification.VISIBILITY_PRIVATE)
enableVibration(true)
setSound(
Uri.parse("android.resource://${context.packageName}/${R.raw.chat_tone}"),
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
)
}
val promoChannel = NotificationChannel(
"promo_channel",
"Promotions",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Special offers and product updates"
setLockscreenVisibility(Notification.VISIBILITY_PRIVATE)
enableVibration(false) // Reduce disturbance for marketing
}
val reminderChannel = NotificationChannel(
"reminder_channel",
"Reminders",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Time‑based reminders and calendar alerts"
setLockscreenVisibility(Notification.VISIBILITY_PUBLIC)
enableVibration(true)
setSound(
Uri.parse("android.resource://${context.packageName}/${R.raw.alert_tone}"),
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ALARM)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
)
}
manager.createNotificationChannels(listOf(chatChannel, promoChannel, reminderChannel))
}[!TIP]
Store the raw tone files (,codechat_tone.mp3) incodealert_tone.mp3and reference them viacoderes/raw/. This avoids hard‑coding URIs and makes it easy to swap sounds for different product flavors.codeR.raw.*
Android lets users adjust channel behavior via Settings → Apps → Your App → Notifications. To surface this directly from your app, launch the channel settings screen with an Intent:
fun openChannelSettings(context: Context, channelId: String) {
val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
putExtra(Settings.EXTRA_CHANNEL_ID, channelId)
}
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
}
}Call this from a Compose UI button or a traditional
PreferenceScreenStarting with Android 14 (API 34), the
POST_NOTIFICATIONSThe recommended way to request this permission is through the
ActivityResultContracts.RequestPermissionclass NotificationPermissionViewModel(
private val activityResultRegistry: ActivityResultRegistry
) : ViewModel() {
private val requestPermission = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
_permissionState.value = if (granted) PermissionState.Granted else PermissionState.Denied
}
val permissionState = MutableStateFlow<PermissionState>(PermissionState.Unknown)
private val _permissionState = permissionState
fun request() {
requestPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}
fun checkCurrentState(context: Context) {
when {
ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED -> _permissionState.value = PermissionState.Granted
ActivityCompat.shouldShowRequestPermissionRationale(
(activityResultRegistry as? Activity) ?: return@when,
Manifest.permission.POST_NOTIFICATIONS
) -> _permissionState.value = PermissionState.RationaleShown
else -> _permissionState.value = PermissionState.Denied
}
}
}
enum class PermissionState { Unknown, Granted, Denied, RationaleShown }[!NOTE]
If you are using Jetpack Compose, collectas a StateFlow and show a rationale dialog when the state iscodepermissionState. This keeps the UI declarative and avoids leaking Activity references.codeRationaleShown
When the user denies
POST_NOTIFICATIONSBadge@Composable
fun ChatBadge(
unreadCount: Int,
permissionState: StateFlow<PermissionState>,
modifier: Modifier = Modifier
) {
val state by permissionState.collectAsState()
val showBadge = state == PermissionState.Granted || unreadCount > 0
if (showBadge) {
Badge(
badgeContent = { Text(text = unreadCount.toString()) },
modifier = modifier
)
}
// If permission is denied and unreadCount == 0, show nothing.
}[!WARNING]
Never attempt to bypass the permission system by using hidden APIs or exploiting notification listener access. Doing so will lead to Play Store policy violations and potential app removal.
Even with correct channels and permissions, notifications can be delayed by battery optimizations, do‑not‑disturb modes, or OEM‑specific power‑saving features. Reliability therefore hinges on three pillars: automated testing, intelligent fallback mechanisms, and runtime monitoring.
You can verify that your
createNotificationChannelsandroidx.test.core.app.ApplicationProvider@RunWith(AndroidJUnit4::class)
class NotificationChannelTest {
private lateinit var context: Context
private lateinit var manager: NotificationManager
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
manager = context.getSystemService(NotificationManager::class.java)
// Clear any existing channels before each test
manager.deleteNotificationChannel("chat_channel")
manager.deleteNotificationChannel("promo_channel")
manager.deleteNotificationChannel("reminder_channel")
}
@Test
fun `chat channel gets default importance`() {
createNotificationChannels(context)
val channel = manager.getNotificationChannel("chat_channel")
assertNotNull(channel)
assertEquals(
NotificationManager.IMPORTANCE_DEFAULT,
channel.importance
)
}
@Test
fun `promo channel gets low importance`() {
createNotificationChannels(context)
val channel = manager.getNotificationChannel("promo_channel")
assertNotNull(channel)
assertEquals(
NotificationManager.IMPORTANCE_LOW,
channel.importance
)
}
@Test
fun `reminder channel gets high importance`() {
createNotificationChannels(context)
val channel = manager.getNotificationChannel("reminder_channel")
assertNotNull(channel)
assertEquals(
NotificationManager.IMPORTANCE_HIGH,
channel.importance
)
}
}Run these tests on every CI build to catch regressions when you refactor channel creation.
If a notification fails to appear (e.g., the system silently drops it because the app is in a restricted bucket), you can schedule a retry with
WorkManagerclass NotificationRetryWorker(
appContext: Context,
workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
companion object {
private const val KEY_NOTIFICATION_ID = "notification_id"
private const val KEY_CHANNEL_ID = "channel_id"
}
override suspend fun doWork(): Result {
val notificationId = inputData.getInt(KEY_NOTIFICATION_ID, -1)
val channelId = inputData.getString(KEY_CHANNEL_ID)
?: return Result.failure()
// Re‑check if the notification is still needed
if (shouldRetryNotification(notificationId)) {
val notification = buildNotification(notificationId, channelId)
val nm = applicationContext.getSystemService(NotificationManager::class.java)
nm.notify(notificationId, notification)
return Result.success()
}
// No retry needed
return Result.success()
}
private fun shouldRetryNotification(id: Int): Boolean {
// Example: check a SharedPreferences flag set when the user opens the app
val prefs = applicationContext.getSharedPreferences("notif_flags", Context.MODE_PRIVATE)
return !prefs.getBoolean("seen_$id", false)
}
private fun buildNotification(id: Int, channelId: String): Notification {
return NotificationCompat.Builder(applicationContext, channelId)
.setSmallIcon(R.drawable.ic_notify)
.setContentTitle("Retry: Important Update")
.setContentText("We tried to alert you earlier.")
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
}
}Schedule the worker with a linear back‑off policy:
fun scheduleNotificationRetry(
context: Context,
notificationId: Int,
channelId: String
) {
val workData = workDataOf(
KEY_NOTIFICATION_ID to notificationId,
KEY_CHANNEL_ID to channelId
)
val request = OneTimeWorkRequestBuilder<NotificationRetryWorker>()
.setInputData(workData)
.setBackoffCriteria(
BackoffPolicy.LINEAR,
Duration.ofMinutes(5)
)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(
"notification_retry_$notificationId",
ExistingWorkPolicy.KEEP,
request
)
}[!IMPORTANT]
Always cap the number of retries (e.g., max 3 attempts) to avoid spamming the user if the underlying issue persists (such as a permanently denied permission).
To detect silent failures in production, log a custom event each time you post a notification and another when the user opens the app from that notification. A mismatch indicates a delivery problem.
fun trackNotificationPosted(channelId: String, id: Int) {
Firebase.analytics.logEvent("notification_posted") {
param("channel_id", channelId)
param("notification_id", id)
}
}
fun trackNotificationOpened(channelId: String, id: Int) {
Firebase.analytics.logEvent("notification_opened") {
param("channel_id", channelId)
param("notification_id", id)
}
}Create a Firebase dashboard that compares the counts of
notification_postednotification_openedSudarshan Chaudhari
AI Systems Builder / Product Engineer
Bangkok, Thailand
Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.
Related Posts
Related Apps
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
Building something? Available for Android dev and QA consulting.
Work with meComments — powered by Giscus
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
ReadPrivate dream journal — structured entry capture, pattern tagging, and optional Claude-powered insight generation. All data stays on-device by default.
ReadWorkout tracker — exercise logging with set/rep/weight history, goal progression, and local Room DB persistence. No account, no cloud sync required.
Read