also looking at this
fix(android): resolve share() with whether the person picked an app
#210
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.
| @@ -567,6 +567,7 @@ describe('Craft Android builder', () => { | ||
| 567 | 567 | ['OTA download', '_craftOTADownloadResolve', 1], |
| 568 | 568 | ['OTA apply', '_craftOTAApplyResolve', 1], |
| 569 | 569 | ['OTA rollback', '_craftOTARollbackResolve', 1], |
| 570 | ['share', '_craftShareResolve', 1], | |
| 570 | 571 | ] as const) { |
| 571 | 572 | const call = `window.__craftPromise('${channel}', '${resolver}',` |
| 572 | 573 | expect(bridge.match(new RegExp(call.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'))?.length).toBe(uses) |
| @@ -581,12 +582,12 @@ describe('Craft Android builder', () => { | ||
| 581 | 582 | const registrations = [...bridge.matchAll( |
| 582 | 583 | /window\.__craftPromise\(\s*'([^']+)'\s*,\s*'([^']+)'\s*,\s*'([^']+)'/g, |
| 583 | 584 | )].map(match => [match[1], match[2], match[3]] as const) |
| 584 | expect(registrations).toHaveLength(65) | |
| 585 | expect(registrations).toHaveLength(66) | |
| 585 | 586 | |
| 586 | 587 | const uniqueRegistrations = new Map( |
| 587 | 588 | registrations.map(registration => [registration.join('\0'), registration]), |
| 588 | 589 | ) |
| 589 | expect(uniqueRegistrations.size).toBe(46) | |
| 590 | expect(uniqueRegistrations.size).toBe(47) | |
| 590 | 591 | |
| 591 | 592 | const channels = new Set<string>() |
| 592 | 593 | const callbackOwners = new Map<string, string>() |
| @@ -599,8 +600,8 @@ describe('Craft Android builder', () => { | ||
| 599 | 600 | callbackOwners.set(callback, channel) |
| 600 | 601 | } |
| 601 | 602 | } |
| 602 | expect(channels.size).toBe(46) | |
| 603 | expect(callbackOwners.size).toBe(92) | |
| 603 | expect(channels.size).toBe(47) | |
| 604 | expect(callbackOwners.size).toBe(94) | |
| 604 | 605 | expect(bridge.match(/webView\.evaluateJavascript\(\s*"window\._craft[A-Za-z]+(?:Resolve|Reject)/g)).toBeNull() |
| 605 | 606 | expect(bridge.match(/window\._craft[A-Za-z]+(?:Resolve|Reject) = (?:resolve|reject)/g)).toBeNull() |
| 606 | 607 | expect(bridge).toContain('_craftShortcutsResolve({set: true, count: ${shortcuts.size}})') |
| @@ -2,10 +2,13 @@ package | ||
| 2 | 2 | |
| 3 | 3 | import android.Manifest |
| 4 | 4 | import android.app.Activity |
| 5 | import android.content.BroadcastReceiver | |
| 5 | 6 | import android.content.ClipData |
| 6 | 7 | import android.content.ClipboardManager |
| 7 | 8 | import android.content.Context |
| 8 | 9 | import android.content.Intent |
| 10 | import android.content.IntentFilter | |
| 11 | import android.content.IntentSender | |
| 9 | 12 | import android.content.pm.PackageManager |
| 10 | 13 | import android.graphics.Bitmap |
| 11 | 14 | import android.hardware.camera2.CameraAccessException |
| @@ -365,9 +368,14 @@ class CraftBridge( | ||
| 365 | 368 | CraftAndroid.stopListening(); |
| 366 | 369 | }, |
| 367 | 370 | |
| 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. | |
| 369 | 375 | 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 | }); | |
| 371 | 379 | }, |
| 372 | 380 | |
| 373 | 381 | // Camera |
| @@ -1610,18 +1618,95 @@ class CraftBridge( | ||
| 1610 | 1618 | |
| 1611 | 1619 | // ==================== Share ==================== |
| 1612 | 1620 | |
| 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 | ||
| 1613 | 1638 | @JavascriptInterface |
| 1614 | 1639 | 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 | |
| 1616 | 1661 | |
| 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") | |
| 1622 | 1673 | } |
| 1623 | 1674 | } |
| 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)})") | |
| 1625 | 1710 | } |
| 1626 | 1711 | |
| 1627 | 1712 | // ==================== Camera & Gallery ==================== |
| @@ -3698,7 +3783,7 @@ class CraftBridge( | ||
| 3698 | 3783 | fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { |
| 3699 | 3784 | if (closed) return requestCode == REQUEST_CAMERA || requestCode == REQUEST_GALLERY || |
| 3700 | 3785 | requestCode == REQUEST_FILE_PICKER || requestCode == REQUEST_VIDEO || |
| 3701 | requestCode == REQUEST_PICK_CONTACT | |
| 3786 | requestCode == REQUEST_PICK_CONTACT || requestCode == REQUEST_SHARE | |
| 3702 | 3787 | |
| 3703 | 3788 | return when (requestCode) { |
| 3704 | 3789 | REQUEST_CAMERA, REQUEST_GALLERY -> { |
| @@ -3717,6 +3802,10 @@ class CraftBridge( | ||
| 3717 | 3802 | handleContactPickerResult(resultCode, data) |
| 3718 | 3803 | true |
| 3719 | 3804 | } |
| 3805 | REQUEST_SHARE -> { | |
| 3806 | handleShareResult() | |
| 3807 | true | |
| 3808 | } | |
| 3720 | 3809 | else -> healthConnect.onActivityResult(requestCode, resultCode, data) |
| 3721 | 3810 | } |
| 3722 | 3811 | } |
| @@ -3736,6 +3825,11 @@ class CraftBridge( | ||
| 3736 | 3825 | CraftNative.close(activity) |
| 3737 | 3826 | healthConnect.close() |
| 3738 | 3827 | |
| 3828 | if (shareReceiverRegistered) { | |
| 3829 | runCatching { activity.unregisterReceiver(shareChosenReceiver) } | |
| 3830 | shareReceiverRegistered = false | |
| 3831 | } | |
| 3832 | ||
| 3739 | 3833 | runCatching { speechRecognizer?.cancel() } |
| 3740 | 3834 | runCatching { speechRecognizer?.destroy() } |
| 3741 | 3835 | speechRecognizer = null |
| @@ -4670,6 +4764,7 @@ class CraftBridge( | ||
| 4670 | 4764 | const val REQUEST_VIDEO = 1008 |
| 4671 | 4765 | const val REQUEST_BLUETOOTH = 1009 |
| 4672 | 4766 | const val REQUEST_PICK_CONTACT = 1010 |
| 4767 | const val REQUEST_SHARE = 1011 | |
| 4673 | 4768 | private const val PERMISSION_REQUEST_START = 2000 |
| 4674 | 4769 | private const val PERMISSION_REQUEST_END = 2999 |
| 4675 | 4770 | private const val LOCATION_REQUEST_TIMEOUT_MS = 15_000L |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | package com.craft.runtime |
| 2 | 2 | |
| 3 | 3 | import android.app.Activity |
| 4 | import android.content.IntentSender | |
| 4 | 5 | import com.google.android.gms.location.Priority |
| 5 | 6 | import com.google.android.gms.location.LocationServices |
| 6 | 7 | import com.google.android.gms.location.LocationResult |
| @@ -227,7 +228,7 @@ object CraftNative { | ||
| 227 | 228 | private external fun nativeClipboardRead(activity: Activity): String? |
| 228 | 229 | private external fun nativeClipboardWrite(activity: Activity, text: String): Boolean |
| 229 | 230 | 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 | |
| 231 | 232 | private external fun nativeOpenCamera(activity: Activity): Boolean |
| 232 | 233 | private external fun nativePickImage(activity: Activity): Boolean |
| 233 | 234 | private external fun nativePickFile(activity: Activity): Boolean |
| @@ -425,11 +426,15 @@ object CraftNative { | ||
| 425 | 426 | } |
| 426 | 427 | } |
| 427 | 428 | |
| 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 { | |
| 430 | 435 | if (!isAvailable) return false |
| 431 | 436 | return try { |
| 432 | nativeShare(activity, text, title) | |
| 437 | nativeShare(activity, text, title, chosen, requestCode) | |
| 433 | 438 | } catch (e: UnsatisfiedLinkError) { |
| 434 | 439 | false |
| 435 | 440 | } |
| @@ -1050,20 +1050,33 @@ export const share = { | ||
| 1050 | 1050 | /** |
| 1051 | 1051 | * Open native share dialog. |
| 1052 | 1052 | * |
| 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 | * | |
| 1053 | 1056 | * @param options - Share options |
| 1054 | 1057 | */ |
| 1055 | async share(options: ShareOptions): Promise<void> { | |
| 1058 | async share(options: ShareOptions): Promise<boolean> { | |
| 1056 | 1059 | const craft = getCraftMobile() |
| 1057 | 1060 | if (typeof window !== 'undefined' && craft?.share) { |
| 1058 | 1061 | return craft.share.share(options) |
| 1059 | 1062 | } |
| 1060 | 1063 | // Web Share API fallback |
| 1061 | 1064 | 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 | } | |
| 1067 | 1080 | } |
| 1068 | 1081 | else { |
| 1069 | 1082 | throw new Error('Share API not available') |
| @@ -1580,7 +1593,7 @@ interface CraftMobileBridge { | ||
| 1580 | 1593 | readRecording?(): Promise<Location[]> |
| 1581 | 1594 | } |
| 1582 | 1595 | share?: { |
| 1583 | share(options: ShareOptions): Promise<void> | |
| 1596 | share(options: ShareOptions): Promise<boolean> | |
| 1584 | 1597 | } |
| 1585 | 1598 | lifecycle?: { |
| 1586 | 1599 | getState(): AppState |
| @@ -118,11 +118,16 @@ export interface CraftBridge { | ||
| 118 | 118 | haptic(style: HapticStyle): void; |
| 119 | 119 | |
| 120 | 120 | /** |
| 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`). | |
| 122 | 127 | * @param text - Text to share |
| 123 | 128 | * @param title - Optional title |
| 124 | 129 | */ |
| 125 | share(text: string, title?: string): void; | |
| 130 | share(text: string, title?: string): Promise<boolean>; | |
| 126 | 131 | |
| 127 | 132 | /** |
| 128 | 133 | * Log message to native console (Xcode/Logcat) |
| @@ -283,7 +283,7 @@ const natives = [_]jni.JNINativeMethod{ | ||
| 283 | 283 | }, |
| 284 | 284 | .{ |
| 285 | 285 | .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", | |
| 287 | 287 | .fnPtr = @ptrCast(&nativeShare), |
| 288 | 288 | }, |
| 289 | 289 | .{ |
| @@ -879,6 +879,8 @@ fn nativeShare( | ||
| 879 | 879 | activity: jni.jobject, |
| 880 | 880 | text: jni.jstring, |
| 881 | 881 | title: jni.jstring, |
| 882 | chosen: jni.jobject, | |
| 883 | request_code: jni.jint, | |
| 882 | 884 | ) callconv(.c) jni.jboolean { |
| 883 | 885 | const j = Jni.init(env); |
| 884 | 886 | var arena = std.heap.ArenaAllocator.init(backing); |
| @@ -888,7 +890,7 @@ fn nativeShare( | ||
| 888 | 890 | const body = j.stringToUtf8(allocator, text) catch |err| return fellThrough("share", err, jni.JNI_FALSE); |
| 889 | 891 | const subject = j.stringToUtf8(allocator, title) catch |err| return fellThrough("share", err, jni.JNI_FALSE); |
| 890 | 892 | |
| 891 | intents.share(j, allocator, activity, body, subject) catch |err| { | |
| 893 | intents.share(j, allocator, activity, body, subject, chosen, request_code) catch |err| { | |
| 892 | 894 | std.log.warn("craft: share fell through to the shim ({s})", .{@errorName(err)}); |
| 893 | 895 | return jni.JNI_FALSE; |
| 894 | 896 | }; |
| @@ -5,18 +5,23 @@ | ||
| 5 | 5 | //! module — they share every JNI call between them, and the only thing that |
| 6 | 6 | //! differs is which extras go on the intent. |
| 7 | 7 | //! |
| 8 | //! ## Neither reports whether anything happened | |
| 8 | //! ## What each one can find out | |
| 9 | 9 | //! |
| 10 | 10 | //! `startActivity` throws `ActivityNotFoundException` when nothing on the |
| 11 | 11 | //! 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. | |
| 15 | 14 | //! |
| 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. | |
| 20 | 25 | //! |
| 21 | 26 | //! ## `share` has one branch, and it is the empty title |
| 22 | 27 | //! |
| @@ -89,11 +94,21 @@ pub fn openUrl(j: Jni, allocator: std.mem.Allocator, activity: jobject, url: []c | ||
| 89 | 94 | try startActivity(j, activity, intent); |
| 90 | 95 | } |
| 91 | 96 | |
| 92 | /// `startActivity(Intent.createChooser(Intent(ACTION_SEND)…, "Share"))`. | |
| 97 | /// `startActivityForResult(Intent.createChooser(Intent(ACTION_SEND)…, "Share", chosen), request_code)`. | |
| 93 | 98 | /// |
| 94 | 99 | /// An empty `title` skips `EXTRA_SUBJECT` entirely rather than setting it to |
| 95 | /// the empty string — see the module comment. | |
| 96 | pub 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. | |
| 103 | pub 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 { | |
| 97 | 112 | try j.pushLocalFrame(24); |
| 98 | 113 | defer _ = j.popLocalFrame(null); |
| 99 | 114 | |
| @@ -139,12 +154,20 @@ pub fn share(j: Jni, allocator: std.mem.Allocator, activity: jobject, text: []co | ||
| 139 | 154 | try j.staticMethodId( |
| 140 | 155 | intent_cls, |
| 141 | 156 | "createChooser", |
| 142 | "(Landroid/content/Intent;Ljava/lang/CharSequence;)Landroid/content/Intent;", | |
| 157 | "(Landroid/content/Intent;Ljava/lang/CharSequence;Landroid/content/IntentSender;)Landroid/content/Intent;", | |
| 143 | 158 | ), |
| 144 | &.{ .{ .l = intent }, .{ .l = chooser_label } }, | |
| 159 | &.{ .{ .l = intent }, .{ .l = chooser_label }, .{ .l = chosen } }, | |
| 145 | 160 | ); |
| 146 | 161 | |
| 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 | ); | |
| 148 | 171 | } |
| 149 | 172 | |
| 150 | 173 | // ============================================================================= |
| @@ -163,6 +186,9 @@ var fake_field_names: std.ArrayListUnmanaged([]const u8) = .empty; | ||
| 163 | 186 | var fake_strings: std.ArrayListUnmanaged([]const u8) = .empty; |
| 164 | 187 | var fake_throw_on_start = false; |
| 165 | 188 | var fake_pending: jobject = null; |
| 189 | /// The last arguments `createChooser` and `startActivityForResult` received. | |
| 190 | var fake_chooser_sender: jobject = null; | |
| 191 | var fake_request_code: ?jni.jint = null; | |
| 166 | 192 | |
| 167 | 193 | fn obj(tag: usize) jobject { |
| 168 | 194 | return @ptrCast(&fake_storage[tag]); |
| @@ -196,8 +222,9 @@ fn fNewStringUTF(_: jni.JNIEnv, text: [*:0]const u8) callconv(.c) jni.jstring { | ||
| 196 | 222 | fake_strings.append(testing.allocator, copy) catch testing.allocator.free(copy); |
| 197 | 223 | return obj(3); |
| 198 | 224 | } |
| 199 | fn fCallStaticObjectMethodA(_: jni.JNIEnv, _: jni.jclass, id: jni.jmethodID, _: [*]const jni.jvalue) callconv(.c) jobject { | |
| 225 | fn fCallStaticObjectMethodA(_: jni.JNIEnv, _: jni.jclass, id: jni.jmethodID, args: [*]const jni.jvalue) callconv(.c) jobject { | |
| 200 | 226 | fake_calls.append(testing.allocator, nameOf(id)) catch {}; |
| 227 | if (std.mem.eql(u8, nameOf(id), "createChooser")) fake_chooser_sender = args[2].l; | |
| 201 | 228 | return obj(4); |
| 202 | 229 | } |
| 203 | 230 | fn 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. | ||
| 208 | 235 | fake_calls.append(testing.allocator, nameOf(id)) catch {}; |
| 209 | 236 | return obj(6); |
| 210 | 237 | } |
| 211 | fn fCallVoidMethodA(_: jni.JNIEnv, _: jobject, id: jni.jmethodID, _: [*]const jni.jvalue) callconv(.c) void { | |
| 238 | fn fCallVoidMethodA(_: jni.JNIEnv, _: jobject, id: jni.jmethodID, args: [*]const jni.jvalue) callconv(.c) void { | |
| 212 | 239 | fake_calls.append(testing.allocator, nameOf(id)) catch {}; |
| 240 | if (std.mem.eql(u8, nameOf(id), "startActivityForResult")) fake_request_code = args[1].i; | |
| 213 | 241 | if (fake_throw_on_start) fake_pending = obj(7); |
| 214 | 242 | } |
| 215 | 243 | fn fExceptionOccurred(_: jni.JNIEnv) callconv(.c) jobject { |
| @@ -255,6 +283,8 @@ fn resetFakes() void { | ||
| 255 | 283 | fake_strings.clearRetainingCapacity(); |
| 256 | 284 | fake_throw_on_start = false; |
| 257 | 285 | fake_pending = null; |
| 286 | fake_chooser_sender = null; | |
| 287 | fake_request_code = null; | |
| 258 | 288 | } |
| 259 | 289 | |
| 260 | 290 | fn freeFakes() void { |
| @@ -321,7 +351,7 @@ test "share sets the text extra and the chooser" { | ||
| 321 | 351 | fakeEnv(&table); |
| 322 | 352 | const ptr: *const jni.JNINativeInterface = &table; |
| 323 | 353 | |
| 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); | |
| 325 | 355 | |
| 326 | 356 | try testing.expect(sawField("ACTION_SEND")); |
| 327 | 357 | try testing.expect(sawField("EXTRA_TEXT")); |
| @@ -329,11 +359,45 @@ test "share sets the text extra and the chooser" { | ||
| 329 | 359 | try testing.expect(sawCall("setType")); |
| 330 | 360 | try testing.expect(sawCall("putExtra")); |
| 331 | 361 | try testing.expect(sawCall("createChooser")); |
| 332 | try testing.expect(sawCall("startActivity")); | |
| 333 | 362 | try testing.expect(sawString("text/plain")); |
| 334 | 363 | try testing.expect(sawString("Share")); |
| 335 | 364 | } |
| 336 | 365 | |
| 366 | test "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 | ||
| 385 | test "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 | ||
| 337 | 401 | test "an empty title omits the subject rather than setting it empty" { |
| 338 | 402 | // The one branch in either action. An empty EXTRA_SUBJECT produces a mail |
| 339 | 403 | // 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" { | ||
| 344 | 408 | fakeEnv(&table); |
| 345 | 409 | const ptr: *const jni.JNINativeInterface = &table; |
| 346 | 410 | |
| 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); | |
| 348 | 412 | |
| 349 | 413 | try testing.expect(sawField("EXTRA_TEXT")); |
| 350 | 414 | try testing.expect(!sawField("EXTRA_SUBJECT")); |
| 351 | 415 | // And the rest of the intent is still built. |
| 352 | 416 | try testing.expect(sawCall("createChooser")); |
| 353 | try testing.expect(sawCall("startActivity")); | |
| 417 | try testing.expect(sawCall("startActivityForResult")); | |
| 354 | 418 | } |
| 355 | 419 | |
| 356 | 420 | test "the action names match the Kotlin methods exactly" { |