-
Notifications
You must be signed in to change notification settings - Fork 25.2k
feat(android): support Activity Result API for native modules #57798
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
matinzd
wants to merge
19
commits into
react:main
Choose a base branch
from
matinzd:feat/permission_contracts_android
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,412
−4
Open
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
597dd44
feat: initial support for permission contracts [WIP]
matinzd a5af91c
feat: let native modules register ActivityResultContracts via ReactCo…
matinzd 1ccc437
fix: mark ReactActivityResultCaller and its implementation internal
matinzd 71ce4d8
feat: add photo picker example
matinzd 05bc781
docs: add readme
matinzd 23bf7e4
fix: discard inflight promises when js context invalidates
matinzd 44eb72a
fix: formatting
matinzd 471a9b7
fix: key strategy for registering activity contracts
matinzd 2a1a3ce
test: add tests for keying mechanism
matinzd 00cdc55
fix: thread-safety and and rebinding to current activity
matinzd 2aa5a8f
docs: update and add more context
matinzd 78b0126
feat: add multi activity example for rebind
matinzd f4c6603
fix: trim comments + cleanup
matinzd de6b29b
fix: unregistering launchers
matinzd cfe487c
fix: check for anonymous class
matinzd 3772263
docs: update
matinzd 3ff606c
fix: throw early if curent activity is not registry owner
matinzd 7f00401
update docs
matinzd 35a1d36
fix: readme formatting
matinzd File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
92 changes: 92 additions & 0 deletions
92
...Android/src/main/java/com/facebook/react/activityresult/DeferredActivityResultLauncher.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| /* | ||
| * Copyright (c) Meta Platforms, Inc. and affiliates. | ||
| * | ||
| * This source code is licensed under the MIT license found in the | ||
| * LICENSE file in the root directory of this source tree. | ||
| */ | ||
|
|
||
| package com.facebook.react.activityresult | ||
|
|
||
| import androidx.activity.result.ActivityResultLauncher | ||
| import androidx.activity.result.ActivityResultRegistry | ||
| import androidx.activity.result.contract.ActivityResultContract | ||
| import androidx.core.app.ActivityOptionsCompat | ||
| import com.facebook.common.logging.FLog | ||
| import com.facebook.react.bridge.UiThreadUtil | ||
| import com.facebook.react.common.ReactConstants | ||
|
|
||
| /** | ||
| * An [ActivityResultLauncher] that may exist before any `ActivityResultRegistry` is available: it | ||
| * delegates to the real launcher once [bind] is called, queues a single [launch] issued while | ||
| * unbound (fired on bind), and can be [unbind]-ed and rebound against a new host's registry. | ||
| * | ||
| * [delegate] and [pendingLaunch] are only touched on the UI thread; [launch] and [unregister] get | ||
| * there via [onUiThread]. [launch] decides between delegating and queueing *on* the UI thread, so | ||
| * a concurrent [unbind] cannot leave it pointed at a dead registry. | ||
| */ | ||
| internal class DeferredActivityResultLauncher<I>( | ||
| private val key: String, | ||
| private val contract: ActivityResultContract<I, *>, | ||
| private val onUnregister: () -> Unit, | ||
| ) : ActivityResultLauncher<I>() { | ||
|
|
||
| override fun getContract(): ActivityResultContract<I, *> = contract | ||
|
|
||
| private class PendingLaunch<I>(val input: I, val options: ActivityOptionsCompat?) | ||
|
|
||
| private var delegate: ActivityResultLauncher<I>? = null | ||
| private var boundRegistry: ActivityResultRegistry? = null | ||
| private var pendingLaunch: PendingLaunch<I>? = null | ||
|
|
||
| override fun launch(input: I, options: ActivityOptionsCompat?) { | ||
| onUiThread { | ||
| val boundDelegate = delegate | ||
| if (boundDelegate != null) { | ||
| boundDelegate.launch(input, options) | ||
| } else { | ||
| if (pendingLaunch != null) { | ||
| FLog.w( | ||
| ReactConstants.TAG, | ||
| "Launcher for '$key' was launched again before an Activity was available; " + | ||
| "replacing the previously queued launch.") | ||
| } | ||
| pendingLaunch = PendingLaunch(input, options) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| override fun unregister() { | ||
| // Drop the registration first so nothing rebinds this launcher in the meantime. | ||
| onUnregister() | ||
| onUiThread { | ||
| delegate?.unregister() | ||
| delegate = null | ||
| pendingLaunch = null | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Attaches [launcher], obtained from [registry] (remembered for [isBoundTo]), and fires any | ||
| * queued launch. | ||
| */ | ||
| fun bind(registry: ActivityResultRegistry, launcher: ActivityResultLauncher<I>) { | ||
| UiThreadUtil.assertOnUiThread() | ||
| delegate = launcher | ||
| boundRegistry = registry | ||
| pendingLaunch?.let { pending -> | ||
| pendingLaunch = null | ||
| launcher.launch(pending.input, pending.options) | ||
| } | ||
| } | ||
|
|
||
| /** Detaches from the bound registry, keeping any queued launch for the next [bind]. */ | ||
| fun unbind() { | ||
| UiThreadUtil.assertOnUiThread() | ||
| delegate?.unregister() | ||
| delegate = null | ||
| boundRegistry = null | ||
| } | ||
|
|
||
| /** Whether this launcher is bound to [registry] itself, not just to any registry. */ | ||
| fun isBoundTo(registry: ActivityResultRegistry): Boolean = boundRegistry === registry | ||
| } |
62 changes: 62 additions & 0 deletions
62
...ReactAndroid/src/main/java/com/facebook/react/activityresult/ReactActivityResultCaller.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| /* | ||
| * Copyright (c) Meta Platforms, Inc. and affiliates. | ||
| * | ||
| * This source code is licensed under the MIT license found in the | ||
| * LICENSE file in the root directory of this source tree. | ||
| */ | ||
|
|
||
| package com.facebook.react.activityresult | ||
|
|
||
| import androidx.activity.result.ActivityResultCallback | ||
| import androidx.activity.result.ActivityResultLauncher | ||
| import androidx.activity.result.contract.ActivityResultContract | ||
|
|
||
| /** | ||
| * Lets a native module register an AndroidX [ActivityResultContract] and receive results without | ||
| * any changes to the consumer's `MainActivity`. Mirrors | ||
| * `androidx.activity.ComponentActivity.registerForActivityResult`, except registration is legal at | ||
| * any time: the returned launcher binds to the real registry once a host Activity resumes. | ||
| * | ||
| * Every registration carries a key that must be unique within the `ReactContext` and stable across | ||
| * process death (AndroidX replays a restored result to whichever registration reproduces the same | ||
| * key). The default key `"<owner class>:<contract class>"` lets unrelated libraries register the | ||
| * same stock contract without colliding; a collision throws [IllegalStateException] at | ||
| * registration time, and the keyed overload (which appends to that scope, not replaces it) | ||
| * resolves it. | ||
| */ | ||
| internal interface ReactActivityResultCaller { | ||
|
|
||
| /** | ||
| * Registers [contract] under the key `"<owner class>:<contract class>"` and returns a launcher | ||
| * for it. [owner] must be an instance of a named class — typically the native module itself. | ||
| * Anonymous classes are rejected because their generated names can change between builds, which | ||
| * breaks result delivery after the process is killed and restored. For the same reason, apps | ||
| * that minify class names (R8/ProGuard) should keep the owner class's name, since the key is not | ||
| * guaranteed to be stable between builds otherwise. | ||
| * | ||
| * @throws IllegalArgumentException if [owner] is an instance of an anonymous class | ||
| * @throws IllegalStateException if [owner] already registered this contract class | ||
| */ | ||
| fun <I, O> registerForActivityResult( | ||
| owner: Any, | ||
| contract: ActivityResultContract<I, O>, | ||
| callback: ActivityResultCallback<O>, | ||
| ): ActivityResultLauncher<I> | ||
|
|
||
| /** | ||
| * Registers [contract] under the key `"<owner class>:<contract class>:<key>"`. Use this when | ||
| * one owner needs several launchers of the same contract class. [key] only has to be unique | ||
| * among those, but must stay the same across process restarts, so derive it from a constant. | ||
| * [owner] carries the same requirements as the two-argument overload: it must be an instance of | ||
| * a named class. | ||
| * | ||
| * @throws IllegalArgumentException if [owner] is an instance of an anonymous class | ||
| * @throws IllegalStateException if [owner] already registered this contract class under [key] | ||
| */ | ||
| fun <I, O> registerForActivityResult( | ||
| owner: Any, | ||
| key: String, | ||
| contract: ActivityResultContract<I, O>, | ||
| callback: ActivityResultCallback<O>, | ||
| ): ActivityResultLauncher<I> | ||
| } |
155 changes: 155 additions & 0 deletions
155
...tAndroid/src/main/java/com/facebook/react/activityresult/ReactActivityResultCallerImpl.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,155 @@ | ||
| /* | ||
| * Copyright (c) Meta Platforms, Inc. and affiliates. | ||
| * | ||
| * This source code is licensed under the MIT license found in the | ||
| * LICENSE file in the root directory of this source tree. | ||
| */ | ||
|
|
||
| package com.facebook.react.activityresult | ||
|
|
||
| import androidx.activity.result.ActivityResultCallback | ||
| import androidx.activity.result.ActivityResultLauncher | ||
| import androidx.activity.result.ActivityResultRegistry | ||
| import androidx.activity.result.ActivityResultRegistryOwner | ||
| import androidx.activity.result.contract.ActivityResultContract | ||
| import com.facebook.common.logging.FLog | ||
| import com.facebook.react.bridge.LifecycleEventListener | ||
| import com.facebook.react.bridge.ReactContext | ||
| import com.facebook.react.bridge.UiThreadUtil | ||
| import com.facebook.react.common.ReactConstants | ||
| import java.util.concurrent.ConcurrentHashMap | ||
|
|
||
| /** | ||
| * Runs [block] on the UI thread, inline if already there. [ActivityResultRegistry] is `@MainThread` | ||
| * but not enforced at runtime: an off-thread call corrupts it silently, and RN calls in from the JS | ||
| * and native-modules threads. | ||
| */ | ||
| internal fun onUiThread(block: () -> Unit) { | ||
| if (UiThreadUtil.isOnUiThread()) block() else UiThreadUtil.runOnUiThread(block) | ||
| } | ||
|
|
||
| /** | ||
| * Default [ReactActivityResultCaller], owned by a [ReactContext]. | ||
| * | ||
| * Registrations are accepted at any time and bound to the current Activity's | ||
| * [ActivityResultRegistry] immediately or on the next `onHostResume`. They outlive any single | ||
| * Activity: keys stay stable so AndroidX can re-associate a result after Activity recreation. | ||
| * | ||
| * Every `onHostResume` checks each launcher against the *current* registry, not just "already | ||
| * bound to something": with multi-Activity navigation the new Activity resumes before the old one | ||
| * is destroyed (whose onHostDestroy is dropped once `currentActivity` moves on), so a bound-only | ||
| * check would leave launchers attached to the previous Activity's dead registry. | ||
| * | ||
| * Threading: [entries] is concurrent and reachable from any thread; everything touching the | ||
| * registry goes through [onUiThread]. Registration stays on the caller's thread so the launcher | ||
| * returns immediately and a duplicate key throws at the causing frame. Only the registry call | ||
| * moves to the UI thread. | ||
| */ | ||
| internal class ReactActivityResultCallerImpl(private val reactContext: ReactContext) : | ||
| ReactActivityResultCaller, LifecycleEventListener { | ||
|
|
||
| private class Entry<I, O>( | ||
| val key: String, | ||
| private val contract: ActivityResultContract<I, O>, | ||
| private val callback: ActivityResultCallback<O>, | ||
| val launcher: DeferredActivityResultLauncher<I>, | ||
| ) { | ||
| /** | ||
| * Ensures the launcher is bound to [registry], rebinding if it is currently attached to a | ||
| * different one. On [Entry] so an `Entry<*, *>` can be bound without unchecked casts. | ||
| */ | ||
| fun bindTo(registry: ActivityResultRegistry) { | ||
| if (launcher.isBoundTo(registry)) return | ||
| // Release any previous (possibly dead) registry first; staying registered there leaks its | ||
| // Activity and sends launches to the wrong one. | ||
| launcher.unbind() | ||
| launcher.bind(registry, registry.register(key, contract, callback)) | ||
| } | ||
| } | ||
|
|
||
| private val entries = ConcurrentHashMap<String, Entry<*, *>>() | ||
|
|
||
| init { | ||
| reactContext.addLifecycleEventListener(this) | ||
| } | ||
|
|
||
| override fun <I, O> registerForActivityResult( | ||
| owner: Any, | ||
| contract: ActivityResultContract<I, O>, | ||
| callback: ActivityResultCallback<O>, | ||
| ): ActivityResultLauncher<I> { | ||
| if(owner::class.java.isAnonymousClass) { | ||
| throw IllegalArgumentException( | ||
| "ActivityResult owner must be a named class, but got an anonymous class. " + | ||
| "Pass an instance of a named class instead." | ||
| ) | ||
| } | ||
|
|
||
| return register( | ||
| key = "${owner.javaClass.name}:${contract.javaClass.name}", | ||
| collisionHint = | ||
| "Register once and reuse the launcher, or pass a distinct key per launcher: " + | ||
| "registerForActivityResult(owner, \"someName\", contract, callback).", | ||
| contract = contract, | ||
| callback = callback) | ||
| } | ||
|
|
||
| override fun <I, O> registerForActivityResult( | ||
| owner: Any, | ||
| key: String, | ||
| contract: ActivityResultContract<I, O>, | ||
| callback: ActivityResultCallback<O>, | ||
| ): ActivityResultLauncher<I> { | ||
| if(owner::class.java.isAnonymousClass) { | ||
| throw IllegalArgumentException( | ||
| "ActivityResult owner must be a named class, but got an anonymous class. " + | ||
| "Pass an instance of a named class instead." | ||
| ) | ||
| } | ||
|
|
||
| return register( | ||
| key = "${owner.javaClass.name}:${contract.javaClass.name}:$key", | ||
| collisionHint = "Pass a key that is unique among this owner's launchers of this contract.", | ||
| contract = contract, | ||
| callback = callback) | ||
| } | ||
|
|
||
| private fun <I, O> register( | ||
| key: String, | ||
| collisionHint: String, | ||
| contract: ActivityResultContract<I, O>, | ||
| callback: ActivityResultCallback<O>, | ||
| ): ActivityResultLauncher<I> { | ||
| val launcher = DeferredActivityResultLauncher(key, contract) { entries.remove(key) } | ||
| val entry = Entry(key, contract, callback, launcher) | ||
| if (entries.putIfAbsent(key, entry) != null) { | ||
| throw IllegalStateException( | ||
| "A launcher is already registered for key '$key'. $collisionHint") | ||
| } | ||
| onUiThread { currentRegistry()?.let { registry -> entry.bindTo(registry) } } | ||
| return launcher | ||
| } | ||
|
|
||
| override fun onHostResume() = onUiThread { | ||
| val registry = currentRegistry() ?: return@onUiThread | ||
| entries.values.forEach { it.bindTo(registry) } | ||
| } | ||
|
|
||
| override fun onHostPause(): Unit = Unit | ||
|
|
||
| override fun onHostDestroy() = onUiThread { | ||
| // Detach from the dying registry but keep the registrations: they rebind under the same keys | ||
| // on the next onHostResume, which is how AndroidX re-associates a surviving result. | ||
| entries.values.forEach { it.launcher.unbind() } | ||
| } | ||
|
|
||
| private fun currentRegistry(): ActivityResultRegistry? { | ||
| val activity = reactContext.currentActivity ?: return null | ||
| val owner = activity as? ActivityResultRegistryOwner | ||
| ?: throw IllegalStateException( | ||
| "Current Activity ${activity.javaClass.name} is not an ActivityResultRegistryOwner; " + | ||
| "ActivityResultContract launchers cannot be registered." | ||
| ) | ||
| return owner.activityResultRegistry | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.