โ† Back to list

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โ€ฆ

Vitalii Voitenko ยท 2026-06-06 12:49 ยท 5 claps ยท 8.2 min read
#compose-multiplatform #push-notification #android #android-notification #kotlin-multiplatform
Open on Medium โ†—
Wiki topics: ๐Ÿ“ฑ ยท Mobile Development

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: UNUserNotificationCenter and 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 screen parameter is a plain String here 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.1 second 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: NotificationPermissionController holds temporary references to the active Activity. 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: NotificationNavigator is defined directly as a Kotlin object. This can make concurrent unit testing difficult. For larger applications, inject it via Koin as a true single singleton 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.willPresent in 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