ReviewOS

also looking at this

craft-native/craft

fix(android): resolve share() with whether the person picked an app

#210
Merged glennmichael123 wants to merge fix/android-share-outcome into main
12 files +399 -56
.github/workflows/README.mdmodified+8-2
Changes to .github/workflows/README.md
@@ -49,10 +49,16 @@ bun run test:mobile-e2e:android
4949```
5050
5151```
52ok android-shim — 4/4 cases, no zig
53ok android-runtime — 4/4 cases, zig served registered:103, declines:0
52ok android-shim — 6/6 cases, no zig
53ok android-runtime — 6/6 cases, zig served registered:103, declines:0
5454```
5555
56One Android case needs a person: `share.dismissed.resolvesFalse` opens the
57real share menu and expects `false` once it is dismissed. The harness plays
58that part. It waits until `dumpsys window` shows the chooser holding input
59focus, saves `share-menu.png`, presses Back, and fails the run if the page asked
60for a dismissal and no menu ever appeared.
61
5662The runtime leg fails on any line where a Zig native gave up — "fell through
5763to the shim", "failed with no fallback" or "could not reach the page" — because
5864Kotlin answering in its place is invisible to the page, and a runtime that has
packages/android/src/index.test.tsmodified+5-4
Changes to packages/android/src/index.test.ts
@@ -567,6 +567,7 @@ describe('Craft Android builder', () => {
567567 ['OTA download', '_craftOTADownloadResolve', 1],
568568 ['OTA apply', '_craftOTAApplyResolve', 1],
569569 ['OTA rollback', '_craftOTARollbackResolve', 1],
570 ['share', '_craftShareResolve', 1],
570571 ] as const) {
571572 const call = `window.__craftPromise('${channel}', '${resolver}',`
572573 expect(bridge.match(new RegExp(call.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'))?.length).toBe(uses)
@@ -581,12 +582,12 @@ describe('Craft Android builder', () => {
581582 const registrations = [...bridge.matchAll(
582583 /window\.__craftPromise\(\s*'([^']+)'\s*,\s*'([^']+)'\s*,\s*'([^']+)'/g,
583584 )].map(match => [match[1], match[2], match[3]] as const)
584 expect(registrations).toHaveLength(65)
585 expect(registrations).toHaveLength(66)
585586
586587 const uniqueRegistrations = new Map(
587588 registrations.map(registration => [registration.join('\0'), registration]),
588589 )
589 expect(uniqueRegistrations.size).toBe(46)
590 expect(uniqueRegistrations.size).toBe(47)
590591
591592 const channels = new Set<string>()
592593 const callbackOwners = new Map<string, string>()
@@ -599,8 +600,8 @@ describe('Craft Android builder', () => {
599600 callbackOwners.set(callback, channel)
600601 }
601602 }
602 expect(channels.size).toBe(46)
603 expect(callbackOwners.size).toBe(92)
603 expect(channels.size).toBe(47)
604 expect(callbackOwners.size).toBe(94)
604605 expect(bridge.match(/webView\.evaluateJavascript\(\s*"window\._craft[A-Za-z]+(?:Resolve|Reject)/g)).toBeNull()
605606 expect(bridge.match(/window\._craft[A-Za-z]+(?:Resolve|Reject) = (?:resolve|reject)/g)).toBeNull()
606607 expect(bridge).toContain('_craftShortcutsResolve({set: true, count: ${shortcuts.size}})')
packages/android/templates/CraftBridge.kt.templatemodified+105-10
Changes to packages/android/templates/CraftBridge.kt.template
@@ -2,10 +2,13 @@ package
22
33import android.Manifest
44import android.app.Activity
5import android.content.BroadcastReceiver
56import android.content.ClipData
67import android.content.ClipboardManager
78import android.content.Context
89import android.content.Intent
10import android.content.IntentFilter
11import android.content.IntentSender
912import android.content.pm.PackageManager
1013import android.graphics.Bitmap
1114import android.hardware.camera2.CameraAccessException
@@ -365,9 +368,14 @@ class CraftBridge(
365368 CraftAndroid.stopListening();
366369 },
367370
368 // Share
371 // Share. Resolves true when the person picks an app, false
372 // when they dismiss the menu, and rejects when there is
373 // nothing to share or the menu cannot open. No timeout: the
374 // menu waits on a person, not on the device.
369375 share: function(text, title) {
370 CraftAndroid.share(text, title || '');
376 return window.__craftPromise('share', '_craftShareResolve', '_craftShareReject', function() {
377 CraftAndroid.share(text == null ? '' : String(text), title == null ? '' : String(title));
378 });
371379 },
372380
373381 // Camera
@@ -1610,18 +1618,95 @@ class CraftBridge(
16101618
16111619 // ==================== Share ====================
16121620
1621 // Whether the person picked an app from the share menu that is open now.
1622 //
1623 // The chooser says so through the IntentSender below and says nothing at
1624 // all when it is dismissed, while its activity result comes back
1625 // RESULT_CANCELED either way. Neither signal answers on its own, so the
1626 // pick marks this and the result, which always arrives, settles the page's
1627 // promise with it. Everything that touches it runs on the main thread: the
1628 // launch is posted there, and receivers and results are delivered there.
1629 private var shareChosen = false
1630 private var shareReceiverRegistered = false
1631 private val shareChosenAction by lazy { "${activity.packageName}.CRAFT_SHARE_CHOSEN" }
1632 private val shareChosenReceiver = object : BroadcastReceiver() {
1633 override fun onReceive(context: Context, intent: Intent) {
1634 shareChosen = true
1635 }
1636 }
1637
16131638 @JavascriptInterface
16141639 fun share(text: String, title: String) {
1615 if (CraftNative.share(activity, text, title)) return
1640 // The same line iOS draws: an empty string is nothing to share, and a
1641 // chooser opened over nothing would offer apps a blank message.
1642 if (text.isEmpty()) {
1643 rejectShare("Nothing to share")
1644 return
1645 }
1646
1647 activity.runOnUiThread {
1648 if (closed) return@runOnUiThread
1649 shareChosen = false
1650
1651 val chosen = try {
1652 shareChosenSender()
1653 } catch (error: Exception) {
1654 rejectShare(error.message ?: "Unable to open the share menu")
1655 return@runOnUiThread
1656 }
1657
1658 // Zig launches the same chooser for the same result; the answer
1659 // still comes back through onActivityResult below.
1660 if (CraftNative.share(activity, text, title, chosen, REQUEST_SHARE)) return@runOnUiThread
16161661
1617 val intent = Intent(Intent.ACTION_SEND).apply {
1618 type = "text/plain"
1619 putExtra(Intent.EXTRA_TEXT, text)
1620 if (title.isNotEmpty()) {
1621 putExtra(Intent.EXTRA_SUBJECT, title)
1662 val intent = Intent(Intent.ACTION_SEND).apply {
1663 type = "text/plain"
1664 putExtra(Intent.EXTRA_TEXT, text)
1665 if (title.isNotEmpty()) {
1666 putExtra(Intent.EXTRA_SUBJECT, title)
1667 }
1668 }
1669 try {
1670 activity.startActivityForResult(Intent.createChooser(intent, "Share", chosen), REQUEST_SHARE)
1671 } catch (error: Exception) {
1672 rejectShare(error.message ?: "Unable to open the share menu")
16221673 }
16231674 }
1624 activity.startActivity(Intent.createChooser(intent, "Share"))
1675 }
1676
1677 // The IntentSender the chooser fires on a pick.
1678 //
1679 // Immutable: the chooser adds the chosen component to the intent it
1680 // fires, and this only needs to know that it fired. Explicit, by package,
1681 // so the broadcast reaches this app and nothing else, and so the
1682 // not-exported receiver accepts it.
1683 private fun shareChosenSender(): IntentSender {
1684 if (!shareReceiverRegistered) {
1685 ContextCompat.registerReceiver(
1686 activity,
1687 shareChosenReceiver,
1688 IntentFilter(shareChosenAction),
1689 ContextCompat.RECEIVER_NOT_EXPORTED
1690 )
1691 shareReceiverRegistered = true
1692 }
1693 val pick = Intent(shareChosenAction).setPackage(activity.packageName)
1694 return PendingIntent.getBroadcast(
1695 activity,
1696 REQUEST_SHARE,
1697 pick,
1698 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
1699 ).intentSender
1700 }
1701
1702 private fun handleShareResult() {
1703 val chosen = shareChosen
1704 shareChosen = false
1705 evaluatePromiseJavascript("window._craftShareResolve && window._craftShareResolve($chosen)")
1706 }
1707
1708 private fun rejectShare(message: String) {
1709 evaluatePromiseJavascript("window._craftShareReject && window._craftShareReject(${jsQuote(message)})")
16251710 }
16261711
16271712 // ==================== Camera & Gallery ====================
@@ -3698,7 +3783,7 @@ class CraftBridge(
36983783 fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
36993784 if (closed) return requestCode == REQUEST_CAMERA || requestCode == REQUEST_GALLERY ||
37003785 requestCode == REQUEST_FILE_PICKER || requestCode == REQUEST_VIDEO ||
3701 requestCode == REQUEST_PICK_CONTACT
3786 requestCode == REQUEST_PICK_CONTACT || requestCode == REQUEST_SHARE
37023787
37033788 return when (requestCode) {
37043789 REQUEST_CAMERA, REQUEST_GALLERY -> {
@@ -3717,6 +3802,10 @@ class CraftBridge(
37173802 handleContactPickerResult(resultCode, data)
37183803 true
37193804 }
3805 REQUEST_SHARE -> {
3806 handleShareResult()
3807 true
3808 }
37203809 else -> healthConnect.onActivityResult(requestCode, resultCode, data)
37213810 }
37223811 }
@@ -3736,6 +3825,11 @@ class CraftBridge(
37363825 CraftNative.close(activity)
37373826 healthConnect.close()
37383827
3828 if (shareReceiverRegistered) {
3829 runCatching { activity.unregisterReceiver(shareChosenReceiver) }
3830 shareReceiverRegistered = false
3831 }
3832
37393833 runCatching { speechRecognizer?.cancel() }
37403834 runCatching { speechRecognizer?.destroy() }
37413835 speechRecognizer = null
@@ -4670,6 +4764,7 @@ class CraftBridge(
46704764 const val REQUEST_VIDEO = 1008
46714765 const val REQUEST_BLUETOOTH = 1009
46724766 const val REQUEST_PICK_CONTACT = 1010
4767 const val REQUEST_SHARE = 1011
46734768 private const val PERMISSION_REQUEST_START = 2000
46744769 private const val PERMISSION_REQUEST_END = 2999
46754770 private const val LOCATION_REQUEST_TIMEOUT_MS = 15_000L
packages/android/templates/CraftNative.kt.templatemodified+9-4
Changes to packages/android/templates/CraftNative.kt.template
@@ -1,6 +1,7 @@
11package com.craft.runtime
22
33import android.app.Activity
4import android.content.IntentSender
45import com.google.android.gms.location.Priority
56import com.google.android.gms.location.LocationServices
67import com.google.android.gms.location.LocationResult
@@ -227,7 +228,7 @@ object CraftNative {
227228 private external fun nativeClipboardRead(activity: Activity): String?
228229 private external fun nativeClipboardWrite(activity: Activity, text: String): Boolean
229230 private external fun nativeOpenUrl(activity: Activity, url: String): Boolean
230 private external fun nativeShare(activity: Activity, text: String, title: String): Boolean
231 private external fun nativeShare(activity: Activity, text: String, title: String, chosen: IntentSender, requestCode: Int): Boolean
231232 private external fun nativeOpenCamera(activity: Activity): Boolean
232233 private external fun nativePickImage(activity: Activity): Boolean
233234 private external fun nativePickFile(activity: Activity): Boolean
@@ -425,11 +426,15 @@ object CraftNative {
425426 }
426427 }
427428
428 /** Returns whether Zig launched the share sheet; false falls through. */
429 fun share(activity: Activity, text: String, title: String): Boolean {
429 /**
430 * Returns whether Zig launched the share menu for a result; false falls
431 * through. `chosen` fires on a pick and `requestCode` routes the result
432 * back to CraftBridge, which settles the page's promise from the two.
433 */
434 fun share(activity: Activity, text: String, title: String, chosen: IntentSender, requestCode: Int): Boolean {
430435 if (!isAvailable) return false
431436 return try {
432 nativeShare(activity, text, title)
437 nativeShare(activity, text, title, chosen, requestCode)
433438 } catch (e: UnsatisfiedLinkError) {
434439 false
435440 }
packages/typescript/src/api/mobile.tsmodified+20-7
Changes to packages/typescript/src/api/mobile.ts
@@ -1050,20 +1050,33 @@ export const share = {
10501050 /**
10511051 * Open native share dialog.
10521052 *
1053 * Resolves `true` when the person shared and `false` when they dismissed the
1054 * dialog, on the native bridges and in the browser alike.
1055 *
10531056 * @param options - Share options
10541057 */
1055 async share(options: ShareOptions): Promise<void> {
1058 async share(options: ShareOptions): Promise<boolean> {
10561059 const craft = getCraftMobile()
10571060 if (typeof window !== 'undefined' && craft?.share) {
10581061 return craft.share.share(options)
10591062 }
10601063 // Web Share API fallback
10611064 if (navigator.share) {
1062 await navigator.share({
1063 title: options.title,
1064 text: options.text,
1065 url: options.url
1066 })
1065 try {
1066 await navigator.share({
1067 title: options.title,
1068 text: options.text,
1069 url: options.url
1070 })
1071 return true
1072 }
1073 catch (error) {
1074 // A dismissed dialog rejects with AbortError here. The native bridges
1075 // answer false for the same thing, so this does too rather than
1076 // turning a person's choice into an exception.
1077 if (error instanceof DOMException && error.name === 'AbortError') return false
1078 throw error
1079 }
10671080 }
10681081else {
10691082 throw new Error('Share API not available')
@@ -1580,7 +1593,7 @@ interface CraftMobileBridge {
15801593 readRecording?(): Promise<Location[]>
15811594 }
15821595 share?: {
1583 share(options: ShareOptions): Promise<void>
1596 share(options: ShareOptions): Promise<boolean>
15841597 }
15851598 lifecycle?: {
15861599 getState(): AppState
packages/typescript/types/craft.d.tsmodified+7-2
Changes to packages/typescript/types/craft.d.ts
@@ -118,11 +118,16 @@ export interface CraftBridge {
118118 haptic(style: HapticStyle): void;
119119
120120 /**
121 * Open native share sheet
121 * Open the native share sheet.
122 *
123 * Resolves `true` when the person shares — finishes an activity on iOS,
124 * picks an app on Android — and `false` when they dismiss the sheet.
125 * Rejects when there is nothing to share, when the sheet cannot open, and on
126 * iOS when `enableShare` is off (`CAPABILITY_DISABLED`).
122127 * @param text - Text to share
123128 * @param title - Optional title
124129 */
125 share(text: string, title?: string): void;
130 share(text: string, title?: string): Promise<boolean>;
126131
127132 /**
128133 * Log message to native console (Xcode/Logcat)
packages/zig/src/android_dispatch.zigmodified+4-2
Changes to packages/zig/src/android_dispatch.zig
@@ -283,7 +283,7 @@ const natives = [_]jni.JNINativeMethod{
283283 },
284284 .{
285285 .name = "nativeShare",
286 .signature = "(Landroid/app/Activity;Ljava/lang/String;Ljava/lang/String;)Z",
286 .signature = "(Landroid/app/Activity;Ljava/lang/String;Ljava/lang/String;Landroid/content/IntentSender;I)Z",
287287 .fnPtr = @ptrCast(&nativeShare),
288288 },
289289 .{
@@ -879,6 +879,8 @@ fn nativeShare(
879879 activity: jni.jobject,
880880 text: jni.jstring,
881881 title: jni.jstring,
882 chosen: jni.jobject,
883 request_code: jni.jint,
882884) callconv(.c) jni.jboolean {
883885 const j = Jni.init(env);
884886 var arena = std.heap.ArenaAllocator.init(backing);
@@ -888,7 +890,7 @@ fn nativeShare(
888890 const body = j.stringToUtf8(allocator, text) catch |err| return fellThrough("share", err, jni.JNI_FALSE);
889891 const subject = j.stringToUtf8(allocator, title) catch |err| return fellThrough("share", err, jni.JNI_FALSE);
890892
891 intents.share(j, allocator, activity, body, subject) catch |err| {
893 intents.share(j, allocator, activity, body, subject, chosen, request_code) catch |err| {
892894 std.log.warn("craft: share fell through to the shim ({s})", .{@errorName(err)});
893895 return jni.JNI_FALSE;
894896 };
packages/zig/src/bridge_android_intents.zigmodified+84-20
Changes to packages/zig/src/bridge_android_intents.zig
@@ -5,18 +5,23 @@
55//! module — they share every JNI call between them, and the only thing that
66//! differs is which extras go on the intent.
77//!
8//! ## Neither reports whether anything happened
8//! ## What each one can find out
99//!
1010//! `startActivity` throws `ActivityNotFoundException` when nothing on the
1111//! device handles the intent, and the Kotlin's `openURL` catches it and answers
12//! `false`. That is the *only* failure either action can see. Once another app
13//! is launched, whether the user did anything with it is unobservable from
14//! here — `share` in particular returns before the chooser is even drawn.
12//! `false`. That is the only failure `openURL` can see: once another app is
13//! launched, whether the user did anything with it is unobservable from here.
1514//!
16//! So `true` means "the intent was accepted", never "the user shared
17//! something", and a page that treats it as the latter is wrong on both
18//! platforms. Kept as-is rather than improved: the shim's answer is the
19//! contract while the shim still exists.
15//! `share` can find out one thing more, and has to, because the page's promise
16//! resolves with it (#203). The chooser reports a pick through the
17//! `IntentSender` passed to `createChooser`, and reports a dismissal not at
18//! all. Its activity result arrives in both cases, and is `RESULT_CANCELED` in
19//! both. So neither signal answers alone, and the Kotlin combines them: the
20//! sender marks a pick, and the result, which always comes, settles the promise
21//! with whatever was marked. This module launches the chooser with both halves
22//! wired. It does not own either, which is why the sender and the request code
23//! arrive as arguments rather than being built here: `CraftBridge` receives the
24//! result, so `CraftBridge` chooses the code it routes on.
2025//!
2126//! ## `share` has one branch, and it is the empty title
2227//!
@@ -89,11 +94,21 @@ pub fn openUrl(j: Jni, allocator: std.mem.Allocator, activity: jobject, url: []c
8994 try startActivity(j, activity, intent);
9095}
9196
92/// `startActivity(Intent.createChooser(Intent(ACTION_SEND)…, "Share"))`.
97/// `startActivityForResult(Intent.createChooser(Intent(ACTION_SEND)…, "Share", chosen), request_code)`.
9398///
9499/// An empty `title` skips `EXTRA_SUBJECT` entirely rather than setting it to
95/// the empty string — see the module comment.
96pub fn share(j: Jni, allocator: std.mem.Allocator, activity: jobject, text: []const u8, title: []const u8) !void {
100/// the empty string — see the module comment. `chosen` is the `IntentSender`
101/// the chooser fires when the person picks an app, and `request_code` is the
102/// one `CraftBridge.onActivityResult` settles the page's promise on.
103pub fn share(
104 j: Jni,
105 allocator: std.mem.Allocator,
106 activity: jobject,
107 text: []const u8,
108 title: []const u8,
109 chosen: jobject,
110 request_code: i32,
111) !void {
97112 try j.pushLocalFrame(24);
98113 defer _ = j.popLocalFrame(null);
99114
@@ -139,12 +154,20 @@ pub fn share(j: Jni, allocator: std.mem.Allocator, activity: jobject, text: []co
139154 try j.staticMethodId(
140155 intent_cls,
141156 "createChooser",
142 "(Landroid/content/Intent;Ljava/lang/CharSequence;)Landroid/content/Intent;",
157 "(Landroid/content/Intent;Ljava/lang/CharSequence;Landroid/content/IntentSender;)Landroid/content/Intent;",
143158 ),
144 &.{ .{ .l = intent }, .{ .l = chooser_label } },
159 &.{ .{ .l = intent }, .{ .l = chooser_label }, .{ .l = chosen } },
145160 );
146161
147 try startActivity(j, activity, chooser);
162 // For a result, not a plain start: the result is the only signal that
163 // arrives when the person dismisses the chooser, and without it the page's
164 // promise would wait for a pick that is never coming.
165 const activity_cls = try j.objectClass(activity);
166 try j.callVoidMethodA(
167 activity,
168 try j.methodId(activity_cls, "startActivityForResult", "(Landroid/content/Intent;I)V"),
169 &.{ .{ .l = chooser }, .{ .i = request_code } },
170 );
148171}
149172
150173// =============================================================================
@@ -163,6 +186,9 @@ var fake_field_names: std.ArrayListUnmanaged([]const u8) = .empty;
163186var fake_strings: std.ArrayListUnmanaged([]const u8) = .empty;
164187var fake_throw_on_start = false;
165188var fake_pending: jobject = null;
189/// The last arguments `createChooser` and `startActivityForResult` received.
190var fake_chooser_sender: jobject = null;
191var fake_request_code: ?jni.jint = null;
166192
167193fn obj(tag: usize) jobject {
168194 return @ptrCast(&fake_storage[tag]);
@@ -196,8 +222,9 @@ fn fNewStringUTF(_: jni.JNIEnv, text: [*:0]const u8) callconv(.c) jni.jstring {
196222 fake_strings.append(testing.allocator, copy) catch testing.allocator.free(copy);
197223 return obj(3);
198224}
199fn fCallStaticObjectMethodA(_: jni.JNIEnv, _: jni.jclass, id: jni.jmethodID, _: [*]const jni.jvalue) callconv(.c) jobject {
225fn fCallStaticObjectMethodA(_: jni.JNIEnv, _: jni.jclass, id: jni.jmethodID, args: [*]const jni.jvalue) callconv(.c) jobject {
200226 fake_calls.append(testing.allocator, nameOf(id)) catch {};
227 if (std.mem.eql(u8, nameOf(id), "createChooser")) fake_chooser_sender = args[2].l;
201228 return obj(4);
202229}
203230fn fCallObjectMethodA(_: jni.JNIEnv, _: jobject, id: jni.jmethodID, _: [*]const jni.jvalue) callconv(.c) jobject {
@@ -208,8 +235,9 @@ fn fNewObjectA(_: jni.JNIEnv, _: jni.jclass, id: jni.jmethodID, _: [*]const jni.
208235 fake_calls.append(testing.allocator, nameOf(id)) catch {};
209236 return obj(6);
210237}
211fn fCallVoidMethodA(_: jni.JNIEnv, _: jobject, id: jni.jmethodID, _: [*]const jni.jvalue) callconv(.c) void {
238fn fCallVoidMethodA(_: jni.JNIEnv, _: jobject, id: jni.jmethodID, args: [*]const jni.jvalue) callconv(.c) void {
212239 fake_calls.append(testing.allocator, nameOf(id)) catch {};
240 if (std.mem.eql(u8, nameOf(id), "startActivityForResult")) fake_request_code = args[1].i;
213241 if (fake_throw_on_start) fake_pending = obj(7);
214242}
215243fn fExceptionOccurred(_: jni.JNIEnv) callconv(.c) jobject {
@@ -255,6 +283,8 @@ fn resetFakes() void {
255283 fake_strings.clearRetainingCapacity();
256284 fake_throw_on_start = false;
257285 fake_pending = null;
286 fake_chooser_sender = null;
287 fake_request_code = null;
258288}
259289
260290fn freeFakes() void {
@@ -321,7 +351,7 @@ test "share sets the text extra and the chooser" {
321351 fakeEnv(&table);
322352 const ptr: *const jni.JNINativeInterface = &table;
323353
324 try share(Jni.init(&ptr), testing.allocator, obj(8), "hello", "Subject");
354 try share(Jni.init(&ptr), testing.allocator, obj(8), "hello", "Subject", obj(9), 1011);
325355
326356 try testing.expect(sawField("ACTION_SEND"));
327357 try testing.expect(sawField("EXTRA_TEXT"));
@@ -329,11 +359,45 @@ test "share sets the text extra and the chooser" {
329359 try testing.expect(sawCall("setType"));
330360 try testing.expect(sawCall("putExtra"));
331361 try testing.expect(sawCall("createChooser"));
332 try testing.expect(sawCall("startActivity"));
333362 try testing.expect(sawString("text/plain"));
334363 try testing.expect(sawString("Share"));
335364}
336365
366test "share launches for a result, with the sender and code it was given" {
367 // Both halves of how the page learns what happened. A plain startActivity
368 // gets no result, so a dismissed chooser would leave the promise waiting
369 // for ever; a chooser without the sender reports no pick, so every share
370 // would resolve false.
371 resetFakes();
372 defer freeFakes();
373 var table: jni.JNINativeInterface = undefined;
374 fakeEnv(&table);
375 const ptr: *const jni.JNINativeInterface = &table;
376
377 try share(Jni.init(&ptr), testing.allocator, obj(8), "hello", "", obj(9), 1011);
378
379 try testing.expect(sawCall("startActivityForResult"));
380 try testing.expect(!sawCall("startActivity"));
381 try testing.expectEqual(obj(9), fake_chooser_sender);
382 try testing.expectEqual(@as(?jni.jint, 1011), fake_request_code);
383}
384
385test "a chooser that cannot start reports the exception rather than success" {
386 // CraftBridge rejects the page's promise on this; swallowing it would
387 // leave the promise waiting on a result for an activity that never began.
388 resetFakes();
389 defer freeFakes();
390 var table: jni.JNINativeInterface = undefined;
391 fakeEnv(&table);
392 const ptr: *const jni.JNINativeInterface = &table;
393
394 fake_throw_on_start = true;
395 try testing.expectError(
396 jni.JniError.JavaException,
397 share(Jni.init(&ptr), testing.allocator, obj(8), "hello", "", obj(9), 1011),
398 );
399}
400
337401test "an empty title omits the subject rather than setting it empty" {
338402 // The one branch in either action. An empty EXTRA_SUBJECT produces a mail
339403 // draft with a blank subject line rather than none, and some clients then
@@ -344,13 +408,13 @@ test "an empty title omits the subject rather than setting it empty" {
344408 fakeEnv(&table);
345409 const ptr: *const jni.JNINativeInterface = &table;
346410
347 try share(Jni.init(&ptr), testing.allocator, obj(8), "hello", "");
411 try share(Jni.init(&ptr), testing.allocator, obj(8), "hello", "", obj(9), 1011);
348412
349413 try testing.expect(sawField("EXTRA_TEXT"));
350414 try testing.expect(!sawField("EXTRA_SUBJECT"));
351415 // And the rest of the intent is still built.
352416 try testing.expect(sawCall("createChooser"));
353 try testing.expect(sawCall("startActivity"));
417 try testing.expect(sawCall("startActivityForResult"));
354418}
355419
356420test "the action names match the Kotlin methods exactly" {
scripts/mobile-e2e/android.tsmodified+30-1
Changes to scripts/mobile-e2e/android.ts
@@ -2,7 +2,7 @@ import type { LegOutcome, RunnerOptions } from './types'
22import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
33import { join } from 'node:path'
44import { init } from '../../packages/android/src/index'
5import { androidDeclines, evaluateRun, hasTerminated } from './protocol'
5import { androidDeclines, awaitedNeeds, DISMISS_SHARE_MENU, evaluateRun, hasTerminated, shareMenuInFront } from './protocol'
66import { command, driverPage, waitForFile } from './support'
77
88/**
@@ -149,6 +149,24 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> {
149149 await adb(['shell', 'am', 'force-stop', PACKAGE], { serial, allowFailure: true })
150150 await adb(['shell', 'am', 'start', '-n', `${PACKAGE}/.MainActivity`], { serial, logPath: join(evidence, 'adb.log') })
151151
152 // The share case needs its menu dismissed, and nothing on the device will
153 // do that: the harness plays the person. It acts only once the page has
154 // asked and the menu is really in front, and it keeps a screenshot of the
155 // menu, which is the evidence that there was one to dismiss.
156 let shareMenu: 'not asked' | 'asked' | 'dismissed' = 'not asked'
157
158 async function dismissShareMenuWhenAsked(log: string): Promise<void> {
159 if (shareMenu === 'dismissed' || !awaitedNeeds(log).includes(DISMISS_SHARE_MENU)) return
160 shareMenu = 'asked'
161
162 const windows = await command(['adb', '-s', serial, 'shell', 'dumpsys', 'window'], { allowFailure: true })
163 if (!shareMenuInFront(windows.stdout)) return
164
165 await command(['adb', '-s', serial, 'exec-out', 'screencap', '-p'], { allowFailure: true, outPath: join(evidence, 'share-menu.png') })
166 await adb(['shell', 'input', 'keyevent', 'KEYCODE_BACK'], { serial, logPath: join(evidence, 'adb.log') })
167 shareMenu = 'dismissed'
168 }
169
152170 // logcat -d dumps and exits, so poll it into the evidence file rather than
153171 // streaming: a stream would have to be killed at exactly the right moment,
154172 // and the dump is cheap.
@@ -156,6 +174,7 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> {
156174 const finished = await waitForFile(logPath, options.timeoutMs, hasTerminated, async () => {
157175 const dumped = await command(['adb', '-s', serial, 'logcat', '-d', ...LOGCAT_FILTER], { allowFailure: true })
158176 writeFileSync(logPath, dumped.stdout)
177 await dismissShareMenuWhenAsked(dumped.stdout)
159178 })
160179
161180 await command(['adb', '-s', serial, 'exec-out', 'screencap', '-p'], { allowFailure: true, outPath: join(evidence, 'screen.png') })
@@ -170,6 +189,16 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> {
170189 const verdict = evaluateRun('android', logText, nonce)
171190 failures.push(...verdict.failures)
172191
192 // The page's case only sees that share resolved false. That is also what a
193 // share that never opened a menu at all would resolve, so the harness says
194 // whether there was a menu to dismiss.
195 if (verdict.planned.includes('share.dismissed.resolvesFalse')) {
196 if (shareMenu === 'not asked')
197 failures.push('the page never asked for the share menu to be dismissed, so the dismissal case did not reach the menu')
198 else if (shareMenu === 'asked')
199 failures.push('the page asked for the share menu to be dismissed, and no share menu ever came to the front')
200 }
201
173202 // The page reported the SDK level the bridge told it; the host asked the
174203 // device directly. Agreement means the value crossed the bridge rather than
175204 // being invented in JavaScript - this leg's substitute for the pasteboard
scripts/mobile-e2e/driver.htmlmodified+39-2
Changes to scripts/mobile-e2e/driver.html
@@ -64,14 +64,14 @@
6464 return { code: String(error.code || ''), message: String(error.message || error) }
6565 }
6666
67 function withTimeout(run) {
67 function withTimeout(run, timeoutMs) {
6868 return new Promise(function (resolve) {
6969 var settled = false
7070 var timer = setTimeout(function () {
7171 if (settled) return
7272 settled = true
7373 resolve({ state: 'timeout' })
74 }, CASE_TIMEOUT_MS)
74 }, timeoutMs || CASE_TIMEOUT_MS)
7575
7676 function finish(value) {
7777 if (settled) return
@@ -198,6 +198,43 @@
198198 })
199199 }
200200 },
201 {
202 // Android does not gate sharing on a flag, so its refusal is the one
203 // both platforms share: an empty string is nothing to share. It is
204 // refused before any menu opens, and used to be refused by nobody -
205 // the call returned undefined whatever happened (#203).
206 name: 'share.empty.rejects',
207 platforms: ['android'],
208 run: function () {
209 return withTimeout(function () { return window.craft.share('') }).then(function (outcome) {
210 if (outcome.state === 'timeout') return 'share never settled'
211 if (outcome.state === 'resolved') return 'share resolved with ' + JSON.stringify(outcome.value) + ' for an empty string'
212 if (outcome.error.message.indexOf('Nothing to share') === -1)
213 return 'message is ' + JSON.stringify(outcome.error.message)
214 })
215 }
216 },
217 {
218 // The cancellation path, and the one case that needs a person. The
219 // page announces that it is about to open the share menu, the harness
220 // waits until the menu is really in front, screenshots it and presses
221 // Back, and the promise has to resolve false. False, not a rejection:
222 // dismissing the menu is a choice the person made, not an error.
223 //
224 // A share that resolved false on its own would pass this without a
225 // menu ever appearing, which is why the harness also fails the run
226 // when it was asked to dismiss a menu and never saw one.
227 name: 'share.dismissed.resolvesFalse',
228 platforms: ['android'],
229 run: function () {
230 emit({ event: 'awaiting', name: 'share.dismissed.resolvesFalse', need: 'dismiss-share-menu' })
231 return withTimeout(function () { return window.craft.share('craft e2e ' + NONCE, 'Craft E2E') }, 60000).then(function (outcome) {
232 if (outcome.state === 'timeout') return 'share never settled after the menu was dismissed'
233 if (outcome.state === 'rejected') return 'share rejected: ' + outcome.error.message
234 if (outcome.value !== false) return 'share resolved with ' + JSON.stringify(outcome.value) + ', expected false for a dismissed menu'
235 })
236 }
237 },
201238 {
202239 // Android's equivalent gate. With push disabled the generator emits a
203240 // literal reject call rather than a Firebase block, so the rejection
scripts/mobile-e2e/protocol.test.tsmodified+37-1
Changes to scripts/mobile-e2e/protocol.test.ts
@@ -1,7 +1,7 @@
11import { describe, expect, it } from 'bun:test'
22import { readFileSync } from 'node:fs'
33import { join } from 'node:path'
4import { ANDROID_DECLINE_PHRASES, androidDeclines, evaluateRun, hasTerminated, parseDriverOutput, REQUIRED_CASES, requiredCaseProblems, ZIG_REFUSED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigRefusals } from './protocol'
4import { ANDROID_DECLINE_PHRASES, androidDeclines, awaitedNeeds, DISMISS_SHARE_MENU, evaluateRun, hasTerminated, parseDriverOutput, REQUIRED_CASES, requiredCaseProblems, shareMenuInFront, ZIG_REFUSED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigRefusals } from './protocol'
55
66const ESC = String.fromCharCode(27)
77
@@ -283,6 +283,42 @@ describe('zig attribution', () => {
283283 })
284284})
285285
286describe('share menu', () => {
287 it('reads what the page is waiting for, once, from either log channel', () => {
288 const awaiting = JSON.stringify({ event: 'awaiting', name: 'share.dismissed.resolvesFalse', need: DISMISS_SHARE_MENU })
289 const text = [
290 `09-16 11:29:25.000 2611 2611 I CraftBridge: CRAFT-E2E ${awaiting}`,
291 `09-16 11:29:25.001 2611 2611 I chromium: [INFO:CONSOLE(1)] "CRAFT-E2E ${awaiting}", source: https://appassets.androidplatform.net/ (1)`,
292 ].join('\n')
293
294 expect(awaitedNeeds(text)).toEqual([DISMISS_SHARE_MENU])
295 expect(awaitedNeeds('CRAFT-E2E {"event":"case","name":"a","status":"pass"}')).toEqual([])
296 })
297
298 // Both spellings the chooser has had, as `dumpsys window` prints them.
299 it('sees the menu holding focus on Android 14 and on 13', () => {
300 expect(shareMenuInFront([
301 ' mCurrentFocus=Window{56c630c u0 com.android.intentresolver/com.android.intentresolver.ChooserActivity}',
302 ' mFocusedApp=ActivityRecord{144007559 u0 com.android.intentresolver/.ChooserActivity t7}',
303 ].join('\n'))).toBe(true)
304
305 expect(shareMenuInFront(
306 ' mCurrentFocus=Window{2b7a1f u0 android/com.android.internal.app.ChooserActivity}',
307 )).toBe(true)
308 })
309
310 it('does not see a menu that is resumed but not yet focused', () => {
311 // The gap the first CI run fell into. The activity manager already calls
312 // the chooser the focused app while the probe's window still has input
313 // focus, so Back here would close the app rather than the menu.
314 const dumpsys = [
315 ' mCurrentFocus=Window{77aa u0 dev.craft.e2e.probe/dev.craft.e2e.probe.MainActivity}',
316 ' mFocusedApp=ActivityRecord{144007559 u0 com.android.intentresolver/.ChooserActivity t7}',
317 ].join('\n')
318 expect(shareMenuInFront(dumpsys)).toBe(false)
319 })
320})
321
286322describe('android declines', () => {
287323 // The lines the first Android runtime run actually produced, plus the two
288324 // decline kinds it did not hit, so all three are read the way they are
scripts/mobile-e2e/protocol.tsmodified+51-1
Changes to scripts/mobile-e2e/protocol.ts
@@ -43,7 +43,19 @@ export interface ObservedEvent {
4343 value: unknown
4444}
4545
46export type DriverEvent = PlanEvent | CaseEvent | DoneEvent | FatalEvent | ObservedEvent
46/**
47 * The page asking the harness to do what a person would. Emitted just before
48 * the call that needs it, because the page cannot tap the device itself.
49 */
50export interface AwaitingEvent {
51 event: 'awaiting'
52 /** The case that is waiting. */
53 name: string
54 /** What it is waiting for, one of the needs the harness knows how to meet. */
55 need: string
56}
57
58export type DriverEvent = PlanEvent | CaseEvent | DoneEvent | FatalEvent | ObservedEvent | AwaitingEvent
4759
4860export type MobilePlatform = 'ios' | 'android'
4961
@@ -74,6 +86,8 @@ export const REQUIRED_CASES: Record<MobilePlatform, string[]> = {
7486 'deviceInfo.isEmulator',
7587 'clipboard.roundTrip',
7688 'push.disabled.rejects',
89 'share.empty.rejects',
90 'share.dismissed.resolvesFalse',
7791 ],
7892}
7993
@@ -303,6 +317,42 @@ export function evaluateRun(platform: MobilePlatform, text: string, expectedRun?
303317 return { ok: failures.length === 0, failures, planned, passed, failed }
304318}
305319
320/** The need the Android share case announces before it opens the menu. */
321export const DISMISS_SHARE_MENU = 'dismiss-share-menu'
322
323/** The needs the page has announced so far, in order, without repeats. */
324export function awaitedNeeds(text: string): string[] {
325 const needs: string[] = []
326 for (const event of parseDriverOutput(text).events) {
327 if (event.event === 'awaiting' && !needs.includes(event.need))
328 needs.push(event.need)
329 }
330 return needs
331}
332
333/**
334 * Whether Android's share menu holds input focus, read from
335 * `adb shell dumpsys window`.
336 *
337 * The harness presses Back only once this is true, because Back goes to the
338 * focused window. Focus rather than the activity being *resumed*, which is
339 * what this first checked: the activity manager marks the chooser resumed
340 * before its process has even created it. On the first CI run the chooser was
341 * resumed at 50.37s and took focus at 52.63s. Back pressed in that gap goes to
342 * the app, which closes, and the page never reports anything. That run passed
343 * only because the harness happened to poll late.
344 *
345 * Matched by class name rather than by package, because the chooser moved:
346 * `android/com.android.internal.app.ChooserActivity` up to Android 13, and
347 * `com.android.intentresolver/com.android.intentresolver.ChooserActivity` from
348 * 14.
349 */
350export function shareMenuInFront(dumpsysWindow: string): boolean {
351 return dumpsysWindow
352 .split('\n')
353 .some(line => /mCurrentFocus=/.test(line) && /ChooserActivity/.test(line))
354}
355
306356/**
307357 * The actions the Zig dispatcher saw, by name.
308358 *