Engineering

Copy and share: two features that are entirely platform code

A gesture requirement, a presenting view controller, and neither on desktop.

MobileByteSensei 11 min read
kmpplatformarchitecture

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 one await after the tap.
  • copy(text) fared worse: it threw on the web for the same reason, and the try/catch around 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.

Three weeks of web telemetry showing three thousand one hundred and fourteen copy taps, zero successful clipboard writes and three thousand one hundred and fourteen success toasts, because the actual discarded the promise it should have awaited

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 precondition each platform imposes on the same action, with a presenting controller and a parent window both checkable and the browser gesture requirement being time-dependent with no API to query it

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.

Four timings showing that a zero-delay suspension survives the gesture and a one-millisecond one does not, so the activation ends when the task that began at the click completes rather than on a timer

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.


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".

Follow by RSS