@@ -15,30 +15,72 @@ import androidx.activity.result.contract.ActivityResultContract
1515import com.facebook.common.logging.FLog
1616import com.facebook.react.bridge.LifecycleEventListener
1717import com.facebook.react.bridge.ReactContext
18+ import com.facebook.react.bridge.UiThreadUtil
1819import com.facebook.react.common.ReactConstants
20+ import java.util.concurrent.ConcurrentHashMap
21+
22+ /* *
23+ * Runs [block] on the UI thread, inline if already there.
24+ *
25+ * [ActivityResultRegistry] is `@MainThread` and its key tables are unsynchronized. Nothing enforces
26+ * that at runtime, so an off-thread call corrupts them silently rather than throwing -- and RN
27+ * registers on the JS thread and launches on the native-modules thread.
28+ */
29+ internal fun onUiThread (block : () -> Unit ) {
30+ if (UiThreadUtil .isOnUiThread()) block() else UiThreadUtil .runOnUiThread(block)
31+ }
1932
2033/* *
2134 * Default [ReactActivityResultCaller], owned by a [ReactContext].
2235 *
2336 * Registrations are accepted at any time -- native modules are created lazily, typically well after
2437 * the host Activity has resumed -- and bound to the current Activity's [ActivityResultRegistry]
25- * either immediately (when an Activity is already available) or on the next `onHostResume`. When
26- * the host Activity is destroyed the registrations are kept and rebound against the new Activity's
27- * registry under the same keys, so AndroidX can re-associate a result that arrives after Activity
28- * recreation.
38+ * either immediately (when an Activity is already available) or on the next `onHostResume`.
39+ * Registrations outlive any single Activity: keys stay stable, so AndroidX can re-associate a result
40+ * that arrives after Activity recreation.
41+ *
42+ * ## Which registry a launcher is bound to
43+ *
44+ * Every `onHostResume` reconciles each launcher against the *current* registry, rebinding it if it
45+ * is attached to a different one. It deliberately does not stop at "already bound to something":
46+ * with multi-Activity navigation the new Activity resumes before the old one is destroyed, and
47+ * `ReactHostImpl.onHostDestroy(activity)` drops the old Activity's destroy entirely once
48+ * `currentActivity` has moved on. A launcher that only checked "am I bound?" would stay attached to
49+ * the previous Activity's dead registry -- leaking it, and misrouting anything launched from the new
50+ * screen.
51+ *
52+ * ## Threading
53+ *
54+ * [entries] is concurrent and reachable from any thread. Everything that touches
55+ * [ActivityResultRegistry] goes through [onUiThread].
56+ *
57+ * Registration itself stays on the caller's thread, so the launcher is returned immediately and a
58+ * duplicate key throws from the frame that caused it. Only the registry call is hopped.
2959 */
3060internal class ReactActivityResultCallerImpl (private val reactContext : ReactContext ) :
3161 ReactActivityResultCaller , LifecycleEventListener {
3262
3363 private class Entry <I , O >(
3464 val key : String ,
3565 val registrantDescription : String ,
36- val contract : ActivityResultContract <I , O >,
37- val callback : ActivityResultCallback <O >,
66+ private val contract : ActivityResultContract <I , O >,
67+ private val callback : ActivityResultCallback <O >,
3868 val launcher : DeferredActivityResultLauncher <I >,
39- )
69+ ) {
70+ /* *
71+ * Ensures the launcher is bound to [registry], rebinding if it is currently attached to a
72+ * different one. On [Entry] so an `Entry<*, *>` can be bound without unchecked casts.
73+ */
74+ fun bindTo (registry : ActivityResultRegistry ) {
75+ if (launcher.isBoundTo(registry)) return
76+ // Release the previous host's registry first: it may already be dead, and leaving the
77+ // callback registered there leaks that Activity and misroutes anything launched from it.
78+ launcher.unbind()
79+ launcher.bind(registry, registry.register(key, contract, callback))
80+ }
81+ }
4082
41- private val entries = LinkedHashMap <String , Entry <* , * >>()
83+ private val entries = ConcurrentHashMap <String , Entry <* , * >>()
4284
4385 init {
4486 reactContext.addLifecycleEventListener(this )
@@ -77,52 +119,36 @@ internal class ReactActivityResultCallerImpl(private val reactContext: ReactCont
77119 callback = callback)
78120 }
79121
80-
81- @Synchronized
82122 private fun <I , O > register (
83123 key : String ,
84124 registrantDescription : String ,
85125 collisionHint : String ,
86126 contract : ActivityResultContract <I , O >,
87127 callback : ActivityResultCallback <O >,
88128 ): ActivityResultLauncher <I > {
89- entries[key]?.let { existing ->
129+ val launcher = DeferredActivityResultLauncher (key, contract) { entries.remove(key) }
130+ val entry = Entry (key, registrantDescription, contract, callback, launcher)
131+ entries.putIfAbsent(key, entry)?.let { existing ->
90132 throw IllegalStateException (
91133 " ${existing.registrantDescription} already registered a launcher for key '$key '. " +
92134 collisionHint)
93135 }
94- val launcher = DeferredActivityResultLauncher (key, contract) { unregister(key) }
95- val entry = Entry (key, registrantDescription, contract, callback, launcher)
96- entries[key] = entry
97- currentRegistry()?.let { registry -> bind(entry, registry) }
136+ onUiThread { currentRegistry()?.let { registry -> entry.bindTo(registry) } }
98137 return launcher
99138 }
100139
101- @Synchronized
102- private fun unregister (key : String ) {
103- entries.remove(key)
104- }
105-
106- @Synchronized
107- override fun onHostResume () {
108- val registry = currentRegistry() ? : return
109- for (entry in entries.values) {
110- if (! entry.launcher.isBound) {
111- bind(entry, registry)
112- }
113- }
140+ override fun onHostResume () = onUiThread {
141+ val registry = currentRegistry() ? : return @onUiThread
142+ entries.values.forEach { it.bindTo(registry) }
114143 }
115144
116145 override fun onHostPause (): Unit = Unit
117146
118- @Synchronized
119- override fun onHostDestroy () {
120- // Detach from the dying registry but keep the registrations: they are rebound against the next
121- // host's registry (same keys) on the next onHostResume, which is also how a result that
122- // outlives the Activity gets re-associated by AndroidX.
123- for (entry in entries.values) {
124- entry.launcher.unbind()
125- }
147+ override fun onHostDestroy () = onUiThread {
148+ // Detach from the dying registry but keep the registrations: they rebind against the next host's
149+ // registry under the same keys on the next onHostResume, which is how AndroidX re-associates a
150+ // result that outlives the Activity.
151+ entries.values.forEach { it.launcher.unbind() }
126152 }
127153
128154 private fun currentRegistry (): ActivityResultRegistry ? {
@@ -137,8 +163,4 @@ internal class ReactActivityResultCallerImpl(private val reactContext: ReactCont
137163 }
138164 return owner.activityResultRegistry
139165 }
140-
141- private fun <I , O > bind (entry : Entry <I , O >, registry : ActivityResultRegistry ) {
142- entry.launcher.bind(registry.register(entry.key, entry.contract, entry.callback))
143- }
144166}
0 commit comments