Closes #203. Stacked on #208 (the iOS half); once that merges, this rebases down to its own two commits.
What was wrong
On Android, craft.share(text, title) returned undefined, and craft.share.share(options) wrapped that in a promise that resolved immediately. Whatever happened next, the page heard nothing: the person could share, dismiss the menu, or there could be nothing to share at all.
What it answers now
share returns a promise that:
- resolves
truewhen the person picks an app; - resolves
falsewhen they dismiss the menu; - rejects with "Nothing to share" for an empty string, the same line iOS draws;
- rejects with the exception message when the menu cannot open.
false is not a rejection because dismissing the menu is the person's choice, not an error. iOS already resolves the same completed flag the same way.
How Android can tell
Android's share menu (the chooser) gives two partial signals:
- The IntentSender passed to
Intent.createChooser(intent, title, sender)fires when the person picks an app. It fires for nothing else. - The activity result arrives both when the person picks and when they dismiss, and it is
RESULT_CANCELEDin both cases.
Neither answers the question alone. So the IntentSender's broadcast sets a flag, and the result, which always arrives, settles the promise with that flag. Everything that touches the flag runs on the main thread: the launch is posted there, and broadcasts and activity results are delivered there.
Details:
- The IntentSender is an explicit, immutable broadcast
PendingIntent. It is explicit so it reaches this app only, and immutable because only the fact that it fired matters, not the component the chooser adds. Its receiver is registered throughContextCompat.registerReceiver(..., RECEIVER_NOT_EXPORTED)and unregistered inclose(). - The request code is
REQUEST_SHARE = 1011. It is routed inonActivityResult, including the closed-bridge path. - Zig launches the same chooser for the same result.
nativeSharenow takes the sender and the request code from Kotlin and callsstartActivityForResult. Kotlin chooses the code becauseCraftBridgeis what receives the result; a copy of the constant in Zig would be a second place for it to drift.
craft.d.ts and the SDK's share.share now type the answer as Promise<boolean>. The Web Share API fallback also answers false for AbortError rather than throwing.
The first real cancellation path in the E2E suite
The first E2E PR left out a true cancellation case. The realistic candidate, the camera, depends on a camera app being installed and on keyevent timing. The share menu has neither problem: the system always provides one. The Android suite gains:
share.empty.rejects:share('')must reject with "Nothing to share".share.dismissed.resolvesFalse: the page announces that it is about to open the menu, then callsshare. The harness waits untildumpsys windowshowsChooserActivityholding input focus, savesshare-menu.pngand presses Back. The promise must then resolvefalse.
false is also what a share would answer if no menu ever opened. So the harness separately fails the run when the page asked for a dismissal and no menu came to the front, which the page cannot see from where it sits. The harness presses Back only once it has seen the menu; pressing on a timer could close the app instead, and the page would never report.
shareMenuInFront requires input focus, not just a resumed activity, because Back goes to whichever window holds focus. The first CI run showed the gap: the activity manager marked the chooser resumed at 50.37s, and its window took focus at 52.63s. Back pressed in that gap would have closed the app, and that run passed only because the harness happened to poll late.
It matches both chooser locations: android/com.android.internal.app.ChooserActivity up to Android 13 and com.android.intentresolver/com.android.intentresolver.ChooserActivity from Android 14. Unit tests cover both spellings and a chooser that is resumed but not yet focused.
On the runtime leg, Zig must launch the chooser without falling back to Kotlin. The leg already fails on any decline, so a descriptor mismatch or a JNI failure in the new nativeShare would turn it red.
Not done here
Android does not gate share on enableShare. It turns out Android gates almost nothing on its enable* flags, apart from push, which gets a generated rejection. That is a wider question than this issue and is filed as #209.
Verification
- Locally:
bun run test,bun run typecheck,bun run verify:packages, Pickier, 37 protocol tests, and thetest:androidandtest:iosgates, including new Zig tests. Those tests check that the chooser is launched for a result with the sender and code it was given, and that a launch failure is reported rather than swallowed. - Only in this PR's CI: the Kotlin compile and the emulator run. There is no Android toolchain on the machine this was written on.