KMP Part 11: Platform APIs in Kotlin Multiplatform Push Notifications (FCM + APNs), Camera…
The previous ten articles in this series focused on what KMP can share: models, networking, database, ViewModels, validation, business…
KMP Part 11: Platform APIs in Kotlin Multiplatform Push Notifications (FCM + APNs), Camera, Biometrics, Location, Permissions, and the expect/actual Pattern in Production for Real Mobile Features
The previous ten articles in this series focused on what KMP can share: models, networking, database, ViewModels, validation, business logic. But every real mobile app also needs things that are inherently platform-specific pushing notifications via FCM and APNs, accessing the camera, prompting for biometric authentication, getting GPS location, requesting permissions, observing connectivity changes. None of these have a “multiplatform API” you can drop into commonMain. They require expect/actual abstractions and careful design.
This final article in the KMP series shows the production patterns for handling platform APIs cleanly. Not “look how easy this Hello World is” the real engineering of FCM tokens that need to roundtrip to your backend, biometric prompts with consistent UX across platforms, camera capture that handles permissions and compression, and location updates with battery-aware tradeoffs. By the end you’ll have a complete toolkit for the parts of mobile apps that resist sharing.

Part 1: The Three Strategies for Platform APIs
Before diving into specific APIs, understand the strategic options. Not every platform feature needs the same treatment.
Strategy 1: Pure Platform Code (Don’t Bridge at All)
For features that have completely different paradigms on each platform, attempting to share is more pain than benefit.
KEEP FULLY NATIVE (don't bridge):
• UI rendering (Compose vs SwiftUI — handled by Compose Multiplatform if shared)
• App lifecycle hooks (Application vs UIApplicationDelegate)
• Push notification REGISTRATION (FCM vs APNs setup is wildly different)
• Background processing scheduling (WorkManager vs BGTaskScheduler)
• Deep linking entry points (Intent vs NSUserActivity)
• In-app updates (Play Store API vs App Store)
• Billing / IAP (Play Billing vs StoreKit)
WHY: The API surface, lifecycle, and constraints differ so much that
a unified abstraction either becomes a leaky mess or strips out
so much functionality it's useless.
For these, just write Android code in :app and iOS code in iosApp. The shared module knows nothing about them.
Strategy 2: expect/actual for Simple Capabilities
For features that have similar shapes on both platforms but different implementations, expect/actual is the right tool.
USE expect/actual:
• Getting the device's unique ID
• Logging (Log.d vs NSLog vs print)
• Reading network connectivity status
• Getting current locale / region
• Reading app version, build number
• Generating UUIDs (Kotlin 2.0+ has multiplatform Uuid)
• File path manipulation (with okio)
These have small API surfaces and clear cross-platform semantics.
Strategy 3: Interface + Platform Implementation (Recommended for Complex)
For features with rich APIs, lifecycle, and async behavior, define a Kotlin interface in commonMain and provide platform implementations via DI.
USE interface + Koin/manual DI:
• Biometric authentication
• Camera capture
• Location tracking
• Push notification HANDLING (after registration)
• Permission requests
• Secure storage
• Analytics tracking
• Crash reporting
• Image picking
This pattern scales better than expect/actual for complex features because:
- Interfaces can have many methods
- Implementations can have private helper methods
- Testing is easier (provide a fake interface implementation)
- DI is cleaner than
expect/actualfor class with dependencies
Part 2: Push Notifications — The Complete Pattern
Push notifications are the most-asked-about platform API in KMP. Let’s build the complete pattern.
The Architecture
┌──────────────────────────────────────────────────────────────┐
│ PLATFORM-NATIVE (NOT in commonMain) │
│ │
│ Android: iOS: │
│ • FCM SDK setup • UNUserNotificationCenter setup │
│ • FirebaseMessagingService • didRegisterForRemoteNotifications│
│ • AndroidManifest config • Info.plist config │
│ • Notification channels • Notification categories │
└──────────────────────────────────────────────────────────────┘
│
│ Both platforms call into shared logic
▼
┌──────────────────────────────────────────────────────────────┐
│ SHARED (commonMain) │
│ │
│ • PushNotificationRepository │
│ - registerToken(token: String): suspend │
│ - unregisterCurrentToken(): suspend │
│ - observeReceivedNotifications(): Flow<Notification> │
│ • NotificationData model │
│ • Notification routing (deep link resolution) │
│ • Backend token registration │
└──────────────────────────────────────────────────────────────┘
The pattern: registration is platform-native, handling is shared.
Shared Notification Model
// commonMain/domain/notification/Notification.kt
data class PushNotification(
val id: String,
val title: String,
val body: String,
val data: Map<String, String>,
val receivedAt: Instant,
val category: NotificationCategory
)
enum class NotificationCategory {
PROMOTIONAL, TRANSACTIONAL, ALERT, MESSAGE, UNKNOWN;
companion object {
fun fromString(value: String): NotificationCategory =
entries.find { it.name.equals(value, ignoreCase = true) } ?: UNKNOWN
}
}
// Result of mapping a deep link from notification.data
sealed class NotificationAction {
data object OpenHome : NotificationAction()
data class OpenProduct(val productId: String) : NotificationAction()
data class OpenChat(val conversationId: String) : NotificationAction()
data class OpenUrl(val url: String) : NotificationAction()
data class ShowOffer(val offerId: String) : NotificationAction()
}
Shared Repository
// commonMain/data/notification/PushNotificationRepository.kt
interface PushNotificationRepository {
suspend fun registerDeviceToken(token: String, platform: Platform): Resource<Unit>
suspend fun unregisterCurrentDevice(): Resource<Unit>
fun observeIncomingNotifications(): Flow<PushNotification>
suspend fun handleIncomingPayload(data: Map<String, String>)
fun parseNotificationAction(notification: PushNotification): NotificationAction
}
enum class Platform { ANDROID, IOS }
class PushNotificationRepositoryImpl(
private val api: NotificationApi,
private val deviceInfoProvider: DeviceInfoProvider // expect/actual
) : PushNotificationRepository {
private val _incomingNotifications = MutableSharedFlow<PushNotification>(
replay = 0,
extraBufferCapacity = 64
)
override suspend fun registerDeviceToken(
token: String,
platform: Platform
): Resource<Unit> = safeApiCall {
api.registerToken(RegisterTokenRequest(
token = token,
platform = platform.name.lowercase(),
deviceId = deviceInfoProvider.deviceId(),
appVersion = deviceInfoProvider.appVersion(),
osVersion = deviceInfoProvider.osVersion()
))
}
override suspend fun unregisterCurrentDevice(): Resource<Unit> = safeApiCall {
api.unregisterDevice(deviceInfoProvider.deviceId())
}
override fun observeIncomingNotifications(): Flow<PushNotification> =
_incomingNotifications.asSharedFlow()
override suspend fun handleIncomingPayload(data: Map<String, String>) {
val notification = parseFromPayload(data) ?: return
_incomingNotifications.emit(notification)
}
override fun parseNotificationAction(notification: PushNotification): NotificationAction {
val action = notification.data["action"]
return when (action) {
"open_product" -> {
val id = notification.data["product_id"]
if (id != null) NotificationAction.OpenProduct(id)
else NotificationAction.OpenHome
}
"open_chat" -> {
val id = notification.data["conversation_id"]
if (id != null) NotificationAction.OpenChat(id)
else NotificationAction.OpenHome
}
"open_url" -> {
val url = notification.data["url"]
if (url != null) NotificationAction.OpenUrl(url)
else NotificationAction.OpenHome
}
"show_offer" -> {
val id = notification.data["offer_id"]
if (id != null) NotificationAction.ShowOffer(id)
else NotificationAction.OpenHome
}
else -> NotificationAction.OpenHome
}
}
private fun parseFromPayload(data: Map<String, String>): PushNotification? {
return PushNotification(
id = data["notification_id"] ?: return null,
title = data["title"] ?: "",
body = data["body"] ?: "",
data = data,
receivedAt = Clock.System.now(),
category = NotificationCategory.fromString(data["category"] ?: "UNKNOWN")
)
}
}
Platform-Native Registration
Android side:
// androidApp/notification/MyFirebaseMessagingService.kt
class MyFirebaseMessagingService : FirebaseMessagingService() {
private val repository: PushNotificationRepository by inject()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onNewToken(token: String) {
super.onNewToken(token)
// Hand off to shared repository
scope.launch {
repository.registerDeviceToken(token, Platform.ANDROID)
}
}
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)
// Hand the data payload to shared logic
scope.launch {
repository.handleIncomingPayload(message.data)
}
// Display the system notification (still Android-native)
showSystemNotification(
title = message.notification?.title ?: "",
body = message.notification?.body ?: "",
data = message.data
)
}
private fun showSystemNotification(
title: String,
body: String,
data: Map<String, String>
) {
val notificationManager = getSystemService(NotificationManager::class.java)
val channelId = data["category"] ?: "default"
val notification = NotificationCompat.Builder(this, channelId)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body)
.setAutoCancel(true)
.setContentIntent(createPendingIntentForData(data))
.build()
notificationManager.notify(System.currentTimeMillis().toInt(), notification)
}
}
iOS side (Swift):
// iosApp/AppDelegate.swift
import UIKit
import UserNotifications
import shared
class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate, MessagingDelegate {
let repository = KoinKt.koin.get(repositoryClass: PushNotificationRepository.self)
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
FirebaseApp.configure()
Messaging.messaging().delegate = self
UNUserNotificationCenter.current().delegate = self
application.registerForRemoteNotifications()
return true
}
// FCM token received
func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
guard let token = fcmToken else { return }
Task {
// Hand off to shared repository
_ = try await repository.registerDeviceToken(
token: token,
platform: Platform.ios
)
}
}
// Notification received while app is in foreground
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
let userInfo = notification.request.content.userInfo
let dataMap = convertToStringMap(userInfo)
Task {
try await repository.handleIncomingPayload(data: dataMap)
}
completionHandler([.banner, .sound, .badge])
}
// User tapped notification
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let userInfo = response.notification.request.content.userInfo
let dataMap = convertToStringMap(userInfo)
Task {
try await repository.handleIncomingPayload(data: dataMap)
// Trigger navigation
await router.handleNotificationTap(data: dataMap)
}
completionHandler()
}
private func convertToStringMap(_ userInfo: [AnyHashable: Any]) -> [String: String] {
var result = [String: String]()
for (key, value) in userInfo {
if let stringKey = key as? String {
result[stringKey] = "\(value)"
}
}
return result
}
}
How the UI Uses It (Both Platforms)
// commonMain — viewmodel
class HomeViewModel(
private val pushRepository: PushNotificationRepository
) : ViewModel() {
private val _navigationEvents = MutableSharedFlow<NotificationAction>()
val navigationEvents = _navigationEvents.asSharedFlow()
init {
viewModelScope.launch {
pushRepository.observeIncomingNotifications().collect { notification ->
val action = pushRepository.parseNotificationAction(notification)
_navigationEvents.emit(action)
}
}
}
}
Both Android (Compose) and iOS (SwiftUI) observe navigationEvents to handle deep linking from notifications. The routing logic lives in the shared module. Platform-specific code only handles registration and display.
Part 3: Biometric Authentication
Biometrics — fingerprint, Face ID, fingerprint, iris — are a great example of platform APIs with similar UX but completely different SDKs.
The Interface in commonMain
// commonMain/auth/BiometricAuthenticator.kt
interface BiometricAuthenticator {
suspend fun isAvailable(): BiometricAvailability
suspend fun authenticate(
title: String,
subtitle: String,
description: String,
negativeButtonText: String = "Cancel"
): BiometricResult
}
sealed class BiometricAvailability {
data object Available : BiometricAvailability()
data object NoHardware : BiometricAvailability()
data object HardwareUnavailable : BiometricAvailability()
data object NotEnrolled : BiometricAvailability()
data object SecurityUpdateRequired : BiometricAvailability()
data class Unknown(val reason: String) : BiometricAvailability()
}
sealed class BiometricResult {
data object Success : BiometricResult()
data object UserCancelled : BiometricResult()
data object AuthenticationFailed : BiometricResult()
data class Error(val code: Int, val message: String) : BiometricResult()
data object Lockout : BiometricResult()
}
Android Implementation
// androidMain/auth/AndroidBiometricAuthenticator.kt
class AndroidBiometricAuthenticator(
private val activityProvider: () -> FragmentActivity?
) : BiometricAuthenticator {
override suspend fun isAvailable(): BiometricAvailability {
val activity = activityProvider() ?: return BiometricAvailability.Unknown("No activity")
val manager = BiometricManager.from(activity)
return when (manager.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)) {
BiometricManager.BIOMETRIC_SUCCESS -> BiometricAvailability.Available
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> BiometricAvailability.NoHardware
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> BiometricAvailability.HardwareUnavailable
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> BiometricAvailability.NotEnrolled
BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED ->
BiometricAvailability.SecurityUpdateRequired
else -> BiometricAvailability.Unknown("Unknown state")
}
}
override suspend fun authenticate(
title: String,
subtitle: String,
description: String,
negativeButtonText: String
): BiometricResult = suspendCancellableCoroutine { continuation ->
val activity = activityProvider()
?: return@suspendCancellableCoroutine continuation.resume(
BiometricResult.Error(-1, "No activity")
)
val executor = ContextCompat.getMainExecutor(activity)
val biometricPrompt = BiometricPrompt(
activity,
executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(
result: BiometricPrompt.AuthenticationResult
) {
if (continuation.isActive) continuation.resume(BiometricResult.Success)
}
override fun onAuthenticationError(
errorCode: Int,
errString: CharSequence
) {
if (!continuation.isActive) return
when (errorCode) {
BiometricPrompt.ERROR_USER_CANCELED,
BiometricPrompt.ERROR_NEGATIVE_BUTTON ->
continuation.resume(BiometricResult.UserCancelled)
BiometricPrompt.ERROR_LOCKOUT,
BiometricPrompt.ERROR_LOCKOUT_PERMANENT ->
continuation.resume(BiometricResult.Lockout)
else ->
continuation.resume(BiometricResult.Error(errorCode, errString.toString()))
}
}
override fun onAuthenticationFailed() {
// This is called when biometric didn't match - don't resume,
// user gets another attempt
}
}
)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.setSubtitle(subtitle)
.setDescription(description)
.setNegativeButtonText(negativeButtonText)
.setAllowedAuthenticators(BIOMETRIC_STRONG)
.build()
biometricPrompt.authenticate(promptInfo)
}
}
iOS Implementation
// iosMain/auth/IosBiometricAuthenticator.kt
class IosBiometricAuthenticator : BiometricAuthenticator {
private val context = LAContext()
override suspend fun isAvailable(): BiometricAvailability {
memScoped {
val error = alloc<ObjCObjectVar<NSError?>>()
val canEvaluate = context.canEvaluatePolicy(
LAPolicy.LAPolicyDeviceOwnerAuthenticationWithBiometrics,
error.ptr
)
return if (canEvaluate) {
BiometricAvailability.Available
} else {
when (error.value?.code) {
LAErrorBiometryNotAvailable -> BiometricAvailability.NoHardware
LAErrorBiometryNotEnrolled -> BiometricAvailability.NotEnrolled
LAErrorBiometryLockout -> BiometricAvailability.HardwareUnavailable
else -> BiometricAvailability.Unknown(error.value?.localizedDescription ?: "Unknown")
}
}
}
}
override suspend fun authenticate(
title: String,
subtitle: String,
description: String,
negativeButtonText: String
): BiometricResult = suspendCancellableCoroutine { continuation ->
context.evaluatePolicy(
LAPolicy.LAPolicyDeviceOwnerAuthenticationWithBiometrics,
localizedReason = description
) { success, error ->
if (!continuation.isActive) return@evaluatePolicy
if (success) {
continuation.resume(BiometricResult.Success)
} else {
when (error?.code) {
LAErrorUserCancel,
LAErrorAppCancel,
LAErrorSystemCancel -> continuation.resume(BiometricResult.UserCancelled)
LAErrorBiometryLockout -> continuation.resume(BiometricResult.Lockout)
LAErrorAuthenticationFailed -> continuation.resume(BiometricResult.AuthenticationFailed)
else -> continuation.resume(
BiometricResult.Error(
code = error?.code?.toInt() ?: -1,
message = error?.localizedDescription ?: "Unknown error"
)
)
}
}
}
}
}
Shared Usage
// commonMain — viewmodel uses biometric
class SecureFeatureViewModel(
private val biometric: BiometricAuthenticator
) : ViewModel() {
fun authenticateForPayment() {
viewModelScope.launch {
val availability = biometric.isAvailable()
if (availability !is BiometricAvailability.Available) {
showError("Biometric not available: $availability")
return@launch
}
val result = biometric.authenticate(
title = "Authenticate Payment",
subtitle = "Confirm $${amount.formatted()}",
description = "Use your fingerprint or face to confirm",
negativeButtonText = "Use Password"
)
when (result) {
BiometricResult.Success -> processPayment()
BiometricResult.UserCancelled -> showPasswordFlow()
BiometricResult.Lockout -> showError("Too many attempts. Use password.")
is BiometricResult.Error -> showError(result.message)
BiometricResult.AuthenticationFailed -> { /* Already shown to user */ }
}
}
}
}
Both Android and iOS get exactly the same authentication flow — same UX, same error handling — implemented once.
Part 4: Location Tracking
Location is the most battery-sensitive API. The interface needs to be flexible enough to handle different accuracy needs.
The Interface
// commonMain/location/LocationProvider.kt
interface LocationProvider {
suspend fun getCurrentLocation(priority: LocationPriority = LocationPriority.BALANCED): Location?
fun observeLocation(priority: LocationPriority = LocationPriority.BALANCED): Flow<Location>
suspend fun requestPermission(): PermissionStatus
fun isPermissionGranted(): Boolean
}
data class Location(
val latitude: Double,
val longitude: Double,
val accuracy: Double, // meters
val timestamp: Instant,
val altitude: Double? = null,
val bearing: Double? = null,
val speed: Double? = null // m/s
)
enum class LocationPriority {
HIGH_ACCURACY, // GPS - most accurate, most battery
BALANCED, // GPS + WiFi + cell - good accuracy, medium battery
LOW_POWER, // WiFi + cell only - coarse, low battery
PASSIVE // Whatever's available, no active requests
}
enum class PermissionStatus {
GRANTED, DENIED, DENIED_PERMANENTLY, NOT_DETERMINED
}
Android Implementation
// androidMain/location/AndroidLocationProvider.kt
class AndroidLocationProvider(
private val context: Context,
private val activityProvider: () -> Activity?
) : LocationProvider {
private val fusedClient = LocationServices.getFusedLocationProviderClient(context)
override suspend fun getCurrentLocation(priority: LocationPriority): Location? {
if (!isPermissionGranted()) return null
val locationRequest = CurrentLocationRequest.Builder()
.setPriority(priority.toAndroidPriority())
.build()
return try {
val androidLocation = fusedClient.getCurrentLocation(
locationRequest,
CancellationTokenSource().token
).await()
androidLocation?.toLocation()
} catch (e: Exception) {
null
}
}
override fun observeLocation(priority: LocationPriority): Flow<Location> = callbackFlow {
if (!isPermissionGranted()) {
close(SecurityException("Location permission not granted"))
return@callbackFlow
}
val locationRequest = LocationRequest.Builder(priority.toAndroidPriority(), 5_000L)
.setMinUpdateIntervalMillis(2_000L)
.build()
val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
result.lastLocation?.toLocation()?.let { trySend(it) }
}
}
fusedClient.requestLocationUpdates(locationRequest, callback, Looper.getMainLooper())
awaitClose {
fusedClient.removeLocationUpdates(callback)
}
}
override fun isPermissionGranted(): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
override suspend fun requestPermission(): PermissionStatus {
// Implementation uses ActivityResultLauncher (omitted for brevity)
// Returns PermissionStatus based on user response
TODO("Use ActivityResultLauncher with permission contract")
}
private fun LocationPriority.toAndroidPriority(): Int = when (this) {
LocationPriority.HIGH_ACCURACY -> Priority.PRIORITY_HIGH_ACCURACY
LocationPriority.BALANCED -> Priority.PRIORITY_BALANCED_POWER_ACCURACY
LocationPriority.LOW_POWER -> Priority.PRIORITY_LOW_POWER
LocationPriority.PASSIVE -> Priority.PRIORITY_PASSIVE
}
private fun android.location.Location.toLocation(): Location = Location(
latitude = latitude,
longitude = longitude,
accuracy = accuracy.toDouble(),
timestamp = Instant.fromEpochMilliseconds(time),
altitude = if (hasAltitude()) altitude else null,
bearing = if (hasBearing()) bearing.toDouble() else null,
speed = if (hasSpeed()) speed.toDouble() else null
)
}
iOS Implementation
// iosMain/location/IosLocationProvider.kt
class IosLocationProvider : LocationProvider {
private val locationManager = CLLocationManager()
private val delegate = LocationDelegate()
init {
locationManager.delegate = delegate
}
override suspend fun getCurrentLocation(priority: LocationPriority): Location? {
if (!isPermissionGranted()) return null
locationManager.desiredAccuracy = priority.toIosAccuracy()
return suspendCancellableCoroutine { continuation ->
delegate.onLocationUpdate = { location ->
if (continuation.isActive) {
continuation.resume(location)
delegate.onLocationUpdate = null
}
}
locationManager.requestLocation()
}
}
override fun observeLocation(priority: LocationPriority): Flow<Location> = callbackFlow {
if (!isPermissionGranted()) {
close(SecurityException("Location permission not granted"))
return@callbackFlow
}
locationManager.desiredAccuracy = priority.toIosAccuracy()
delegate.onLocationUpdate = { trySend(it) }
locationManager.startUpdatingLocation()
awaitClose {
locationManager.stopUpdatingLocation()
delegate.onLocationUpdate = null
}
}
override fun isPermissionGranted(): Boolean {
return when (locationManager.authorizationStatus) {
kCLAuthorizationStatusAuthorizedAlways,
kCLAuthorizationStatusAuthorizedWhenInUse -> true
else -> false
}
}
override suspend fun requestPermission(): PermissionStatus =
suspendCancellableCoroutine { continuation ->
delegate.onAuthorizationChange = { status ->
if (continuation.isActive) {
continuation.resume(status.toPermissionStatus())
delegate.onAuthorizationChange = null
}
}
locationManager.requestWhenInUseAuthorization()
}
private fun LocationPriority.toIosAccuracy(): Double = when (this) {
LocationPriority.HIGH_ACCURACY -> kCLLocationAccuracyBest
LocationPriority.BALANCED -> kCLLocationAccuracyHundredMeters
LocationPriority.LOW_POWER -> kCLLocationAccuracyKilometer
LocationPriority.PASSIVE -> kCLLocationAccuracyThreeKilometers
}
}
private class LocationDelegate : NSObject(), CLLocationManagerDelegateProtocol {
var onLocationUpdate: ((Location) -> Unit)? = null
var onAuthorizationChange: ((CLAuthorizationStatus) -> Unit)? = null
override fun locationManager(manager: CLLocationManager, didUpdateLocations: List<*>) {
val cl = didUpdateLocations.lastOrNull() as? CLLocation ?: return
val coord = cl.coordinate
onLocationUpdate?.invoke(Location(
latitude = coord.useContents { latitude },
longitude = coord.useContents { longitude },
accuracy = cl.horizontalAccuracy,
timestamp = Instant.fromEpochSeconds(cl.timestamp.timeIntervalSince1970.toLong()),
altitude = cl.altitude,
bearing = if (cl.course >= 0) cl.course else null,
speed = if (cl.speed >= 0) cl.speed else null
))
}
override fun locationManagerDidChangeAuthorization(manager: CLLocationManager) {
onAuthorizationChange?.invoke(manager.authorizationStatus)
}
}
The implementations differ significantly underneath, but the shared interface gives uniform behavior to the rest of the app.
Part 5: Camera Capture
Camera is more complex because the result is a file/image that needs to flow through your shared logic.
The Interface
// commonMain/camera/CameraCapture.kt
interface CameraCapture {
suspend fun capturePhoto(): CaptureResult
suspend fun pickFromGallery(): CaptureResult
}
sealed class CaptureResult {
data class Success(val imagePath: String, val sizeBytes: Long) : CaptureResult()
data object UserCancelled : CaptureResult()
data object PermissionDenied : CaptureResult()
data class Error(val message: String) : CaptureResult()
}
Usage in Shared Code
// commonMain — viewmodel
class ProfileViewModel(
private val camera: CameraCapture,
private val uploadRepository: UploadRepository
) : ViewModel() {
fun changeProfilePicture() {
viewModelScope.launch {
when (val result = camera.capturePhoto()) {
is CaptureResult.Success -> {
uploadRepository.uploadProfileImage(result.imagePath)
}
CaptureResult.UserCancelled -> { /* user cancelled */ }
CaptureResult.PermissionDenied -> showPermissionDeniedDialog()
is CaptureResult.Error -> showError(result.message)
}
}
}
}
The pattern: shared interface returns a file path. Platform implementations handle the platform-specific UI for capturing. Subsequent processing (upload, compression, analysis) happens in shared code.
Part 6: Permissions — A Unified Approach
Permissions are subtle. Different platforms, different paradigms, different user UX expectations.
// commonMain/permission/PermissionManager.kt
interface PermissionManager {
suspend fun checkPermission(permission: AppPermission): PermissionStatus
suspend fun requestPermission(permission: AppPermission): PermissionStatus
suspend fun shouldShowRationale(permission: AppPermission): Boolean
fun openAppSettings()
}
enum class AppPermission {
CAMERA,
PHOTO_LIBRARY,
LOCATION_WHEN_IN_USE,
LOCATION_ALWAYS,
PUSH_NOTIFICATIONS,
MICROPHONE,
CONTACTS
}
enum class PermissionStatus {
GRANTED,
DENIED,
DENIED_PERMANENTLY, // Android: "Don't ask again" / iOS: equivalent
NOT_DETERMINED, // iOS only - hasn't asked yet
RESTRICTED // iOS only - parental controls, etc.
}
The implementations bridge to Android’s ActivityResultLauncher and iOS's per-API authorization patterns. Shared code uses a uniform API.
Part 7: Patterns and Anti-Patterns
After implementing all these, here are the patterns that worked vs the ones that didn’t.
Patterns That Worked
✅ ONE INTERFACE PER CAPABILITY
BiometricAuthenticator, LocationProvider, CameraCapture
Each has a focused interface. Easy to mock, test, swap.
✅ SEALED CLASSES FOR RESULTS
BiometricResult, CaptureResult, PermissionStatus
Exhaustive switching. No "null means error" ambiguity.
✅ KOIN DI INSTEAD OF expect/actual
Easier to test (swap implementations in tests).
Easier to add new platform later (e.g., desktop).
Constructor injection works naturally.
✅ ASYNC-FIRST DESIGN
Every method that touches platform is suspend or Flow.
No callback-style interfaces in shared code.
✅ PLATFORM REGISTRATION SEPARATED FROM HANDLING
FCM/APNs setup is platform-native. Token handling is shared.
Each layer does what it's best at.
Anti-Patterns to Avoid
❌ expect/actual FOR CLASSES WITH MANY METHODS
expect class FrobnicatesEverything {
fun a(); fun b(); fun c(); fun d(); ...
}
Becomes hard to maintain. Use interfaces instead.
❌ THROWING EXCEPTIONS FROM PLATFORM CODE
Use sealed result classes instead.
Exceptions cross the Kotlin/Native bridge as NSError on iOS - lossy.
❌ PASSING ANDROID Context OR iOS UIViewController TO SHARED CODE
Shared code shouldn't see platform types.
Platform implementations hold those references internally.
❌ BLOCKING CALLS IN PLATFORM IMPLEMENTATIONS
Always async (suspend or Flow). Never .get() on Tasks/Futures.
Blocking calls deadlock on iOS in subtle ways.
❌ FORGETTING TO CLEAN UP IN callbackFlow
awaitClose { /* must remove listeners */ }
Otherwise: memory leaks, ghost callbacks.
Part 8: Wiring It All Up With Koin
The final piece — registering all these platform implementations cleanly.
// commonMain/di/PlatformModule.kt — interfaces declared in common
expect val platformModule: Module
// androidMain/di/PlatformModuleAndroid.kt
actual val platformModule = module {
single<BiometricAuthenticator> {
AndroidBiometricAuthenticator(activityProvider = { get() })
}
single<LocationProvider> {
AndroidLocationProvider(get(), activityProvider = { get() })
}
single<CameraCapture> {
AndroidCameraCapture(get())
}
single<PermissionManager> {
AndroidPermissionManager(activityProvider = { get() })
}
single<PushNotificationRepository> {
PushNotificationRepositoryImpl(get(), get())
}
}
// iosMain/di/PlatformModuleIos.kt
actual val platformModule = module {
single<BiometricAuthenticator> { IosBiometricAuthenticator() }
single<LocationProvider> { IosLocationProvider() }
single<CameraCapture> { IosCameraCapture() }
single<PermissionManager> { IosPermissionManager() }
single<PushNotificationRepository> {
PushNotificationRepositoryImpl(get(), get())
}
}
// commonMain/di/AppModule.kt
val sharedModules = listOf(
platformModule, // platform-specific (different per target)
networkModule, // Ktor - shared
databaseModule, // SQLDelight - shared
repositoryModule, // domain repos - shared
useCaseModule, // business logic - shared
viewModelModule // ViewModels - shared
)
Each platform provides its own platformModule. Everything else is identical. Koin handles the wiring.
Conclusion — The KMP Series Wraps Up
This series has covered eleven articles spanning the entire KMP journey:
Part 1: KMP From Zero — fundamentals, expect/actual, Gradle
Part 2: Sharing Models, Logic, Validation — the domain layer
Part 3: Networking with Ktor — replacing Retrofit
Part 4: Database with SQLDelight — replacing Room
Part 5: ViewModel & State — shared MVI, Flow on iOS
Part 6: Koin DI — replacing Hilt for multiplatform
Part 7: Compose Multiplatform — shared UI (when it fits)
Part 8: Testing, CI/CD, Production — commonTest, GitHub Actions
Part 9: iOS Interop Deep Dive — SKIE, async/await, AsyncSequence
Part 10: Migration Strategy — incremental from existing Android app
Part 11: Platform APIs — push, biometrics, location, camera (this article)
The final lesson is the most important: KMP isn’t about sharing everything. It’s about sharing the right things. Domain logic, business rules, validation, networking, persistence, state management — yes. UI, platform APIs, lifecycle, billing — no, keep those native. The art of KMP is drawing this line cleanly so both Android and iOS teams stay productive on the parts they’re best at.
If you’ve followed this series, you now have a complete blueprint for building a production-grade KMP application — from initial setup, through sharing models and networking, to wrapping platform-specific features in clean interfaces, to migrating an existing codebase, to making the shared module feel native in Swift. The patterns scale from small projects to apps with millions of users.
The most valuable advice I can leave you with: start small, iterate often, listen to your iOS team, and never sacrifice native UX for code sharing. KMP done right is one of the most powerful tools in modern mobile development. KMP done wrong is one of the most frustrating. The difference is mostly discipline.
Thank you for following this series. Build something great.
Connect with Me on LinkedIn
Follow me on LinkedIn
Tags: #KotlinMultiplatform #KMP #PushNotifications #FCM #APNs #Biometrics #Location #Camera #Android #iOS #PlatformAPIs #ExpectActual
메타데이터
- post_id
- cd0d034a18e3
- slug
- kmp-part-11-platform-apis-in-kotlin-multiplatform-push-notifications-fcm-apns-camera-cd0d034a18e3
- url
- https://medium.com/@ramadan123sayed/kmp-part-11-platform-apis-in-kotlin-multiplatform-push-notifications-fcm-apns-camera-cd0d034a18e3
- canonical_url
- https://medium.com/@ramadan123sayed/kmp-part-11-platform-apis-in-kotlin-multiplatform-push-notifications-fcm-apns-camera-cd0d034a18e3
- author_url
- https://medium.com/@ramadan123sayed
- status
- ok
- fetched_at
- 2026-06-09 15:37:30