We shared 60% of the code and one platform used all of it
Measured per target rather than per line: the iOS app reached a third of the shared surface because the API was shaped around Android's lifecycle.
TL;DR
- "60% shared" was true and measured the wrong thing. It counted lines that compile for every target. Measured by what each target actually calls, iOS reached 34% of the shared public surface and Android reached 96%.
- The gap was not missing iOS features. It was API shape: 41 of 58 shared public entry points
assumed an Android-ish lifecycle — a
Context-like handle, an owner that outlives a screen, a "resume" that iOS does not signal — so the iOS app had its own Swift implementations beside them. - The fix was not to share more. It was to find the 11 duplicate implementations, see which shared API each one was routing around, and reshape those.
- What reading on buys: the reach measurement (a
grepand a linker map), the four shapes that blocked iOS, and what 34% → 70% actually changed.
The case this post is about
The number everyone quoted, from a line count:
$ tokei --exclude '*Test*' shared/src | grep -E 'commonMain|androidMain|iosMain'
commonMain 18,420 lines
androidMain 6,110
iosMain 2,840
desktopMain 1,290
wasmJsMain 1,460
# 18,420 / 30,120 = 61.2%
Sixty-one percent of the Kotlin lives in commonMain. That is a true statement about the
repository and it says nothing about whether anyone calls it.
And the shape that produced the problem, repeated across the shared surface:
// core/sync/src/commonMain/kotlin/SyncManager.kt
class SyncManager(
private val repo: SyncRepo,
private val scope: CoroutineScope,
) {
/** Call from the host's onResume. */
fun onResume() { scope.launch { repo.pull() } }
/** Call from the host's onPause. */
fun onPause() { pending?.cancel() }
/** Register once per Activity. */
fun observeConnectivity(owner: LifecycleOwner, onChange: (Boolean) -> Unit) { … }
}
Three methods, all shaped around a host that has onResume, onPause and a LifecycleOwner.
The symptom
The iOS app growing Swift code that duplicated Kotlin we had already written:
# Swift files implementing something the shared module also implements
iosApp/Sync/SwiftSyncCoordinator.swift 312 lines ← duplicates SyncManager
iosApp/Network/ReachabilityWatcher.swift 88 ← duplicates observeConnectivity
iosApp/Storage/KeychainStore.swift 140 ← duplicates SecureStore
…
11 files, 1,840 lines
And the measurement nobody had taken, once we took it:
# shared PUBLIC entry points (classes, top-level functions, public members): 58
target entry points called reach
android 56 96%
desktop 29 50%
web 24 41%
ios 20 34%
Three of four targets used half or less of the shared surface. The 61% figure and the 34% figure are both correct and they answer different questions — and only one of them is about whether sharing worked.
The cost was not abstract:
# bugs fixed in Kotlin that did NOT reach iOS, because iOS used its own implementation
12 months 7 bugs
of which, re-fixed in Swift later 5
of which, never fixed on iOS 2 (found during this audit)

Why the obvious fix failed
The first response was to set a target: "get iOS to 80% reach". Which produced a quarter of work shaped by a number rather than by a problem — two of the APIs iOS was not calling, it genuinely should not call (an Android-only notification channel helper, a WorkManager scheduler), and "reach" counted them against us.
The second response was to delete the Swift duplicates and force the shared API:
// SwiftSyncCoordinator deleted; call SyncManager directly
syncManager.onResume() // ← from where?
From where. SwiftUI has no onResume. We wired it to scenePhase == .active, which fires on every
return from the app switcher and on every alert dismissal on some iOS versions — so pull() ran
3.4× more often than on Android, and the duplicate existed partly because someone had already
discovered that.
The third response was an expect/actual lifecycle shim so onResume would have an iOS meaning.
Which makes the shared API callable by giving a platform a vocabulary it does not have, and the shim
then needs its own per-platform semantics — the problem moved one file down.
The actual mechanism
The shared percentage measures compilation, and the thing you care about is call-site reach. They diverge in exactly one way: shared code that compiles for a target the target cannot sensibly call. And when we read the 38 unreached entry points, they clustered into four shapes:
shape entry points iOS can call it?
takes a lifecycle owner / Context-like handle 14 no — no equivalent
requires the caller to signal resume/pause 11 no — no such callback
returns a type only Android consumes (LiveData,
WorkRequest, Intent-shaped payload) 9 no — unusable in Swift
genuinely Android-only by design 4 correctly not called
──────
38
Fourteen take a handle iOS does not have. A LifecycleOwner parameter makes a function
uncallable from Swift, and the expect/actual version of that parameter is a type with no
meaningful iOS implementation.
Eleven ask the caller to signal a lifecycle iOS does not emit. onResume/onPause is not a
vocabulary SwiftUI has; the nearest thing has different firing semantics, which is a behavioural
difference rather than a naming one.
Nine return an Android type. A LiveData or a WorkRequest crossing into Swift is either
unusable or wrapped, and the wrapper is where the duplicate starts.
And four are correctly Android-only — which matters, because it means 100% reach is the wrong goal and a reach target is the wrong instrument.
So the real finding is the inverse of "share more". Every one of the 11 Swift duplicates was routing around a shape, not a missing feature. The shared code was written first on Android, by people thinking in Android, and it compiled for iOS the whole time.

