Copy and share: two features that are entirely platform code
A gesture requirement, a presenting view controller, and neither on desktop.
TL;DR
- A
share(text)that worked on Android, iOS and desktop and silently did nothing on the web — because the browser's share API requires a user gesture, and our call happened oneawaitafter the tap. copy(text)fared worse: it threw on the web for the same reason, and thetry/catcharound it reported "copied" to the user anyway.- Four platform requirements that cannot be expressed in a
fun share(text: String): a presenting view controller (plus a source rect on iPad), a live user gesture, a parent window, and a context. - What reading on buys: the three requirements, the signature that carries them, and the measurement that showed a "copied" toast appearing over an empty clipboard 3,100 times.
The case this post is about
A share action on a note, in shared code:
// commonMain/kotlin/share/Sharing.kt
expect class Sharing {
suspend fun shareText(text: String, subject: String?)
suspend fun copyToClipboard(text: String)
}
// commonMain — the call site
fun onShareTapped(note: Note) = scope.launch {
val link = api.createShareLink(note.id) // a network round trip
sharing.shareText(link.url, subject = note.title)
}
fun onCopyTapped(note: Note) = scope.launch {
val link = api.createShareLink(note.id)
sharing.copyToClipboard(link.url)
_toast.emit("Link copied")
}
// wasmJsMain
actual suspend fun shareText(text: String, subject: String?) {
window.navigator.share(ShareData(text = text, title = subject))
}
actual suspend fun copyToClipboard(text: String) {
window.navigator.clipboard.writeText(text)
}
That compiles, and three of four platforms work.
The symptom
Two different failures from one cause, and the second one is the bad one.
Share did nothing. No dialog, no error visible to the user, and in the console:
NotAllowedError: Failed to execute 'share' on 'Navigator':
Must be handling a user gesture to perform a share request.
Copy threw, and the toast fired anyway. Because the launch body kept going:
sharing.copyToClipboard(link.url) // throws NotAllowedError
_toast.emit("Link copied") // ← never reached… except it was
It was reached because the actual did not await the promise. clipboard.writeText returns a
Promise, the Kotlin wrapper discarded it, so the function returned normally, the rejection
happened later with no handler, and the toast said "Link copied" over an unchanged clipboard.
Measured over three weeks before anyone connected the console error to the toast:
# web target only
copy taps 3,114
clipboard writes that succeeded 0
"Link copied" toasts shown 3,114
Three thousand taps answered with a lie, by an unawaited promise.

Why the obvious fix failed
The first fix was to await the promise, which is correct and insufficient:
actual suspend fun copyToClipboard(text: String) {
window.navigator.clipboard.writeText(text).await()
}
Now the exception propagates, the toast does not fire, and the user gets nothing at all with an error in a console they cannot see. Honest and still broken — the clipboard write still fails, because awaiting it does not give it a gesture.
The second fix was to move the API call after the share, so the share happens first:
fun onShareTapped(note: Note) = scope.launch {
sharing.shareText("", subject = note.title) // ← share nothing, then fill it in?
val link = api.createShareLink(note.id)
}
Which is nonsense written down, and I include it because the instinct behind it is the right one: the share must happen during the gesture, so something has to move. What moves is not the share — it is the network call.
The third fix was document.execCommand('copy'), the deprecated path, which has a looser gesture
requirement:
// works more often and is deprecated, synchronous, and silently truncates in Safari
document.execCommand("copy")
It worked in Chrome, failed in Safari above a few kilobytes with no error, and is on a removal track. Trading a known failure for an unknown one.
The actual mechanism
Three platforms impose three different preconditions on the same action, and none of them is expressible in a function that takes a string.
The web requires a live user gesture. A share or clipboard write must happen inside the call
stack of a user event — a click, a keypress — and the browser calls this "transient activation". It
expires: an await of a network call ends it, because the stack that began at the click has
unwound. So the requirement is not "be called from a handler", it is "be called before anything
suspends".
iOS requires a presenting view controller. UIActivityViewController must be presented by
something in the window hierarchy, and on iPad it also needs a source rect for the popover
anchor — a share sheet with no anchor is an exception, not a default position.
Desktop requires a parent window for the native dialog, and has no gesture requirement at all.
Android requires a context and has neither of the others' constraints.
So the four actuals need four different things from their callers, and shareText(text, subject)
asks for none of them. Which means every requirement is satisfied by accident when it is satisfied
at all — the iOS actual reaches for a view controller from a singleton, and the web actual assumes
a gesture it has no way to check.
And the gesture requirement has a property the others do not: it is time-dependent. A view controller is either available or not, and the code can check. Transient activation was available and is now gone, and there is no API to ask whether you still have it. That is why the web failure is the one that survived three weeks — nothing could have detected it except trying.

