PPrepLearn
Progress · 0/6 phases

📱 Phase 4 — Platform APIs & iOS Interop (Days 43—54)

8 min read · Days 43 · Notion

Core insight: KMP shares logic. Platforms own their feel. Camera, GPS, biometrics, push notifications, and Keychain are platform APIs that must be accessed from iosMain or androidMain. SKIE makes the Swift side of this effortless — Kotlin sealed classes become Swift enums, StateFlow becomes AsyncSequence, coroutines become async/await.


📅 Day 43–44 — SKIE: Swift-Kotlin Interop Done Right

Why SKIE

Without SKIE, Kotlin code exposed to Swift is awkward:

  • StateFlow has no Swift equivalent
  • Sealed classes become open classes in Swift (no exhaustive switch)
  • Coroutines require manual wrapping

SKIE generates idiomatic Swift wrappers automatically.

// shared/build.gradle.kts
plugins {
    alias(libs.plugins.skie)
}
 
skie {
    features {
        // All features enabled by default with SKIE 0.9+
        // Sealed classes → Swift enums
        // StateFlow → AsyncSequence
        // Suspend functions → async/await
        // Flows → AsyncStream
    }
}
// commonMain — sealed class for UI state
sealed interface AuthState {
    data object Loading : AuthState
    data class Authenticated(val user: User) : AuthState
    data class Unauthenticated(val reason: String? = null) : AuthState
    data class Error(val message: String) : AuthState
}
 
class AuthViewModel : ViewModel() {
    val authState: StateFlow<AuthState> = MutableStateFlow(AuthState.Loading)
}
// Swift side — SKIE transforms these automatically
func observeAuthState() async {
    for await state in viewModel.authState {
        // SKIE generates a proper Swift enum
        switch state {
        case .loading:
            showLoadingSpinner()
        case .authenticated(let user):
            showHomeScreen(user: user)
        case .unauthenticated(let reason):
            showLoginScreen(message: reason)
        case .error(let message):
            showError(message: message)
        }
    }
}
 
// Calling suspend functions from Swift
func login(email: String, password: String) async {
    do {
        // SKIE wraps suspend fun as async throws
        let user = try await viewModel.login(email: email, password: password)
        print("Logged in: \(user.name)")
    } catch {
        print("Login failed: \(error)")
    }
}

Initialising Koin from Swift

// iosMain — KoinInitializer.kt
object KoinInitializer {
    fun init() {
        startKoin {
            modules(sharedModules() + iosModule)
        }
    }
}
 
// Swift AppDelegate / @main App
@main
struct iOSApp: App {
    init() {
        KoinInitializer.shared.init()
    }
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

📅 Day 45—46 — Platform APIs: Keychain & Secure Storage

// commonMain — domain/security/SecureStorage.kt
interface SecureStorage {
    suspend fun saveString(key: String, value: String)
    suspend fun getString(key: String): String?
    suspend fun remove(key: String)
    suspend fun clear()
}
 
// commonMain — domain/security/TokenManager.kt
class TokenManager(private val storage: SecureStorage) {
    suspend fun saveAccessToken(token: String) =
        storage.saveString(KEY_ACCESS_TOKEN, token)
 
    suspend fun getAccessToken(): String? =
        storage.getString(KEY_ACCESS_TOKEN)
 
    suspend fun saveRefreshToken(token: String) =
        storage.saveString(KEY_REFRESH_TOKEN, token)
 
    suspend fun clearTokens() = storage.clear()
 
    companion object {
        private const val KEY_ACCESS_TOKEN = "access_token"
        private const val KEY_REFRESH_TOKEN = "refresh_token"
    }
}
// androidMain — security/AndroidSecureStorage.kt
class AndroidSecureStorage(context: Context) : SecureStorage {
    private val prefs = EncryptedSharedPreferences.create(
        context,
        "secure_prefs",
        MasterKey.Builder(context)
            .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
            .build(),
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
    )
 
    override suspend fun saveString(key: String, value: String) =
        withContext(Dispatchers.IO) { prefs.edit().putString(key, value).apply() }
 
    override suspend fun getString(key: String): String? =
        withContext(Dispatchers.IO) { prefs.getString(key, null) }
 
    override suspend fun remove(key: String) =
        withContext(Dispatchers.IO) { prefs.edit().remove(key).apply() }
 
