Platform Dependencies: Bridging Android Context and Platform APIs

πŸ“š Dependency Injection in Kotlin Multiplatform with Koin Part 5 of 8

Consider a typical KMP appβ€”like our fictional Budget Trackerβ€”whose shared code needs to store user preferences. On Android, that means SharedPreferences (which requires Context). On iOS, that means NSUserDefaults (which is globally accessible). Two completely different APIs, two different access patterns, but your shared UserRepository shouldn't know or care about the difference.

This is the platform bridging problem. And solving it cleanlyβ€”without littering your code with expect/actual declarations or platform checksβ€”is what separates maintainable KMP codebases from tangled messes.

The Platform Dependency Inventory

Before writing any bridging code, let's inventory what a typical KMP app like Budget Tracker would need from each platform:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚               Platform Dependencies Inventory                     β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                                   β”‚
β”‚  Capability          β”‚ Android              β”‚ iOS                 β”‚
β”‚  ────────────────────┼──────────────────────┼──────────────────── β”‚
β”‚  Key-Value Storage   β”‚ SharedPreferences    β”‚ NSUserDefaults      β”‚
β”‚                      β”‚ (needs Context)      β”‚ (global)            β”‚
β”‚  ────────────────────┼──────────────────────┼──────────────────── β”‚
β”‚  Secure Storage      β”‚ EncryptedSharedPrefs β”‚ Keychain            β”‚
β”‚                      β”‚ (needs Context)      β”‚ (Security.framework)β”‚
β”‚  ────────────────────┼──────────────────────┼──────────────────── β”‚
β”‚  Database            β”‚ AndroidSqliteDriver  β”‚ NativeSqliteDriver  β”‚
β”‚                      β”‚ (needs Context)      β”‚ (no context)        β”‚
β”‚  ────────────────────┼──────────────────────┼──────────────────── β”‚
β”‚  HTTP Engine         β”‚ OkHttp               β”‚ Darwin/URLSession   β”‚
β”‚  ────────────────────┼──────────────────────┼──────────────────── β”‚
β”‚  App Version         β”‚ PackageManager       β”‚ NSBundle            β”‚
β”‚                      β”‚ (needs Context)      β”‚ (global)            β”‚
β”‚  ────────────────────┼──────────────────────┼──────────────────── β”‚
β”‚  Background Work     β”‚ WorkManager          β”‚ BGTaskScheduler     β”‚
β”‚                      β”‚ (needs Context)      β”‚ (AppDelegate)       β”‚
β”‚  ────────────────────┼──────────────────────┼──────────────────── β”‚
β”‚  Biometrics          β”‚ BiometricPrompt      β”‚ LocalAuthentication β”‚
β”‚                      β”‚ (needs Activity)     β”‚ (LAContext)         β”‚
β”‚                                                                   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Notice the pattern: Android needs Context for almost everything. iOS typically doesn't. This asymmetry is the core challenge.

The Naive Approach (And Why It Fails)

The first instinct is to use expect/actual for each platform dependency:

// commonMain
expect fun createSettings(): Settings
expect fun createDatabaseDriver(): SqlDriver
expect fun createHttpEngine(): HttpClientEngine
expect fun getAppVersion(): String
expect fun isDebugBuild(): Boolean

// androidMain
actual fun createSettings(): Settings = ... // Needs Context!
actual fun createDatabaseDriver(): SqlDriver = ... // Needs Context!

This immediately hits a wall: the Android implementations need Context, but expect fun declarations can't have platform-specific parameters. You can't write:

// This doesn't work
expect fun createSettings(context: Context): Settings  // Context is Android-only!

Some developers work around this with a global Context holder:

// DON'T DO THIS
object ContextHolder {
    lateinit var appContext: Context
}

actual fun createSettings(): Settings {
    return AndroidSettings(ContextHolder.appContext.getSharedPreferences(...))
}

This "works" but creates hidden dependencies, makes testing difficult, and can cause crashes if accessed before initialization. Let's do better.

The PlatformDependencies Pattern

