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

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

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