    override suspend fun clear() =
        withContext(Dispatchers.IO) { prefs.edit().clear().apply() }
}
// iosMain — security/IosSecureStorage.kt
class IosSecureStorage : SecureStorage {
    override suspend fun saveString(key: String, value: String) {
        val query = CFDictionaryCreateMutable(null, 0, null, null)
        CFDictionarySetValue(query, kSecClass, kSecClassGenericPassword)
        CFDictionarySetValue(query, kSecAttrAccount, CFStringCreateWithCString(null, key, kCFStringEncodingUTF8))
        SecItemDelete(query)  // delete existing first
 
        val newItem = CFDictionaryCreateMutable(null, 0, null, null)
        CFDictionarySetValue(newItem, kSecClass, kSecClassGenericPassword)
        CFDictionarySetValue(newItem, kSecAttrAccount, CFStringCreateWithCString(null, key, kCFStringEncodingUTF8))
        val data = value.encodeToByteArray().toNSData()
        CFDictionarySetValue(newItem, kSecValueData, data)
        SecItemAdd(newItem, null)
    }
 
    override suspend fun getString(key: String): String? {
        val query = CFDictionaryCreateMutable(null, 0, null, null)
        CFDictionarySetValue(query, kSecClass, kSecClassGenericPassword)
        CFDictionarySetValue(query, kSecAttrAccount, CFStringCreateWithCString(null, key, kCFStringEncodingUTF8))
        CFDictionarySetValue(query, kSecReturnData, kCFBooleanTrue)
        var result: CFTypeRef? = null
        val status = SecItemCopyMatching(query, result?.ptr)
        if (status == errSecSuccess) {
            return (result as? NSData)?.toByteArray()?.decodeToString()
        }
        return null
    }
 
    override suspend fun remove(key: String) { /* SecItemDelete */ }
    override suspend fun clear() { /* SecItemDelete all */ }
}

📅 Day 47—48 — Platform APIs: Camera & Image Picker

// commonMain — expect/actual for image picking
expect class ImagePicker {
    @Composable
    fun registerPicker(onImagePicked: (ByteArray) -> Unit)
    fun pickImage()
}
// androidMain
actual class ImagePicker {
    private var onImagePicked: (ByteArray) -> Unit = {}
    private lateinit var launcher: ActivityResultLauncher<PickVisualMediaRequest>
 
    @Composable
    actual fun registerPicker(onImagePicked: (ByteArray) -> Unit) {
        this.onImagePicked = onImagePicked
        launcher = rememberLauncherForActivityResult(
            contract = ActivityResultContracts.PickVisualMedia()
        ) { uri ->
            uri?.let { selectedUri ->
                val context = LocalContext.current
                val bytes = context.contentResolver.openInputStream(selectedUri)?.readBytes()
                bytes?.let { onImagePicked(it) }
            }
        }
    }
 
    actual fun pickImage() {
        launcher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
    }
}
// iosMain
actual class ImagePicker : NSObject(), UIImagePickerControllerDelegateProtocol,
    UINavigationControllerDelegateProtocol {
 
    private var onImagePicked: (ByteArray) -> Unit = {}
    private var viewController: UIViewController? = null
 
    @Composable
    actual fun registerPicker(onImagePicked: (ByteArray) -> Unit) {
        this.onImagePicked = onImagePicked
        viewController = LocalUIViewController.current
    }
 
    actual fun pickImage() {
        val picker = UIImagePickerController()
        picker.sourceType = UIImagePickerControllerSourceType.UIImagePickerControllerSourceTypePhotoLibrary
        picker.delegate = this
        viewController?.presentViewController(picker, animated = true, completion = null)
    }
 
    override fun imagePickerController(
        picker: UIImagePickerController,
        didFinishPickingMediaWithInfo: Map<Any?, *>
    ) {
        val image = didFinishPickingMediaWithInfo[UIImagePickerControllerOriginalImage] as? UIImage
        image?.let {
            val data = UIImageJPEGRepresentation(it, 0.8)
            val bytes = data?.toByteArray() ?: return
            onImagePicked(bytes)
        }
        picker.dismissViewControllerAnimated(true, completion = null)
    }
}

📅 Day 49—50 — Platform APIs: Push Notifications

// commonMain — notification interface
interface NotificationManager {
    suspend fun requestPermission(): Boolean
    fun showLocalNotification(title: String, body: String, id: Int = 0)
    val fcmToken: Flow<String?>
}
 
// commonMain — handle notification payloads
@Serializable
data class PushPayload(
    val type: String,
    val targetId: String? = null,
    val message: String? = null
)
 
class NotificationHandler(
    private val notificationManager: NotificationManager
) {
    fun handlePush(payloadJson: String) {
        val payload = Json.decodeFromString<PushPayload>(payloadJson)
        when (payload.type) {
            "new_message" -> { /* navigate to chat */ }
            "new_follower" -> { /* navigate to profile */ }
            "system" -> { /* show alert */ }
        }
    }
}
// androidMain — Firebase Messaging Service
class MyFirebaseMessagingService : FirebaseMessagingService() {
    override fun onNewToken(token: String) {
        // Store via Koin → TokenManager
        GlobalScope.launch {
            KoinPlatform.getKoin().get<TokenManager>().saveFcmToken(token)
        }
    }
 
