fix(android): restore image picker after Activity recreation (#7546)

Backport expo/expo#49634 to SDK 54 so retained launchers register
against the current Activity after their previous owner is destroyed.
Cover repeated recreation and replacement-before-destroy ordering with
native regression tests, and document the native rebuild requirement.

Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: Ido Yana <idoyana@gmail.com>
Co-authored-by: RoundRedDot <roundreddot@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-21 09:17:26 +02:00
committed by GitHub
co-authored by luke Ido Yana RoundRedDot
parent fbd90cb88d
commit 5d70bf0645
3 changed files with 166 additions and 0 deletions
+16
View File
@@ -61,6 +61,22 @@ Gradle auto-fetches the platform/build-tools it needs once licenses are accepted
## Local build + install
The locked `expo-modules-core@57.0.23` needs the patch installed by `npm install`.
It backports
[Expo's Activity-result launcher fix](https://github.com/expo/expo/pull/49634): changing
font size or another unhandled configuration can otherwise disable image selection
until the app process restarts. Rebuild the native app after changing this patch;
a Metro reload cannot update it. Remove the patch when the installed
`expo-modules-core` includes the fix.
Run its native regression tests from `packages/app/android` after prebuild:
```bash
./gradlew :expo-modules-core:testDebugUnitTest \
--tests expo.modules.kotlin.activityresult.AppContextActivityResultRegistryRecreationTest \
--no-daemon --max-workers=1 -Dorg.gradle.parallel=false
```
From repo root:
```bash
+144
View File
@@ -0,0 +1,144 @@
diff --git a/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/activityresult/AppContextActivityResultRegistry.kt b/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/activityresult/AppContextActivityResultRegistry.kt
index a5b971e..7ef1b59 100644
--- a/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/activityresult/AppContextActivityResultRegistry.kt
+++ b/node_modules/expo-modules-core/android/src/main/java/expo/modules/kotlin/activityresult/AppContextActivityResultRegistry.kt
@@ -29,6 +29,7 @@ import expo.modules.kotlin.safeGetParcelable
import expo.modules.kotlin.safeGetParcelableExtra
import java.io.Serializable
import java.util.Random
+import java.util.concurrent.CountDownLatch
private const val TAG = "ActivityResultRegistry"
@@ -222,6 +223,7 @@ class AppContextActivityResultRegistry(
return object : AppContextActivityResultLauncher<I, O>() {
override fun launch(input: I, callback: ActivityResultCallback<O>) {
val requestCode = keyToRequestCode[key]
+ ?: reregister(key, contract, fallbackCallback)
?: throw IllegalStateException("Attempting to launch an unregistered ActivityResultLauncher with contract $contract and input $input. You must ensure the ActivityResultLauncher is registered before calling launch()")
keyToCallbacksAndContract[key] = CallbacksAndContract(fallbackCallback, callback, contract)
@@ -240,6 +242,39 @@ class AppContextActivityResultRegistry(
}
}
+ /**
+ * A registration is bound to one Activity's lifecycle and dropped by its ON_DESTROY,
+ * while launchers outlive the Activity inside the AppContext. Registers [key] again
+ * against the live Activity and returns its request code. register() adds a lifecycle
+ * observer, which androidx requires on the main thread; launch() runs on the module's
+ * IO dispatcher, so the call is marshalled there.
+ */
+ private fun <I : Serializable, O> reregister(
+ key: String,
+ contract: AppContextActivityResultContract<I, O>,
+ fallbackCallback: AppContextActivityResultFallbackCallback<I, O>
+ ): Int? {
+ val liveActivity = activity
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ register(key, liveActivity, contract, fallbackCallback)
+ } else {
+ val registered = CountDownLatch(1)
+ var failure: Throwable? = null
+ Handler(Looper.getMainLooper()).post {
+ try {
+ register(key, liveActivity, contract, fallbackCallback)
+ } catch (e: Throwable) {
+ failure = e
+ } finally {
+ registered.countDown()
+ }
+ }
+ registered.await()
+ failure?.let { throw it }
+ }
+ return keyToRequestCode[key]
+ }
+
/**
* Persist the state of the registry.
*/
diff --git a/node_modules/expo-modules-core/android/src/test/java/expo/modules/kotlin/activityresult/AppContextActivityResultRegistryRecreationTest.kt b/node_modules/expo-modules-core/android/src/test/java/expo/modules/kotlin/activityresult/AppContextActivityResultRegistryRecreationTest.kt
new file mode 100644
index 0000000..89a8eb1
--- /dev/null
+++ b/node_modules/expo-modules-core/android/src/test/java/expo/modules/kotlin/activityresult/AppContextActivityResultRegistryRecreationTest.kt
@@ -0,0 +1,78 @@
+package expo.modules.kotlin.activityresult
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import androidx.appcompat.app.AppCompatActivity
+import expo.modules.kotlin.providers.CurrentActivityProvider
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [33], manifest = Config.NONE)
+class AppContextActivityResultRegistryRecreationTest {
+ private class ActivityProvider(override var currentActivity: Activity?) : CurrentActivityProvider
+
+ private val contract = object : AppContextActivityResultContract<String, String> {
+ override fun createIntent(context: Context, input: String) = Intent("test.PICK_IMAGE")
+ override fun parseResult(input: String, resultCode: Int, intent: Intent?) = "$input:$resultCode"
+ }
+
+ private fun createActivity() = Robolectric.buildActivity(AppCompatActivity::class.java).apply {
+ get().setTheme(androidx.appcompat.R.style.Theme_AppCompat)
+ }.setup()
+
+ @Test
+ fun retainedLauncherDeliversResultsAfterRepeatedActivityRecreation() {
+ var controller = createActivity()
+ val provider = ActivityProvider(controller.get())
+ val registry = AppContextActivityResultRegistry(provider)
+ val fallbackResults = mutableListOf<String>()
+ val launcher = registry.register("image", controller.get(), contract) { _, result ->
+ fallbackResults.add(result)
+ }
+ val results = mutableListOf<String>()
+
+ repeat(3) { iteration ->
+ controller.pause().stop().destroy()
+ controller = createActivity()
+ provider.currentActivity = controller.get()
+
+ launcher.launch("image-$iteration") { results.add(it) }
+ val started = shadowOf(controller.get()).nextStartedActivityForResult
+ assertEquals("test.PICK_IMAGE", started.intent.action)
+ assertTrue(registry.dispatchResult(started.requestCode, Activity.RESULT_OK, null))
+ }
+
+ assertEquals(listOf("image-0:-1", "image-1:-1", "image-2:-1"), results)
+ assertEquals(emptyList<String>(), fallbackResults)
+ controller.pause().stop().destroy()
+ }
+
+ @Test
+ fun retainedLauncherUsesReplacementThatResumesBeforePreviousActivityIsDestroyed() {
+ val previous = createActivity()
+ val provider = ActivityProvider(previous.get())
+ val registry = AppContextActivityResultRegistry(provider)
+ val results = mutableListOf<String>()
+ val launcher = registry.register("image", previous.get(), contract) { _, result ->
+ results.add("fallback:$result")
+ }
+ previous.pause()
+ val replacement = createActivity()
+ provider.currentActivity = replacement.get()
+ previous.stop().destroy()
+
+ launcher.launch("replacement") { results.add(it) }
+ val started = shadowOf(replacement.get()).nextStartedActivityForResult
+ assertTrue(registry.dispatchResult(started.requestCode, Activity.RESULT_OK, null))
+ assertEquals(listOf("replacement:-1"), results)
+ replacement.pause().stop().destroy()
+ }
+}
+6
View File
@@ -8,6 +8,12 @@ import { join, relative } from "node:path";
// workspace root live in their workspace's own node_modules, and patch-package resolves
// the patch's node_modules/... paths relative to its working directory.
const patchedPackages = [
// Backport for expo-modules-core 57.0.23: https://github.com/expo/expo/pull/49634.
// Remove when the installed Expo SDK includes the Activity recreation fix.
{
nodeModulesPath: "node_modules/expo-modules-core",
patchPrefix: "expo-modules-core+",
},
{
nodeModulesPath: "packages/app/node_modules/react-native-enriched-markdown",
patchPrefix: "react-native-enriched-markdown+",