A running list of the things that cost me time on the iOS side of a Kotlin Multiplatform project.

File IO from iosMain

There’s no java.io.File in iosMain, so file access goes through NSString. Both directions are one-liners, but the signatures are easy to get wrong.

Read a file:

val content = NSString.create(
    contentsOfFile = path,
    encoding = NSUTF8StringEncoding,
    error = null,
) as? String ?: return

The as? String matters — Kotlin/Native bridges NSString to String on the way out, but create returns a nullable Any?, so you need the cast to get anything usable.

Write a file:

NSString.create(content)?.writeToFile(path, true, NSUTF8StringEncoding, null)

The second argument is atomically. Leave it true unless you have a reason not to — it writes to a temp file and renames, so a crash mid-write can’t leave you with a truncated file.

Converting NSString back to String:

Sometimes the implicit bridge doesn’t kick in and you’re stuck holding an NSString. This works:

fun NSString.toKotlinString(): String {
    // Warning: awful hack
    return this.stringByAppendingString("")
}

Appending an empty string forces the value through the bridge. It is genuinely a hack, but it’s the shortest thing that works.

For a real-world reference, LogSharer.ios.kt in music-assistant/mobile-app does file sharing this way.

Finding files on the simulator

When you’ve written a file from iOS and want to actually look at it, you need the app’s data container. First get the booted simulator’s UDID:

xcrun simctl list devices booted

Then ask for the container path:

xcrun simctl get_app_container <SIMULATOR_UDID> <BUNDLE_ID> data

For example:

xcrun simctl get_app_container 43F06E17-DEAA-4623-832E-EFB5BE8FAE52 com.paulcoding.lnviewer.LNViewer data

That prints an absolute path you can cd into or open in Finder. The data argument is the one you want — app gives you the read-only bundle instead.

Room on iOS

Room supports KMP now, but the database builder is expect/actual — you have to supply the iOS path yourself.

IrLinkageError from Koin

This one is not your code’s fault, and the error message does a good job of hiding that:

Uncaught Kotlin exception: kotlin.internal.IrLinkageError: Can not read value from
backing field of property 'androidx_lifecycle_viewmodel_compose_LocalViewModelStoreOwner$stable':
Private backing field of property declared in module
<org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose>
can not be accessed in module <io.insert-koin:koin-compose-viewmodel>

It crashes at runtime, not at compile time, which is what makes it confusing.

What’s happening: koin-compose-viewmodel 4.0.0 was compiled against an older lifecycle-viewmodel-compose (below 2.10.0). In the newer lifecycle artifact, LocalViewModelStoreOwner’s $stable backing field became private. Koin’s compiled-in reference to it no longer resolves, and Kotlin/Native reports that as an IR linkage failure the first time the code path runs.

The fix: update Koin. 4.2.1 is built against Kotlin 2.3.20 and the current lifecycle APIs.

# libs.versions.toml
[versions]
koin = "4.2.1"

Downgrading lifecycle-viewmodel-compose back below 2.10.0 also stops the crash, but it drags the rest of your Compose stack backwards with it. Moving Koin forward is the better direction.

Any IrLinkageError mentioning a “private backing field” in someone else’s module means two dependencies were compiled against incompatible versions of a third. Look at the two modules named in the message and find a pair of versions that agree — don’t go looking in your own source.