    override fun onMessageReceived(remoteMessage: RemoteMessage) {
        val payload = remoteMessage.data["payload"] ?: return
        KoinPlatform.getKoin().get<NotificationHandler>().handlePush(payload)
    }
}

📅 Day 51—52 — Platform APIs: GPS & Location

// commonMain — domain/location/LocationService.kt
data class Location(
    val latitude: Double,
    val longitude: Double,
    val accuracy: Double,
    val timestamp: Long
)
 
interface LocationService {
    suspend fun requestPermission(): Boolean
    fun observeLocation(): Flow<Location?>
    suspend fun getCurrentLocation(): Location?
}
 
// commonMain — use case
class GetNearbyUsersUseCase(
    private val locationService: LocationService,
    private val userRepository: UserRepository
) {
    operator fun invoke(): Flow<List<User>> = flow {
        val location = locationService.getCurrentLocation() ?: return@flow
        userRepository.getNearbyUsers(
            lat = location.latitude,
            lon = location.longitude,
            radiusKm = 10.0
        ).collect { emit(it) }
    }
}
// androidMain — LocationServiceImpl using Fused Location Provider
class AndroidLocationService(private val context: Context) : LocationService {
    private val fusedLocationClient =
        LocationServices.getFusedLocationProviderClient(context)
 
    override suspend fun requestPermission(): Boolean {
        // Use accompanist-permissions or ActivityResultContract
        return true
    }
 
    override fun observeLocation(): Flow<Location?> = callbackFlow {
        val request = LocationRequest.Builder(
            Priority.PRIORITY_HIGH_ACCURACY, 5000L
        ).build()
 
        val callback = object : LocationCallback() {
            override fun onLocationResult(result: LocationResult) {
                result.lastLocation?.let { loc ->
                    trySend(Location(loc.latitude, loc.longitude, loc.accuracy.toDouble(), loc.time))
                }
            }
        }
        fusedLocationClient.requestLocationUpdates(request, callback, Looper.getMainLooper())
        awaitClose { fusedLocationClient.removeLocationUpdates(callback) }
    }
 
    override suspend fun getCurrentLocation(): Location? =
        suspendCancellableCoroutine { cont ->
            fusedLocationClient.lastLocation
                .addOnSuccessListener { loc ->
                    cont.resume(loc?.let { Location(it.latitude, it.longitude, it.accuracy.toDouble(), it.time) })
                }
                .addOnFailureListener { cont.resume(null) }
        }
}

📅 Day 53—54 — Phase 4 Project: Native Features App

Deliverable: App using all platform APIs from shared Kotlin

Features:
Login with biometrics (FaceID on iOS, Fingerprint on Android)
Profile image: pick from gallery or take photo
Upload avatar: ByteArrayKtor multipartdisplay with Coil
  ✓ Location: show nearby users on a map
Push notifications: handle payloadnavigate to relevant screen
Secure token storage: JWT in Keychain (iOS) / EncryptedSharedPreferences (Android)
  ✓ SKIE: Swift UI consumes Kotlin ViewModel directly
 
SKIE verification:
AuthState sealed classexhaustive Swift switch
StateFlow<UserListState>for-await loop in Swift
suspend fun login() → async throws in Swift
No manual wrapping required in any Swift file

⚠️ Common mistakes Phase 4

Mistake 1

❌ Writing @ObjCName and manual Swift wrappers instead of using SKIE.

Before SKIE existed, every Kotlin sealed class, Flow, and coroutine needed manual Swift wrappers. This is 2x the code and always out of sync.

Use SKIE. It auto-generates everything. The only manual Swift code you write is the entry point (ContentView.swift) and UI-specific interactions.

Mistake 2

❌ Calling platform APIs from commonMain directly using @OptIn(ExperimentalForeignApi::class)****.

This compiles but makes your common module iOS-only. It will fail to compile for Android or Desktop.

Platform API calls ALWAYS go in platform source sets. commonMain only sees the interface (via expect or regular interfaces). Implementations live in androidMain, iosMain, etc.

Mistake 3

❌ Not handling iOS memory model in iosMain code.

Kotlin/Native had strict ownership rules. Although the new memory model (Kotlin 1.7.20+) relaxed most of this, objects shared across threads must still be careful with mutable state.

Keep shared objects immutable where possible. Use @ThreadLocal for thread-specific mutable state. Ensure all network/DB work uses Dispatchers.IO.

Related