Instead of individual expect/actual functions, we consolidate all platform dependencies into a single interface:

// commonMain/kotlin/com/budgettracker/platform/PlatformDependencies.kt

package com.budgettracker.platform

import app.cash.sqldelight.db.SqlDriver
import com.russhwolf.settings.Settings
import io.ktor.client.engine.HttpClientEngine

/**
 * Contract for platform-specific dependencies.
 *
 * Each platform provides an implementation of this interface,
 * which is then registered with Koin at app startup.
 *
 * This pattern centralizes platform bridging in one place,
 * making it easy to:
 * - See all platform dependencies at a glance
 * - Mock for testing
 * - Add new platform capabilities
 */
interface PlatformDependencies {

    /**
     * Key-value storage for user preferences.
     * Android: SharedPreferences, iOS: NSUserDefaults
     */
    val settings: Settings

    /**
     * Encrypted storage for sensitive data (tokens, credentials).
     * Android: EncryptedSharedPreferences, iOS: Keychain
     */
    val secureStorage: SecureStorage

    /**
     * SQLDelight database driver.
     * Android: AndroidSqliteDriver, iOS: NativeSqliteDriver
     */
    val databaseDriver: SqlDriver

    /**
     * Ktor HTTP client engine.
     * Android: OkHttp, iOS: Darwin
     */
    val httpClientEngine: HttpClientEngine

    /**
     * Application metadata (version, build number, etc.)
     */
    val appInfo: AppInfo

    /**
     * Platform capabilities and feature flags.
     */
    val capabilities: PlatformCapabilities
}

Supporting types:

// commonMain/kotlin/com/budgettracker/platform/AppInfo.kt

data class AppInfo(
    val appVersion: String,
    val buildNumber: Int,
    val osName: String,
    val osVersion: String,
    val deviceModel: String,
    val isDebug: Boolean
)

// commonMain/kotlin/com/budgettracker/platform/PlatformCapabilities.kt

data class PlatformCapabilities(
    val hasBiometrics: Boolean,
    val hasBackgroundSync: Boolean,
    val hasPushNotifications: Boolean,
    val maxBackgroundTime: Long  // milliseconds, 0 if unlimited
)

// commonMain/kotlin/com/budgettracker/platform/SecureStorage.kt

interface SecureStorage {
    suspend fun getString(key: String): String?
    suspend fun putString(key: String, value: String)
    suspend fun remove(key: String)
    suspend fun clear()
    suspend fun hasKey(key: String): Boolean
}

Now shared code depends only on these interfaces. It never imports Android or iOS types.

Android Implementation

The Android implementation receives Context at construction timeβ€”explicitly, not through a global holder:

// androidMain/kotlin/com/budgettracker/platform/AndroidPlatformDependencies.kt

package com.budgettracker.platform

import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import com.budgettracker.db.BudgetTrackerDatabase
import com.russhwolf.settings.Settings
import com.russhwolf.settings.SharedPreferencesSettings
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.engine.okhttp.OkHttp
import java.util.concurrent.TimeUnit

/**
 * Android implementation of platform dependencies.
 *
 * Receives Context at construction, avoiding global state.
 * Uses lazy initialization for expensive operations.
 */
