also looking at this
fix(android): resolve share() with whether the person picked an app
#210
12 files
+399
-56
| @@ -49,10 +49,16 @@ bun run test:mobile-e2e:android | ||
| 49 | 49 | ``` |
| 50 | 50 | |
| 51 | 51 | ``` |
| 52 | ok android-shim — 4/4 cases, no zig | |
| 53 | ok android-runtime — 4/4 cases, zig served registered:103, declines:0 | |
| 52 | ok android-shim — 6/6 cases, no zig | |
| 53 | ok android-runtime — 6/6 cases, zig served registered:103, declines:0 | |
| 54 | 54 | ``` |
| 55 | 55 | |
| 56 | One Android case needs a person: `share.dismissed.resolvesFalse` opens the | |
| 57 | real share menu and expects `false` once it is dismissed. The harness plays | |
| 58 | that part. It waits until `dumpsys window` shows the chooser holding input | |
| 59 | focus, saves `share-menu.png`, presses Back, and fails the run if the page asked | |
| 60 | for a dismissal and no menu ever appeared. | |
| 61 | ||
| 56 | 62 | The runtime leg fails on any line where a Zig native gave up — "fell through |
| 57 | 63 | to the shim", "failed with no fallback" or "could not reach the page" — because |
| 58 | 64 | Kotlin answering in its place is invisible to the page, and a runtime that has |
| @@ -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" { |
| @@ -2,7 +2,7 @@ import type { LegOutcome, RunnerOptions } from './types' | ||
| 2 | 2 | import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' |
| 3 | 3 | import { join } from 'node:path' |
| 4 | 4 | import { init } from '../../packages/android/src/index' |
| 5 | import { androidDeclines, evaluateRun, hasTerminated } from './protocol' | |
| 5 | import { androidDeclines, awaitedNeeds, DISMISS_SHARE_MENU, evaluateRun, hasTerminated, shareMenuInFront } from './protocol' | |
| 6 | 6 | import { command, driverPage, waitForFile } from './support' |
| 7 | 7 | |
| 8 | 8 | /** |
| @@ -149,6 +149,24 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> { | ||
| 149 | 149 | await adb(['shell', 'am', 'force-stop', PACKAGE], { serial, allowFailure: true }) |
| 150 | 150 | await adb(['shell', 'am', 'start', '-n', `${PACKAGE}/.MainActivity`], { serial, logPath: join(evidence, 'adb.log') }) |
| 151 | 151 | |
| 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 | ||
| 152 | 170 | // logcat -d dumps and exits, so poll it into the evidence file rather than |
| 153 | 171 | // streaming: a stream would have to be killed at exactly the right moment, |
| 154 | 172 | // and the dump is cheap. |
| @@ -156,6 +174,7 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> { | ||
| 156 | 174 | const finished = await waitForFile(logPath, options.timeoutMs, hasTerminated, async () => { |
| 157 | 175 | const dumped = await command(['adb', '-s', serial, 'logcat', '-d', ...LOGCAT_FILTER], { allowFailure: true }) |
| 158 | 176 | writeFileSync(logPath, dumped.stdout) |
| 177 | await dismissShareMenuWhenAsked(dumped.stdout) | |
| 159 | 178 | }) |
| 160 | 179 | |
| 161 | 180 | 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> { | ||
| 170 | 189 | const verdict = evaluateRun('android', logText, nonce) |
| 171 | 190 | failures.push(...verdict.failures) |
| 172 | 191 | |
| 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 | ||
| 173 | 202 | // The page reported the SDK level the bridge told it; the host asked the |
| 174 | 203 | // device directly. Agreement means the value crossed the bridge rather than |
| 175 | 204 | // being invented in JavaScript - this leg's substitute for the pasteboard |
| @@ -64,14 +64,14 @@ | ||
| 64 | 64 | return { code: String(error.code || ''), message: String(error.message || error) } |
| 65 | 65 | } |
| 66 | 66 | |
| 67 | function withTimeout(run) { | |
| 67 | function withTimeout(run, timeoutMs) { | |
| 68 | 68 | return new Promise(function (resolve) { |
| 69 | 69 | var settled = false |
| 70 | 70 | var timer = setTimeout(function () { |
| 71 | 71 | if (settled) return |
| 72 | 72 | settled = true |
| 73 | 73 | resolve({ state: 'timeout' }) |
| 74 | }, CASE_TIMEOUT_MS) | |
| 74 | }, timeoutMs || CASE_TIMEOUT_MS) | |
| 75 | 75 | |
| 76 | 76 | function finish(value) { |
| 77 | 77 | if (settled) return |
| @@ -198,6 +198,43 @@ | ||
| 198 | 198 | }) |
| 199 | 199 | } |
| 200 | 200 | }, |
| 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 | }, | |
| 201 | 238 | { |
| 202 | 239 | // Android's equivalent gate. With push disabled the generator emits a |
| 203 | 240 | // literal reject call rather than a Firebase block, so the rejection |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { describe, expect, it } from 'bun:test' |
| 2 | 2 | import { readFileSync } from 'node:fs' |
| 3 | 3 | import { join } from 'node:path' |
| 4 | import { ANDROID_DECLINE_PHRASES, androidDeclines, evaluateRun, hasTerminated, parseDriverOutput, REQUIRED_CASES, requiredCaseProblems, ZIG_REFUSED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigRefusals } from './protocol' | |
| 4 | import { 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' | |
| 5 | 5 | |
| 6 | 6 | const ESC = String.fromCharCode(27) |
| 7 | 7 | |
| @@ -283,6 +283,42 @@ describe('zig attribution', () => { | ||
| 283 | 283 | }) |
| 284 | 284 | }) |
| 285 | 285 | |
| 286 | describe('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 | ||
| 286 | 322 | describe('android declines', () => { |
| 287 | 323 | // The lines the first Android runtime run actually produced, plus the two |
| 288 | 324 | // decline kinds it did not hit, so all three are read the way they are |
| @@ -43,7 +43,19 @@ export interface ObservedEvent { | ||
| 43 | 43 | value: unknown |
| 44 | 44 | } |
| 45 | 45 | |
| 46 | export 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 | */ | |
| 50 | export 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 | ||
| 58 | export type DriverEvent = PlanEvent | CaseEvent | DoneEvent | FatalEvent | ObservedEvent | AwaitingEvent | |
| 47 | 59 | |
| 48 | 60 | export type MobilePlatform = 'ios' | 'android' |
| 49 | 61 | |
| @@ -74,6 +86,8 @@ export const REQUIRED_CASES: Record<MobilePlatform, string[]> = { | ||
| 74 | 86 | 'deviceInfo.isEmulator', |
| 75 | 87 | 'clipboard.roundTrip', |
| 76 | 88 | 'push.disabled.rejects', |
| 89 | 'share.empty.rejects', | |
| 90 | 'share.dismissed.resolvesFalse', | |
| 77 | 91 | ], |
| 78 | 92 | } |
| 79 | 93 | |
| @@ -303,6 +317,42 @@ export function evaluateRun(platform: MobilePlatform, text: string, expectedRun? | ||
| 303 | 317 | return { ok: failures.length === 0, failures, planned, passed, failed } |
| 304 | 318 | } |
| 305 | 319 | |
| 320 | /** The need the Android share case announces before it opens the menu. */ | |
| 321 | export const DISMISS_SHARE_MENU = 'dismiss-share-menu' | |
| 322 | ||
| 323 | /** The needs the page has announced so far, in order, without repeats. */ | |
| 324 | export 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 | */ | |
| 350 | export function shareMenuInFront(dumpsysWindow: string): boolean { | |
| 351 | return dumpsysWindow | |
| 352 | .split('\n') | |
| 353 | .some(line => /mCurrentFocus=/.test(line) && /ChooserActivity/.test(line)) | |
| 354 | } | |
| 355 | ||
| 306 | 356 | /** |
| 307 | 357 | * The actions the Zig dispatcher saw, by name. |
| 308 | 358 | * |