The fix
Start from the duplicates, not from the percentage. For each one, find the shared API it routes around and reshape that — which is a bounded piece of work with a definite end, unlike a reach target.
// core/sync/src/commonMain/kotlin/SyncManager.kt
/**
* Reshaped from three lifecycle callbacks to one scope-owned loop.
*
* The old shape asked the HOST to call onResume/onPause and to hand over a LifecycleOwner.
* Android has all three; SwiftUI has none of them, which is why iosApp carried a 312-line
* SwiftSyncCoordinator doing the same work beside this class. Forcing the old API onto
* scenePhase made pull() run 3.4× more often than on Android — the duplicate existed partly
* because someone had already found that.
*
* Now: the caller supplies a scope and a Flow of "the app is foreground". Both exist on all
* four targets; neither is an Android concept wearing a multiplatform name.
*/
class SyncManager(private val repo: SyncRepo) {
fun syncWhileForeground(
scope: CoroutineScope,
isForeground: Flow<Boolean>,
): Job = scope.launch {
isForeground
// The de-dup the Swift coordinator had and this class did not: scenePhase fires on
// alert dismissal on some iOS versions, so a raw edge is not a foreground event.
.distinctUntilChanged()
.collectLatest { foreground -> if (foreground) repo.pull() }
}
/**
* Connectivity as a Flow rather than (owner, callback). The owner parameter was the single
* most common reason an API was uncallable from Swift — 14 of 58 entry points took one.
*/
fun connectivity(): Flow<Boolean> = repo.connectivityChanges().distinctUntilChanged()
}
// iosApp — the 312-line coordinator becomes a wiring call
@main struct MyApp: App {
@Environment(\.scenePhase) private var phase
private let sync = SyncManager(repo: …)
var body: some Scene {
WindowGroup { ContentView() }
.onChange(of: phase) { foregroundSubject.send($0 == .active) }
}
}
And the measurement, in CI, because the number that mattered was one nobody was computing:
# tools/shared-reach.sh — run per target, reported per PR
set -euo pipefail
# The shared PUBLIC surface: what a consumer could call.
./gradlew :core:printPublicApi > /tmp/surface.txt # one fully-qualified name per line
total=$(wc -l < /tmp/surface.txt)
# What a target ACTUALLY calls. Not a line count: the question is call-site reach, and the
# 61%-shared figure that satisfied us for a year was measuring compilation instead.
for target in android ios desktop web; do
called=0
while read -r sym; do
short=${sym##*.}
# Android/desktop/web: grep the target's own sources. iOS: the Swift sources plus the
# linker map, since Swift can reach a symbol our grep patterns miss.
case $target in
ios) hit=$( { grep -rl "\b$short\b" iosApp --include='*.swift' || true
grep -l "$sym" build/ios/App.linkmap 2>/dev/null || true; } | head -1 ) ;;
*) hit=$(grep -rl "\b$short\b" "src/${target}Main" --include='*.kt' 2>/dev/null | head -1) ;;
esac
[ -n "$hit" ] && called=$((called+1))
done < /tmp/surface.txt
echo "$target: $called/$total = $((called*100/total))%"
done
# And the finding that actually drives work: shared APIs with a same-named duplicate in a
# platform source set. This is the list that is bounded and finishable.
echo "--- duplicates ---"
while read -r sym; do
short=${sym##*.}
grep -rl "class *${short#*.}\|struct ${short#*.}" iosApp --include='*.swift' 2>/dev/null \
| sed "s|^| $short duplicated by |"
done < /tmp/surface.txt
And the output it produced:
$ bash tools/shared-reach.sh
android: 56/58 = 96%
ios: 41/58 = 70%
desktop: 38/58 = 65%
web: 31/58 = 53%
--- duplicates ---
SecureStore duplicated by iosApp/Storage/KeychainStore.swift
iOS went from 34% to 70%. One duplicate remains — SecureStore, which is a genuine platform
boundary we chose to leave as Swift and is now a recorded choice rather than an unnoticed one.
$ # the four shapes, after
shape entry points change
takes a lifecycle owner / Context-like handle 2 was 14
requires the caller to signal resume/pause 0 was 11
returns a type only Android consumes 3 was 9
genuinely Android-only by design 4 unchanged — correctly so
$ ./tools/bug-reach.sh --days 180
bugs fixed in Kotlin that did not reach iOS
before (prior 180 days) 4
after 0
How we proved it
That the two numbers measure different things. The measurement that reframed the project, and the cheapest one here:
line-share (tokei, commonMain / total) 61%
call-site reach, averaged across the four targets 55%
call-site reach, iOS alone 34%
The first number cannot go down when a target stops calling something. The second can.
That the unreached APIs cluster by shape, not by feature. The reading that turned a reach number into a work list:
38 unreached entry points, categorised by hand
lifecycle-owner parameter 14
resume/pause callback contract 11
Android return type 9
correctly Android-only 4
Thirty-four of thirty-eight are shape. Four are legitimate.
That every Swift duplicate routed around one of those shapes. Checked one by one, since this is the claim the fix rests on:
Swift file routes around shape
SwiftSyncCoordinator.swift SyncManager.onResume/onPause resume/pause
ReachabilityWatcher.swift observeConnectivity(owner, cb) lifecycle owner
KeychainStore.swift SecureStore genuine boundary
… 8 more … 8 shapes
Ten of eleven were shape. One was a real platform boundary.
That forcing the old API onto scenePhase was worse. Measured before deciding to reshape rather
than to wire, because wiring is the cheaper-looking option:
pull() invocations per foreground hour
android, onResume 4.1
ios, scenePhase == .active 13.9 ← 3.4×
ios, distinctUntilChanged Flow 4.3
The duplicate's de-dup logic was load-bearing, and the shared class did not have it.
That a reach target is the wrong instrument. Established by what the first quarter's work produced:
work driven by "get iOS to 80%"
APIs made iOS-callable that iOS should not call 2
net reach gained from them +3 points
bugs prevented 0
That the duplicate list is the finishable one. The contrast that justifies the script's second half:
instrument size has an end?
"raise reach to 80%" open no — includes APIs that should not be called
"11 duplicates" 11 yes — and each one names its own cause
What I cannot tell you is whether 70% is where this should stop. The remaining 17 unreached entry points are 4 legitimately Android-only, 3 Android return types and 2 lifecycle-owner parameters we have not reshaped, and 8 that iOS could call and does not need yet — and I cannot distinguish "does not need" from "has not noticed" without a product conversation per API.
And an honest limitation of the measurement. The reach script greps for short names, so it
over-counts on common words (Config, State) and the iOS pass needs the linker map because Swift
can reach a Kotlin symbol our patterns miss. Both error in the direction of reporting more reach
than exists, which means the 34% was, if anything, generous.

What this generalizes to
A sharing percentage computed from source counts the code that compiles for every target, which is a property of the build rather than of the architecture. The number that tells you whether sharing worked is how much of the shared surface each target actually calls — and when that number is low, the cause is usually not missing features but API shape: a parameter one platform cannot supply, a callback one platform does not emit, a return type one platform cannot use. The tractable version of the problem is not the percentage at all. It is the list of duplicate implementations, because each one names the shape it was written to avoid.
Related
- A shared ViewModel has four lifecycles — the lifecycle vocabulary that 11 of these entry points assumed.
- The coroutine that kept collecting after the screen was gone — the same asymmetry, where the cost is a leak rather than a duplicate.
Keep reading
One post a week on shipping mobile software. No digests, no course pitch. Subscribe.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".