class AndroidPlatformDependencies(
    private val context: Context
) : PlatformDependencies {

    override val settings: Settings by lazy {
        SharedPreferencesSettings(
            delegate = context.getSharedPreferences(
                "budget_tracker_preferences",
                Context.MODE_PRIVATE
            ),
            commit = false  // Use apply() for better performance
        )
    }

    override val secureStorage: SecureStorage by lazy {
        AndroidSecureStorage(context)
    }

    override val databaseDriver: SqlDriver by lazy {
        AndroidSqliteDriver(
            schema = BudgetTrackerDatabase.Schema,
            context = context,
            name = "budget_tracker.db"
        )
    }

    override val httpClientEngine: HttpClientEngine by lazy {
        OkHttp.create {
            config {
                connectTimeout(30, TimeUnit.SECONDS)
                readTimeout(30, TimeUnit.SECONDS)
                writeTimeout(30, TimeUnit.SECONDS)
                retryOnConnectionFailure(true)
            }
        }
    }

    override val appInfo: AppInfo by lazy {
        createAppInfo()
    }

    override val capabilities: PlatformCapabilities by lazy {
        createCapabilities()
    }

    private fun createAppInfo(): AppInfo {
        val packageManager = context.packageManager
        val packageName = context.packageName

        val packageInfo = if (Build.VERSION.SDK_INT >= 33) {
            packageManager.getPackageInfo(
                packageName,
                PackageManager.PackageInfoFlags.of(0)
            )
        } else {
            @Suppress("DEPRECATION")
            packageManager.getPackageInfo(packageName, 0)
        }

        val versionCode = if (Build.VERSION.SDK_INT >= 28) {
            packageInfo.longVersionCode.toInt()
        } else {
            @Suppress("DEPRECATION")
            packageInfo.versionCode
        }

        return AppInfo(
            appVersion = packageInfo.versionName ?: "unknown",
            buildNumber = versionCode,
            osName = "Android",
            osVersion = Build.VERSION.RELEASE,
            deviceModel = "${Build.MANUFACTURER} ${Build.MODEL}",
            isDebug = (context.applicationInfo.flags and
                android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
        )
    }

    private fun createCapabilities(): PlatformCapabilities {
        val biometricManager = androidx.biometric.BiometricManager.from(context)
        val canAuthenticate = biometricManager.canAuthenticate(
            androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
        )

        return PlatformCapabilities(
            hasBiometrics = canAuthenticate == androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS,
            hasBackgroundSync = true,  // WorkManager available on all supported Android versions
            hasPushNotifications = true,
            maxBackgroundTime = 0  // No strict limit on Android
        )
    }
}

Android Secure Storage

The secure storage implementation uses Android's EncryptedSharedPreferences:

// androidMain/kotlin/com/budgettracker/platform/AndroidSecureStorage.kt

package com.budgettracker.platform

import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

class AndroidSecureStorage(context: Context) : SecureStorage {

    private val masterKey = MasterKey.Builder(context)
        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
        .build()

    private val securePrefs: SharedPreferences = EncryptedSharedPreferences.create(
        context,
        "budget_tracker_secure",
        masterKey,
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
    )

    override suspend fun getString(key: String): String? = withContext(Dispatchers.IO) {
        securePrefs.getString(key, null)
    }

    override suspend fun putString(key: String, value: String) = withContext(Dispatchers.IO) {
        securePrefs.edit().putString(key, value).apply()
    }

    override suspend fun remove(key: String) = withContext(Dispatchers.IO) {
        securePrefs.edit().remove(key).apply()
    }

    override suspend fun clear() = withContext(Dispatchers.IO) {
        securePrefs.edit().clear().apply()
    }

    override suspend fun hasKey(key: String): Boolean = withContext(Dispatchers.IO) {
        securePrefs.contains(key)
    }
}

iOS Implementation

The iOS implementation doesn't need any contextβ€”most APIs are globally accessible:

// iosMain/kotlin/com/budgettracker/platform/IosPlatformDependencies.kt

package com.budgettracker.platform

import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.native.NativeSqliteDriver
import com.budgettracker.db.BudgetTrackerDatabase
import com.russhwolf.settings.NSUserDefaultsSettings
import com.russhwolf.settings.Settings
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.engine.darwin.Darwin
import platform.Foundation.*
import platform.UIKit.UIDevice
import platform.LocalAuthentication.LAContext
import platform.LocalAuthentication.LAPolicyDeviceOwnerAuthenticationWithBiometrics

class IosPlatformDependencies : PlatformDependencies {

    override val settings: Settings by lazy {
        NSUserDefaultsSettings(NSUserDefaults.standardUserDefaults)
    }

    override val secureStorage: SecureStorage by lazy {
        IosSecureStorage()
    }

    override val databaseDriver: SqlDriver by lazy {
        NativeSqliteDriver(
            schema = BudgetTrackerDatabase.Schema,
            name = "budget_tracker.db"
        )
    }

    override val httpClientEngine: HttpClientEngine by lazy {
        Darwin.create {
            configureRequest {
                setAllowsCellularAccess(true)
                setTimeoutInterval(30.0)
            }
            configureSession {
                // Use default URLSession configuration
            }
        }
    }

    override val appInfo: AppInfo by lazy {
        createAppInfo()
    }

    override val capabilities: PlatformCapabilities by lazy {
        createCapabilities()
    }

    private fun createAppInfo(): AppInfo {
        val bundle = NSBundle.mainBundle
        val device = UIDevice.currentDevice

        return AppInfo(
            appVersion = bundle.objectForInfoDictionaryKey("CFBundleShortVersionString")
                as? String ?: "unknown",
            buildNumber = (bundle.objectForInfoDictionaryKey("CFBundleVersion")
                as? String)?.toIntOrNull() ?: 0,
            osName = device.systemName,
            osVersion = device.systemVersion,
            deviceModel = device.model,
            isDebug = Platform.isDebugBinary
        )
    }

    private fun createCapabilities(): PlatformCapabilities {
        val laContext = LAContext()
        val canUseBiometrics = laContext.canEvaluatePolicy(
            LAPolicyDeviceOwnerAuthenticationWithBiometrics,
            error = null
        )

        return PlatformCapabilities(
            hasBiometrics = canUseBiometrics,
            hasBackgroundSync = false,  // iOS has stricter background limits
            hasPushNotifications = true,
            maxBackgroundTime = 30_000  // ~30 seconds typical background time
        )
    }
}

iOS Secure Storage (Keychain)

// iosMain/kotlin/com/budgettracker/platform/IosSecureStorage.kt

package com.budgettracker.platform

import kotlinx.cinterop.*
import platform.CoreFoundation.*
import platform.Foundation.*
import platform.Security.*

class IosSecureStorage : SecureStorage {

    private val serviceName = "com.budgettracker"

    override suspend fun getString(key: String): String? {
        val query = createQuery(key).toMutableMap().apply {
            put(kSecReturnData, kCFBooleanTrue)
            put(kSecMatchLimit, kSecMatchLimitOne)
        }

        memScoped {
            val result = alloc<CFTypeRefVar>()
            val status = SecItemCopyMatching(query.toCFDictionary(), result.ptr)

            return if (status == errSecSuccess) {
                val data = CFBridgingRelease(result.value) as? NSData
                data?.toKotlinString()
            } else {
                null
            }
        }
    }

    override suspend fun putString(key: String, value: String) {
        // Remove existing value first (SecItemAdd fails if key exists)
        remove(key)

        val data = value.toNSData()
        val query = createQuery(key).toMutableMap().apply {
            put(kSecValueData, data)
        }

        val status = SecItemAdd(query.toCFDictionary(), null)
        if (status != errSecSuccess && status != errSecDuplicateItem) {
            throw SecurityException("Failed to store secure value: $status")
        }
    }

    override suspend fun remove(key: String) {
        val query = createQuery(key)
        SecItemDelete(query.toCFDictionary())
    }

    override suspend fun clear() {
        val query = mapOf<Any?, Any?>(
            kSecClass to kSecClassGenericPassword,
            kSecAttrService to serviceName
        )
        SecItemDelete(query.toCFDictionary())
    }

    override suspend fun hasKey(key: String): Boolean {
        return getString(key) != null
    }

    private fun createQuery(key: String): Map<Any?, Any?> = mapOf(
        kSecClass to kSecClassGenericPassword,
        kSecAttrService to serviceName,
        kSecAttrAccount to key
    )

    private fun Map<Any?, Any?>.toCFDictionary(): CFDictionaryRef? {
        return (this as Map<Any?, Any>).let { map ->
            CFDictionaryCreateMutable(
                null,
                map.size.toLong(),
                null,
                null
            ).also { dict ->
                map.forEach { (key, value) ->
                    CFDictionaryAddValue(dict, key as CFTypeRef?, value as CFTypeRef?)
                }
            }
        }
    }

    private fun String.toNSData(): NSData =
        (this as NSString).dataUsingEncoding(NSUTF8StringEncoding)!!

    private fun NSData.toKotlinString(): String =
        NSString.create(this, NSUTF8StringEncoding) as String
}

class SecurityException(message: String) : Exception(message)

Wiring Platform Dependencies to Koin

Now we create platform-specific Koin modules that provide PlatformDependencies:

// androidMain/kotlin/com/budgettracker/di/AndroidPlatformModule.kt

package com.budgettracker.di

import android.content.Context
import com.budgettracker.platform.AndroidPlatformDependencies
import com.budgettracker.platform.PlatformDependencies
import org.koin.dsl.module

/**
 * Creates the Android platform module.
 *
 * Must receive Context from the Application class.
 */
fun createAndroidPlatformModule(context: Context) = module {
    // Provide the full PlatformDependencies implementation
    single<PlatformDependencies> { AndroidPlatformDependencies(context) }

    // Also expose individual dependencies for convenience
    // This allows modules to declare `get<Settings>()` instead of
    // `get<PlatformDependencies>().settings`
    single { get<PlatformDependencies>().settings }
    single { get<PlatformDependencies>().secureStorage }
    single { get<PlatformDependencies>().databaseDriver }
    single { get<PlatformDependencies>().httpClientEngine }
    single { get<PlatformDependencies>().appInfo }
    single { get<PlatformDependencies>().capabilities }
}
// iosMain/kotlin/com/budgettracker/di/IosPlatformModule.kt

package com.budgettracker.di

import com.budgettracker.platform.IosPlatformDependencies
import com.budgettracker.platform.PlatformDependencies
import org.koin.dsl.module

/**
 * Creates the iOS platform module.
 *
 * No context needed - iOS APIs are globally accessible.
 */
fun createIosPlatformModule() = module {
    single<PlatformDependencies> { IosPlatformDependencies() }

    single { get<PlatformDependencies>().settings }
    single { get<PlatformDependencies>().secureStorage }
    single { get<PlatformDependencies>().databaseDriver }
    single { get<PlatformDependencies>().httpClientEngine }
    single { get<PlatformDependencies>().appInfo }
    single { get<PlatformDependencies>().capabilities }
}

Initialization From Each Platform

Android Initialization

// androidApp/src/main/kotlin/com/budgettracker/BudgetTrackerApplication.kt

class BudgetTrackerApplication : Application() {

    override fun onCreate() {
        super.onCreate()

        initKoin(
            platformModule = createAndroidPlatformModule(this)
        )
    }
}

iOS Initialization

// iosApp/Sources/BudgetTrackerApp.swift

import SwiftUI
import Shared

@main
struct BudgetTrackerApp: App {

    init() {
        KoinInitKt.doInitKoin(
            platformModule: IosPlatformModuleKt.createIosPlatformModule()
        )
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

Using Platform Dependencies in Shared Code

Now shared code can use platform dependencies without knowing which platform it's running on:

// commonMain - shared repository
class UserRepository(
    private val settings: Settings,
    private val secureStorage: SecureStorage,
    private val appInfo: AppInfo
) {
    suspend fun saveAuthToken(token: String) {
        secureStorage.putString("auth_token", token)
    }

    suspend fun getAuthToken(): String? {
        return secureStorage.getString("auth_token")
    }

    fun saveUserPreference(key: String, value: String) {
        settings.putString(key, value)
    }

    fun getAppVersion(): String = appInfo.appVersion
}

The Koin module wires everything together:

// commonMain
val dataModule = module {
    single {
        UserRepository(
            settings = get(),
            secureStorage = get(),
            appInfo = get()
        )
    }
}

Handling Platform-Specific Features

Some features exist on one platform but not the other. This can be handled with capability checks:

// commonMain
class BackgroundSyncManager(
    private val capabilities: PlatformCapabilities,
    private val syncRepository: SyncRepository
) {
    fun scheduleSync() {
        if (!capabilities.hasBackgroundSync) {
            // iOS: Fall back to sync-on-foreground
            return
        }

        // Android: Schedule with WorkManager (via expect/actual)
        scheduleBackgroundWork()
    }
}

For features that require platform-specific implementation:

// commonMain
expect class BackgroundWorkScheduler {
    fun schedulePeriodicSync(intervalMinutes: Int)
    fun cancelAllWork()
}

// androidMain
actual class BackgroundWorkScheduler(
    private val context: Context
) {
    actual fun schedulePeriodicSync(intervalMinutes: Int) {
        val request = PeriodicWorkRequestBuilder<SyncWorker>(
            intervalMinutes.toLong(), TimeUnit.MINUTES
        ).build()

        WorkManager.getInstance(context)
            .enqueueUniquePeriodicWork(
                "sync",
                ExistingPeriodicWorkPolicy.KEEP,
                request
            )
    }

    actual fun cancelAllWork() {
        WorkManager.getInstance(context).cancelAllWork()
    }
}

// iosMain
actual class BackgroundWorkScheduler {
    actual fun schedulePeriodicSync(intervalMinutes: Int) {
        // iOS has limited background capabilities
        // Register for background app refresh instead
        BGTaskScheduler.shared.register(
            forTaskWithIdentifier: "com.budgettracker.sync",
            using: nil
        ) { task in
            // Handle background task
        }
    }

    actual fun cancelAllWork() {
        BGTaskScheduler.shared.cancelAllTaskRequests()
    }
}

Testing Platform Dependencies

The PlatformDependencies interface makes testing straightforward:

// commonTest
class FakePlatformDependencies : PlatformDependencies {
    override val settings: Settings = MapSettings()  // In-memory settings

    override val secureStorage: SecureStorage = FakeSecureStorage()

    override val databaseDriver: SqlDriver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)

    override val httpClientEngine: HttpClientEngine = MockEngine { request ->
        respond(content = "{}", status = HttpStatusCode.OK)
    }

    override val appInfo: AppInfo = AppInfo(
        appVersion = "1.0.0-test",
        buildNumber = 1,
        osName = "Test",
        osVersion = "1.0",
        deviceModel = "TestDevice",
        isDebug = true
    )

    override val capabilities: PlatformCapabilities = PlatformCapabilities(
        hasBiometrics = false,
        hasBackgroundSync = true,
        hasPushNotifications = false,
        maxBackgroundTime = 0
    )
}

class FakeSecureStorage : SecureStorage {
    private val storage = mutableMapOf<String, String>()

    override suspend fun getString(key: String) = storage[key]
    override suspend fun putString(key: String, value: String) { storage[key] = value }
    override suspend fun remove(key: String) { storage.remove(key) }
    override suspend fun clear() { storage.clear() }
    override suspend fun hasKey(key: String) = storage.containsKey(key)
}

Use in tests:

class UserRepositoryTest {

    @Test
    fun `saves and retrieves auth token`() = runTest {
        val fakePlatform = FakePlatformDependencies()
        val repository = UserRepository(
            settings = fakePlatform.settings,
            secureStorage = fakePlatform.secureStorage,
            appInfo = fakePlatform.appInfo
        )

        repository.saveAuthToken("test_token")

        assertEquals("test_token", repository.getAuthToken())
    }
}

Summary: Platform Bridging Checklist

When adding a new platform dependency:

  1. Add to PlatformDependencies interface - Single source of truth
  2. Implement in Android - Handle Context requirements
  3. Implement in iOS - Usually simpler, no context needed
  4. Add to platform modules - Make available to Koin
  5. Create test fake - Enable testing without platform code
  6. Document capability differences - Note what each platform supports

What's Next

Platform dependencies are flowing into our shared code. But we've glossed over a major challenge: iOS developers don't want to learn Koin. They want Swift APIs that feel native.

In the next article, we'll explore iOS and Swift integrationβ€”creating factory objects that hide Koin from Swift, integrating with SwiftUI's state system, and bridging Kotlin Flows to Swift's reactive patterns.