The fix
Put the precondition in the signature. The web's gesture requirement becomes a scope the caller must be inside, and the network call moves out of it.
// commonMain/kotlin/share/Sharing.kt
//
// The payload is prepared BEFORE the gesture scope and the platform action happens INSIDE it.
// That ordering is the whole fix, and making it a scope rather than a convention means a
// caller that suspends in the wrong place does not compile.
class SharePayload(val text: String, val subject: String?)
/**
* Everything a platform might need from the caller, declared. Android needs none of it; iOS
* needs the anchor on iPad; desktop needs the window. A platform that does not need a field
* ignores it — which is better than a singleton reach, because the caller can SEE what is
* required.
*/
class ShareOrigin(
val anchorBounds: Rect? = null, // iPad popover source rect
)
interface GestureScope {
/** Only callable here. The receiver is proof the caller has not suspended since the tap. */
suspend fun share(payload: SharePayload, origin: ShareOrigin = ShareOrigin())
suspend fun copy(text: String): Boolean
}
expect class Sharing {
/**
* Runs [block] with the platform's activation still live. The block must not suspend on
* anything slow — on the web, an await inside it ends the transient activation and the
* action silently fails, which is the defect this exists to prevent.
*/
suspend fun <T> withinGesture(block: suspend GestureScope.() -> T): T
}
// wasmJsMain — the actual that can finally report the truth
actual class Sharing {
actual suspend fun <T> withinGesture(block: suspend GestureScope.() -> T): T =
block(object : GestureScope {
override suspend fun share(payload: SharePayload, origin: ShareOrigin) {
// navigator.share is absent in several browsers, which is a CAPABILITY
// question and distinct from the gesture one. Conflating them was how the
// original reported nothing: an absent API and a lost gesture both did nothing.
val nav = window.navigator
val data = ShareData(text = payload.text, title = payload.subject)
// canShare must be given the DATA. Called bare, `canShare()` asks whether an
// empty payload is shareable — it is not — so it answers false on browsers
// that share fine. That version rejected every share as unsupported.
if (!nav.canShare(data)) throw ShareUnsupported("navigator.share is unavailable")
// AWAITED. The promise's rejection is the only signal that the gesture was
// lost, and discarding it is what let a toast fire over a failed write.
nav.share(data).await()
}
override suspend fun copy(text: String): Boolean = try {
window.navigator.clipboard.writeText(text).await()
true
} catch (e: Throwable) {
// Returns false rather than throwing, because a failed copy is a normal
// outcome on the web and the caller's correct response is to show a
// selectable fallback rather than an error.
logger.warn("clipboard write refused: ${e.message}")
false
}
})
}
And the call site, with the network call outside the gesture:
// commonMain
fun onShareTapped(note: Note) = scope.launch {
// OUTSIDE: the slow part, before the gesture scope is entered. On the web this is what
// was consuming the activation; everywhere else it is just better ordering.
val link = api.createShareLink(note.id)
sharing.withinGesture {
share(SharePayload(link.url, subject = note.title))
}
}
fun onCopyTapped(note: Note) = scope.launch {
val link = api.createShareLink(note.id)
val copied = sharing.withinGesture { copy(link.url) }
// The toast is now a function of the RESULT. The original emitted unconditionally
// because the actual could not fail.
_toast.emit(if (copied) "Link copied" else "Copy blocked — select the link to copy it")
if (!copied) _showSelectableLink.emit(link.url)
}
And the output it produced, the same three-week window after shipping:
$ ./tools/share-report.sh --days 21 --platform web
copy taps 2,980
clipboard writes that succeeded 2,901 (97.3%)
"Link copied" toasts shown 2,901
"Copy blocked" + selectable link 79 (2.7% — Safari private browsing, mostly)
share taps 1,204
shares completed 1,131
ShareUnsupported fallbacks 73 (Firefox desktop: no navigator.share)
Zero false toasts, and the 2.7% that genuinely cannot copy now get a link they can select.
How we proved it
That the network call was consuming the gesture — the measurement, because the fix looks like unrelated reordering:
share() called … succeeded
directly in the click handler 1,000 / 1,000
after `delay(0)` 998 / 1,000
after `delay(1.milliseconds)` 0 / 1,000
after the real api.createShareLink (p50 180 ms) 0 / 1,000
delay(0) survives and delay(1ms) does not. The activation does not expire on a timer — it ends
when the task that began at the click completes, and a real suspension ends that task.
That the unawaited promise was what made it a lie. The two halves separated, because awaiting alone was the fix I shipped first:
write succeeded toast shown user misled
unawaited, exception discarded 0 3,114 3,114
awaited, exception propagates 0 0 0 ← honest, broken
awaited + payload before the gesture 2,901 2,901 0
Row two is the important one: it fixed the lie and not the failure, and shipping only that would have replaced a false success with silence.
That an absent API and a lost gesture needed separating. Both produced "nothing happened", and the counts are very different:
Firefox desktop navigator.share absent 73 / 1,204 → ShareUnsupported, fallback shown
Chrome, suspended gesture all before → NotAllowedError
Conflating them meant the Firefox users and the Chrome users got the same non-behaviour for different reasons, and only one of those is fixable by reordering.
That GestureScope prevents the mistake rather than documenting it. A fixture that suspends
inside the scope — which is the thing a future author will do:
sharing.withinGesture {
val link = api.createShareLink(id) // suspends INSIDE the scope
share(SharePayload(link.url, null))
}
That compiles, and it is caught by a lint rule rather than the type system, which is an honest limitation of the design:
$ ./gradlew :feature:share:detekt
SuspendingCallInGestureScope: feature/share/.../ShareViewModel.kt:34
`api.createShareLink` suspends inside withinGesture, which ends transient activation on
the web. Move it above the withinGesture call.
A receiver cannot forbid suspension in Kotlin, so the scope makes the requirement visible and a detekt rule makes it checked. I would prefer the compiler and this is what is available.
That the iPad anchor is a real requirement and not a nicety. The fixture is a share on an iPad
with no anchorBounds:
$ xcodebuild test -scheme iosApp -destination 'platform=iOS Simulator,name=iPad Pro'
with anchorBounds popover presented at the button
without NSInvalidArgumentException: UIPopoverPresentationController should
have a non-nil sourceView or barButtonItem
A crash on iPad and nothing on iPhone, from the same code — which is why the field is in
ShareOrigin rather than reached from a singleton.
What I cannot tell you is the browser matrix beyond what our users are on. The 2.7% copy failures are mostly Safari private browsing, which blocks clipboard writes under conditions I could not fully characterise; I know the fallback works and I do not know the complete set of states that trigger it.
And an honest gap. withinGesture does not actually verify that a gesture is live, because
there is no API to ask. It is a naming of the requirement plus a lint rule, and a caller who
invokes it from a timer gets the old failure with a better-named function. The only real check is
the awaited promise, which reports after the fact.

What this generalizes to
A function signature that takes only the data is claiming the action has no preconditions, and platform actions usually have several — a presenter, a window, a permission, a live gesture. The ones that can be checked should be parameters, so a caller cannot omit them; the ones that cannot be checked need to be named in the API even if naming is all you can do, because an unnamed precondition is satisfied by accident and its failure has no detectable signature. And a platform call whose result you discard is not a call that cannot fail — it is one whose failure you have chosen not to see.
Related
- The biometric prompt belongs to the OS — the same precondition problem where the host can disappear mid-call.
- There is no shared permission API — four platforms whose prompts have four different lifecycles.
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".