Local Notifications in Compose Multiplatform: A Complete Guide for Android and iOS ๐
If youโve ever tried to get notifications working on both Android and iOS from a single codebase, you know the feeling. Android demandsโฆ
Local Notifications in Compose Multiplatform: A Complete Guide for Android and iOS ๐
If youโve ever tried to get notifications working on both Android and iOS from a single codebase, you know the feeling. Android demands notification channels, pending intents, and permission checks. iOS hits you with UNUserNotificationCenter, delegates, and Apple-specific frameworks. It often feels like trying to make a cat and a dog agree on a mobile architecture, two completely different systems, yet your KMP codebase has to speak both languages fluently.
Welcome to the next part of our production-ready KMP series! After setting up remote behavior with **Firebase Remote Config and measuring user steps with [Amplitude Analytics](https://medium.com/@Vetal_3534/what-your-users-really-want-tracking-behavior-in-kmp-projects-using-amplitude-8c983b8e69c5)**, itโs time to talk to our users directly.
In this article, weโll build a clean, reusable local notification system from scratch, handle native runtime permissions, fire local notifications, and implement reliable deep link navigation all through a single shared interface.
Whatโs inside:
- Shared interface design with
expect/actual - Android implementation: channels,
PendingIntent, and activity permission bridges - iOS implementation:
UNUserNotificationCenterand Swift app delegate wiring - Deep link navigation when tapping a notification (handling both foreground and cold start cases)
- Wiring everything together with Koin
- Potential pitfalls to fix before production โ ๏ธ
The Architecture Overview ๐บ๏ธ
Before writing any code, letโs agree on the design. The idea follows the exact same pattern we used for Amplitude and Firebase: one interface in commonMain, and two concrete implementations in androidMain and iosMain.
The tricky part here isnโt showing a notification โ thatโs relatively straightforward. The real challenge is what happens when the user taps it.
On Android, a notification tap delivers an Intent to your Activity. On iOS, that same tap triggers a delegate method in AppDelegate. Both of these are completely platform-specific entry points, but we need to funnel them into our shared Compose navigation graph.
To solve this, weโll build NotificationNavigator โ a shared Kotlin object that acts as a reactive bridge between native tap handlers and your Compose NavController.
Step 1: The Shared Interface ๐ค
Letโs start in commonMain. Everything our shared business logic or ViewModels will ever touch lives here.
// commonMain/notification/NotificationManager.kt
enum class NotificationPermissionStatus {
GRANTED, DENIED, NOT_DETERMINED
}
data class NotificationAction(
val screen: String,
val data: Map<String, String> = emptyMap()
)
interface NotificationManager {
suspend fun requestPermission(): Boolean
suspend fun checkPermissionStatus(): NotificationPermissionStatus
suspend fun showNotification(
id: String = generateId(),
title: String,
body: String,
action: NotificationAction? = null
)
suspend fun cancelNotification(id: String)
suspend fun cancelAllNotifications()
}
expect class NotificationFactory {
fun create(): NotificationManager
}
internal expect fun generateId(): String
NotificationAction carries our deep link information: which screen to open and any extra key-value payloads.
๐ก Pro Tip: The
screenparameter is a plainStringhere for simplicity. In a production app, consider using a type-safe approach or a sealed class instead of magic strings like"notification_detail". You'll thank yourself when your codebase grows!
Step 2: The Navigation Bridge ๐
Before we touch any platform code, letโs build the shared piece that handles the โuser tapped a notification โ navigate somewhereโ flow.
// commonMain/notification/NotificationNavigator.kt
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
object NotificationNavigator {
private val channel = Channel<NotificationAction>(Channel.BUFFERED)
val events: Flow<NotificationAction> = channel.receiveAsFlow()
fun navigate(action: NotificationAction) {
channel.trySend(action)
}
}
The Channel.BUFFERED configuration is our secret weapon here. If a user taps a notification while the app is completely closed (a cold start scenario), native iOS and Android components will catch the event and fire it before Compose even finishes its first frame. The buffer queues this event up and holds it until a collector in our UI finally subscribes. No dropped events, no broken deep links!
Step 3: Android Implementation ๐ค
Android needs a few things: a notification channel (mandatory since API 26), a PendingIntent to reopen or bring the app to the foreground, and runtime permission handling for API 33+.
// androidMain/notification/NotificationManager.android.kt
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager as AndroidSystemNotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import java.util.UUID
actual class NotificationFactory(private val context: Context) {
actual fun create(): NotificationManager = AndroidNotificationManager(context)
}
internal actual fun generateId(): String = UUID.randomUUID().toString()
class AndroidNotificationManager(private val context: Context) : NotificationManager {
private val notificationManagerCompat get() = NotificationManagerCompat.from(context)
init {
createNotificationChannel()
}
override suspend fun requestPermission(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true
if (isGranted) return true
return NotificationPermissionController.requestPermission?.invoke() ?: false
}
override suspend fun checkPermissionStatus(): NotificationPermissionStatus {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
return when {
isGranted -> NotificationPermissionStatus.GRANTED
NotificationPermissionController.shouldShowRationale?.invoke() == true ->
NotificationPermissionStatus.DENIED
else -> NotificationPermissionStatus.NOT_DETERMINED
}
}
return if (notificationManagerCompat.areNotificationsEnabled())
NotificationPermissionStatus.GRANTED
else NotificationPermissionStatus.DENIED
}
override suspend fun showNotification(
id: String, title: String, body: String, action: NotificationAction?
) {
if (checkPermissionStatus() != NotificationPermissionStatus.GRANTED) return
val intent = Intent(context, context.packageManager.getLaunchIntentForPackage(context.packageName)?.component?.className?.let { Class.forName(it) }).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
action?.let {
putExtra(EXTRA_NOTIFICATION_SCREEN, it.screen)
putExtra(EXTRA_NOTIFICATION_ID, id)
it.data.forEach { (key, value) -> putExtra(key, value) }
}
}
val pendingIntent = PendingIntent.getActivity(
context, id.hashCode(), intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
.setContentText(body)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.build()
notificationManagerCompat.notify(id.hashCode(), notification)
}
override suspend fun cancelNotification(id: String) {
notificationManagerCompat.cancel(id.hashCode())
}
override suspend fun cancelAllNotifications() {
notificationManagerCompat.cancelAll()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID, "App Notifications",
AndroidSystemNotificationManager.IMPORTANCE_HIGH
)
context.getSystemService(AndroidSystemNotificationManager::class.java)
.createNotificationChannel(channel)
}
}
companion object {
private const val CHANNEL_ID = "app_notifications"
const val EXTRA_NOTIFICATION_SCREEN = "notification_screen"
const val EXTRA_NOTIFICATION_ID = "notification_id"
}
private val isGranted get() = ContextCompat.checkSelfPermission(
context, Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
}
Step 4: Android Permission Bridge ๐
Thereโs a classic architectural puzzle here: our AndroidNotificationManager lives safely inside the Application context. However, launching the runtime permission dialog (POST_NOTIFICATIONS on Android 13+) explicitly requires an Activity context. We need a way to communicate across boundaries.
Enter pragmatic engineering: Weโll establish a global mutable controller. It might not be the textbook architectural approach, but for a single-Activity app, it is an incredibly robust, zero-boilerplate solution. Weโll talk about how to make it super elegant in the potential pitfalls section, I promise!
// androidMain/notification/NotificationPermissionController.kt
import kotlinx.coroutines.CompletableDeferred
object NotificationPermissionController {
var requestPermission: (suspend () -> Boolean)? = null
var shouldShowRationale: (() -> Boolean)? = null
}
Now, letโs wire this controller into your MainActivity so it registers during onCreate and cleans up during onDestroy:
// androidMain/MainActivity.kt
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.app.ActivityCompat
import kotlinx.coroutines.CompletableDeferred
class MainActivity : ComponentActivity() {
private var permissionDeferred: CompletableDeferred<Boolean>? = null
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
permissionDeferred?.complete(granted)
permissionDeferred = null
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
NotificationPermissionController.requestPermission = {
val deferred = CompletableDeferred<Boolean>()
permissionDeferred = deferred
permissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
deferred.await()
}
NotificationPermissionController.shouldShowRationale = {
ActivityCompat.shouldShowRequestPermissionRationale(
this, android.Manifest.permission.POST_NOTIFICATIONS
)
}
handleNotificationIntent(intent)
setContent { App() }
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleNotificationIntent(intent)
}
override fun onDestroy() {
NotificationPermissionController.requestPermission = null
NotificationPermissionController.shouldShowRationale = null
super.onDestroy()
}
private fun handleNotificationIntent(intent: Intent) {
val screen = intent.getStringExtra(
AndroidNotificationManager.EXTRA_NOTIFICATION_SCREEN
) ?: return
val notificationId = intent.getStringExtra(AndroidNotificationManager.EXTRA_NOTIFICATION_ID)
NotificationNavigator.navigate(
NotificationAction(
screen = screen,
data = buildMap { notificationId?.let { put("notification_id", it) } }
)
)
}
}
onNewIntent handles cases where the app is already running and the user taps a fresh notification banner onCreate captures cold starts when the app is launched entirely from scratch by tapping a notification.
โ ๏ธ Critical Configuration: Donโt forget to add android:launchMode="singleTop" to your MainActivity in AndroidManifest.xml. Without it, tapping a notification while the app is active will recreate the entire Activity from scratch, resetting your states and dropping navigation routing arguments!
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop">
</activity>
Step 5: iOS Implementation ๐
iOS uses UNUserNotificationCenter, which has been the unified way to handle local and remote notifications since iOS 10.
// iosMain/notification/NotificationManager.ios.kt
import platform.Foundation.NSUUID
import platform.UserNotifications.UNAuthorizationOptionAlert
import platform.UserNotifications.UNAuthorizationOptionBadge
import platform.UserNotifications.UNAuthorizationOptionSound
import platform.UserNotifications.UNAuthorizationStatusAuthorized
import platform.UserNotifications.UNAuthorizationStatusDenied
import platform.UserNotifications.UNMutableNotificationContent
import platform.UserNotifications.UNNotificationRequest
import platform.UserNotifications.UNNotificationSound
import platform.UserNotifications.UNNotificationTrigger
import platform.UserNotifications.UNTimeIntervalNotificationTrigger
import platform.UserNotifications.UNUserNotificationCenter
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
actual class NotificationFactory {
actual fun create(): NotificationManager = IosNotificationManager()
}
internal actual fun generateId(): String = NSUUID.UUID().UUIDString()
class IosNotificationManager : NotificationManager {
private val center get() = UNUserNotificationCenter.currentNotificationCenter()
override suspend fun requestPermission(): Boolean = suspendCancellableCoroutine { cont ->
center.requestAuthorizationWithOptions(
options = UNAuthorizationOptionAlert or UNAuthorizationOptionSound or UNAuthorizationOptionBadge
) { granted, error ->
cont.resume(granted && error == null)
}
}
override suspend fun checkPermissionStatus(): NotificationPermissionStatus =
suspendCancellableCoroutine { cont ->
center.getNotificationSettingsWithCompletionHandler { settings ->
val status = when (settings?.authorizationStatus) {
UNAuthorizationStatusAuthorized -> NotificationPermissionStatus.GRANTED
UNAuthorizationStatusDenied -> NotificationPermissionStatus.DENIED
else -> NotificationPermissionStatus.NOT_DETERMINED
}
cont.resume(status)
}
}
override suspend fun showNotification(
id: String, title: String, body: String, action: NotificationAction?
) {
val content = UNMutableNotificationContent().apply {
setTitle(title)
setBody(body)
setSound(UNNotificationSound.defaultSound())
action?.let { act ->
val userInfo = mutableMapOf<Any?, Any?>(
"screen" to act.screen,
"notification_id" to id
)
act.data.forEach { (key, value) -> userInfo[key] = value }
setUserInfo(userInfo as Map<Any?, *>)
}
}
val trigger = UNTimeIntervalNotificationTrigger.triggerWithTimeInterval(
timeInterval = 0.1,
repeats = false
)
val request = UNNotificationRequest.requestWithIdentifier(
identifier = id,
content = content,
trigger = trigger
)
suspendCancellableCoroutine<Unit> { cont ->
center.addNotificationRequest(request) { error ->
cont.resume(Unit)
}
}
}
override suspend fun cancelNotification(id: String) {
center.removeDeliveredNotificationsWithIdentifiers(listOf(id))
}
override suspend fun cancelAllNotifications() {
center.removeAllDeliveredNotifications()
}
}
Dev Note: Yes, that
0.1second trigger is a deliberate, shameless workaround. By default, iOS explicitly hides notification banners when the app is actively running in the foreground. This tiny delay tricks iOS into scheduling it properly as a system event. It keeps our demo code clean, but don't show this trick to your iOS tech lead without a warm coffee in hand!
Step 6: iOS Swift Bridge ๐
When a user taps an iOS notification banner, the native OS fires a callback directly into Swiftโs AppDelegate. We need an entry point to pass this event into Kotlin.
First, write a simple Kotlin function inside iosMain:
// iosMain/notification/IosNotificationBridge.kt
fun handleNotificationTap(screen: String, data: Map<String, String>) {
NotificationNavigator.navigate(NotificationAction(screen = screen, data = data))
}
Next, open Xcode and implement UNUserNotificationCenterDelegate within your Swift project:
// iosApp/AppDelegate.swift
import UserNotifications
import ComposeApp
class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
UNUserNotificationCenter.current().delegate = self
return true
}
// Fired when a user taps the notification banner
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let userInfo = response.notification.request.content.userInfo
let screen = userInfo["screen"] as? String ?? ""
let notificationId = userInfo["notification_id"] as? String ?? ""
IosNotificationBridgeKt.handleNotificationTap(
screen: screen,
data: ["notification_id": notificationId]
)
completionHandler()
}
}
Donโt forget to assign this delegate class inside your main iOSApp.swift entry file:
// iosApp/iOSApp.swift
import SwiftUI
import ComposeApp
@main
struct iOSApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Step 7: Wiring Navigation in Compose ๐งญ
Both native sides are now successfully streaming events into NotificationNavigator. Now, our shared Compose code simply needs to listen and execute navigation commands:
// commonMain/app/App.kt
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
@Composable
fun App() {
MaterialTheme {
val navController = rememberNavController()
LaunchedEffect(navController) {
NotificationNavigator.events.collect { action ->
when (action.screen) {
"notification_detail" -> navController.navigate(
Route.NotificationDetail(
notificationId = action.data["notification_id"] ?: "",
title = "From Notification",
message = "Opened via notification deep link!"
)
)
}
}
}
NavHost(navController = navController, startDestination = Route.MainGraph) {
// Define your standard NavHost graphs and composables here...
}
}
}
Step 8: Dependency Injection with Koin ๐ ๏ธ
Letโs register our modules so they can be injected effortlessly across our ViewModels:
// commonMain/Modules.kt
val sharedModule = module {
single { get<NotificationFactory>().create() }
viewModelOf(::HomeViewModel)
}
// androidMain/Modules.android.kt
actual val platformModule = module {
single { NotificationFactory(androidContext()) }
}
// iosMain/Modules.ios.kt
actual val platformModule = module {
single { NotificationFactory() }
}
Step 9: Usage from ViewModels ๐
Now, calling notifications from anywhere in your platform-agnostic business logic is beautifully clean:
[embed]
Things Worth Knowing Before You Ship โ ๏ธ
While this system serves as a great, reliable production foundation, keep these architectural trade-offs in mind:
- Global Permission Controllers Leak Context:
NotificationPermissionControllerholds temporary references to the activeActivity. If an Android process gets aggressively killed or closed, those references could leak. For massive enterprise applications, consider binding this bridge to an active Koin Activity Scope. - Global Singletons Limit Testing:
NotificationNavigatoris defined directly as a Kotlinobject. This can make concurrent unit testing difficult. For larger applications, inject it via Koin as a truesinglesingleton instance instead. - The iOS Foreground Presentation Illusion: Our 0.1s trigger delay works perfectly for demos. However, for a production iOS application, you should properly implement
UNUserNotificationCenterDelegate.willPresentin Swift to determine whether a banner should slide down when the app is active in the foreground.
Conclusion ๐
Weโve officially built a complete, cross-platform local notification architecture from scratch using Compose Multiplatform. The expect/actual pattern proves its incredible value once again: your ViewModels donโt know or care which operating system they are currently running on, and heavy native platform complexities remain safely encapsulated behind a clean interface.
Donโt forget to check out the previous parts of this architectural series where we set up Firebase Remote Config and Amplitude Analytics to complete your enterprise-ready KMP template!
Happy coding, and let your notifications always land smoothly! ๐ Also, feel free to connect with me on LinkedIn
๋ฉํ๋ฐ์ดํฐ
- post_id
- 92a2d87374fb
- slug
- local-notifications-in-compose-multiplatform-a-complete-guide-for-android-and-ios-92a2d87374fb
- url
- https://medium.com/@Vetal_3534/local-notifications-in-compose-multiplatform-a-complete-guide-for-android-and-ios-92a2d87374fb
- canonical_url
- https://medium.com/@Vetal_3534/local-notifications-in-compose-multiplatform-a-complete-guide-for-android-and-ios-92a2d87374fb
- author_url
- https://medium.com/@Vetal_3534
- status
- ok
- fetched_at
- 2026-08-06 03:04:31