Skip to content
Go back

🔔 Cross-Platform Notifications with KMP — All in Kotlin!

by KMP Bits

KMP Bits Cover

“Wait… did I just request iOS notification permissions from Kotlin?” 😱

That was exactly my reaction while building this demo. I expected a lot of platform-specific code, maybe some Swift boilerplate, and instead… I ended up with almost everything in shared Kotlin.

KMP continues to surprise me — and this time, it’s all about notifications.


🧩 What This Demo Does

This demo app shows how you can handle local notifications for both Android and iOS directly from shared Kotlin code, using expect/actual.

You can:

All from the Kotlin side.


🧱 Core Setup

Here’s the shared expect class that defines our cross-platform notification interface:

expect class NotificationService {

    fun showNotification(
        title: String,
        message: String?
    )

    fun requestPermission(
        activity: PlatformActivity,
        onFinished: (Boolean) -> Unit
    )

    suspend fun areNotificationsEnabled(): Boolean
}

Each platform provides its own actual implementation. On Android it uses NotificationCompat, and on iOS it directly interacts with UNUserNotificationCenter and UIApplication — all from Kotlin!


🧠 ViewModel Logic

To keep everything reactive, there’s a simple AppViewModel that triggers and observes notification permission state:

class AppViewModel(
    private val notificationService: NotificationService
) : ViewModel() {

    private val _notificationEnabledState = MutableStateFlow(false)
    val notificationEnabledState = _notificationEnabledState.asStateFlow()

    init {
        // The first time the user opens the app, check if there is a notification permission
        viewModelScope.launch {
            _notificationEnabledState.value = notificationService.areNotificationsEnabled()
        }

        // When the user clicks on the permission dialog, the notification permission is granted/denied
        viewModelScope.launch {
            NotificationStateEvent.observe().collectLatest {
                _notificationEnabledState.value = it == NotificationPermissionType.GRANTED
            }
        }
    }

    fun askNotificationPermission(activity: PlatformActivity) {
        notificationService.requestPermission(activity)
    }

    fun showNotification() {
        notificationService.showNotification(
            title = "Hello, ${getPlatform().name}!",
            message = "This is a notification message."
        )
    }
}

The PlatformActivity is also an expect class — only Android needs to pass the native Activity for permission handling.


🤖 Android part

On Android, there is a small difference due to how permission handling works. We need to override onRequestPermissionsResult and notify the ViewModel when the user grants or denies the permission.

First, create a NotificationStateEvent to send and observe permission changes:

object NotificationStateEvent {
    private val _event = Channel<NotificationPermissionType>()

    fun observe() = _event.receiveAsFlow()

    fun send(event: NotificationPermissionType) {
        _event.trySend(event)
    }
}

enum class NotificationPermissionType {
    GRANTED,
    DENIED,
}

Then, in your MainActivity, override onRequestPermissionsResult and send the event:

override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String?>,
        grantResults: IntArray,
        deviceId: Int
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults, deviceId)

        if (requestCode == NotificationService.REQUEST_CODE_NOTIFICATIONS) {
            NotificationStateEvent.send(
                if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED)
                    NotificationPermissionType.GRANTED
                else
                    NotificationPermissionType.DENIED
            )
        }
    }

With this in place, your ViewModel will correctly react to permission results, keeping the notification state in sync.

🧩 Dependency Injection with Koin

This demo uses standard Koin for dependency injection (no annotations this time). If you’ve seen my previous article about Koin Annotations, you’ll notice how easily both approaches work with shared KMP code.


🍏 The Only Swift Code

The only Swift code you’ll find lives in AppDelegate, because iOS requires a delegate to handle notification callbacks. Everything else runs from Kotlin.

@main
struct iOSApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
    
    init() {
        KoinCommonKt.doInitKoin()
        UNUserNotificationCenter.current().delegate = delegate
    }
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil
    ) -> Bool {
        return true
    }
    
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        willPresent notification: UNNotification,
        withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
    ) {
        completionHandler([.banner, .sound, .badge])
    }

    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse,
        withCompletionHandler completionHandler: @escaping () -> Void
    ) {
        let userInfo = response.notification.request.content.userInfo
        print("Notification tapped. User info: \(userInfo)")
        completionHandler()
    }
}

That’s it — Swift stays minimal, Kotlin handles the rest.


🎬 Watch It in Action

Marigolds in container garden

🧩 Try It Yourself

You can find the full demo here: 👉 KMP Notifications Demo on GitHub

Clone it, run it, and watch as Kotlin talks directly to iOS and Android without breaking a sweat.


⚡ Why It Matters

This demo proves that Kotlin Multiplatform isn’t limited to ViewModels or business logic. You can integrate deep platform features like notifications — and keep your architecture clean and shared.

If you’ve been wondering whether KMP is ready for production-level native integrations… this is your answer. KMP is an absolute beast. 💪


Follow more experiments:


Share this post on:

Next Post
🚀 Exploring Multi-Layer Navigation in Jetpack Compose with Navigation 3