From eb93fda0605ea7f4964545624c755b4f5d339f5c Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:14:47 +0200 Subject: [PATCH] fix(app): keep image selections through Android Activity recreation Keep in-flight Expo activity-result callbacks in the live AppContext until the picker returns, then release the detached registration. Run the lifecycle regression tests with the Android module suite. --- packages/app/package.json | 2 +- patches/expo-modules-core+57.0.23.patch | 99 +++++++++++++++++++++++-- scripts/postinstall-patches.mjs | 3 +- 3 files changed, 97 insertions(+), 7 deletions(-) diff --git a/packages/app/package.json b/packages/app/package.json index b59ef19b73..37d4e7c674 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -40,7 +40,7 @@ "dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios", "build:apk": "npm --prefix ../.. run build:apk", "build:apk:arm64": "npm --prefix ../.. run build:apk:arm64", - "test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false" + "test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest :expo-modules-core:testReleaseUnitTest --tests expo.modules.kotlin.activityresult.AppContextActivityResultRegistryRecreationTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false" }, "dependencies": { "@codemirror/commands": "6.10.4", diff --git a/patches/expo-modules-core+57.0.23.patch b/patches/expo-modules-core+57.0.23.patch index bfcc8932a5..0deeaefda0 100644 --- a/patches/expo-modules-core+57.0.23.patch +++ b/patches/expo-modules-core+57.0.23.patch @@ -1,5 +1,5 @@ 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 +index a5b971e..84ba96f 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 @@ -10,7 +10,22 @@ index a5b971e..7ef1b59 100644 private const val TAG = "ActivityResultRegistry" -@@ -222,6 +223,7 @@ class AppContextActivityResultRegistry( +@@ -208,7 +209,13 @@ class AppContextActivityResultRegistry( + } + + Lifecycle.Event.ON_DESTROY -> { +- unregister(key) ++ if (launchedKeys.contains(key) && keyToCallbacksAndContract[key]?.mainCallback != null) { ++ // The AppContext and awaiting caller survive Activity recreation. ++ // Detach the old lifecycle, but keep the in-flight result callback. ++ keyToLifecycleContainers.remove(key)?.clearObservers() ++ } else { ++ unregister(key) ++ } + } + + else -> Unit +@@ -222,6 +229,7 @@ class AppContextActivityResultRegistry( return object : AppContextActivityResultLauncher() { override fun launch(input: I, callback: ActivityResultCallback) { val requestCode = keyToRequestCode[key] @@ -18,7 +33,7 @@ index a5b971e..7ef1b59 100644 ?: 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( +@@ -240,6 +248,39 @@ class AppContextActivityResultRegistry( } } @@ -58,12 +73,24 @@ index a5b971e..7ef1b59 100644 /** * Persist the state of the registry. */ +@@ -324,6 +365,11 @@ class AppContextActivityResultRegistry( + val input = keyToInputParam[key] as I + callbacksAndContract.mainCallback.onActivityResult(callbacksAndContract.contract.parseResult(input, resultCode, data)) + launchedKeys.remove(key) ++ // Release a completed registration whose Activity was destroyed. The ++ // retained launcher will register against the live Activity on its next use. ++ if (!keyToLifecycleContainers.containsKey(key)) { ++ unregister(key) ++ } + } else if (currentLifecycleState != null && currentLifecycleState.isAtLeast(Lifecycle.State.STARTED) && callbacksAndContract != null && launchedKeys.contains(key)) { + // 2. Activity has already started, so let's proceed with fallback callback scenario + @Suppress("UNCHECKED_CAST") 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 +index 0000000..71d740c --- /dev/null +++ b/node_modules/expo-modules-core/android/src/test/java/expo/modules/kotlin/activityresult/AppContextActivityResultRegistryRecreationTest.kt -@@ -0,0 +1,78 @@ +@@ -0,0 +1,140 @@ +package expo.modules.kotlin.activityresult + +import android.app.Activity @@ -71,6 +98,7 @@ index 0000000..89a8eb1 +import android.content.Intent +import androidx.appcompat.app.AppCompatActivity +import expo.modules.kotlin.providers.CurrentActivityProvider ++import org.junit.Assert.assertFalse +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test @@ -95,6 +123,67 @@ index 0000000..89a8eb1 + }.setup() + + @Test ++ fun inFlightResultReachesOriginalCallerAfterRepeatedActivityRecreation() { ++ var controller = createActivity() ++ val provider = ActivityProvider(controller.get()) ++ val registry = AppContextActivityResultRegistry(provider) ++ val results = mutableListOf() ++ val fallbackResults = mutableListOf() ++ val launcher = registry.register("image", controller.get(), contract) { _, result -> ++ fallbackResults.add(result) ++ } ++ ++ // Both selection and cancellation must settle the awaiting picker call. ++ listOf(Activity.RESULT_OK, Activity.RESULT_CANCELED, Activity.RESULT_OK).forEachIndexed { index, code -> ++ launcher.launch("image-$index") { results.add(it) } ++ val started = shadowOf(controller.get()).nextStartedActivityForResult ++ controller.pause().stop().destroy() ++ controller = createActivity() ++ provider.currentActivity = controller.get() ++ ++ assertTrue(registry.dispatchResult(started.requestCode, code, null)) ++ assertEquals(index + 1, results.size) ++ assertEquals("image-$index:$code", results.last()) ++ // A completed request from the destroyed Activity is no longer registered. ++ assertFalse(registry.dispatchResult(started.requestCode, code, null)) ++ } ++ ++ launcher.launch("next-pick") { results.add(it) } ++ val next = shadowOf(controller.get()).nextStartedActivityForResult ++ assertTrue(registry.dispatchResult(next.requestCode, Activity.RESULT_OK, null)) ++ assertEquals(listOf("image-0:-1", "image-1:0", "image-2:-1", "next-pick:-1"), results) ++ assertEquals(emptyList(), fallbackResults) ++ controller.pause().stop().destroy() ++ } ++ ++ @Test ++ fun restoredRegistryStillUsesFallbackWithoutTheOriginalAppContext() { ++ val previous = createActivity() ++ val provider = ActivityProvider(previous.get()) ++ val original = AppContextActivityResultRegistry(provider) ++ val results = mutableListOf() ++ val launcher = original.register("image", previous.get(), contract) { _, result -> ++ results.add("old-fallback:$result") ++ } ++ launcher.launch("restored-image") { results.add("old-caller:$it") } ++ val started = shadowOf(previous.get()).nextStartedActivityForResult ++ original.persistInstanceState(previous.get()) ++ previous.pause().stop().destroy() ++ ++ val replacement = createActivity() ++ provider.currentActivity = replacement.get() ++ val restored = AppContextActivityResultRegistry(provider) ++ restored.restoreInstanceState(replacement.get()) ++ restored.register("image", replacement.get(), contract) { _, result -> ++ results.add("fallback:$result") ++ } ++ ++ assertTrue(restored.dispatchResult(started.requestCode, Activity.RESULT_OK, null)) ++ assertEquals(listOf("fallback:restored-image:-1"), results) ++ replacement.pause().stop().destroy() ++ } ++ ++ @Test + fun retainedLauncherDeliversResultsAfterRepeatedActivityRecreation() { + var controller = createActivity() + val provider = ActivityProvider(controller.get()) diff --git a/scripts/postinstall-patches.mjs b/scripts/postinstall-patches.mjs index d82af94788..937f25a833 100644 --- a/scripts/postinstall-patches.mjs +++ b/scripts/postinstall-patches.mjs @@ -9,7 +9,8 @@ import { join, relative } from "node:path"; // 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. + // Also retain in-flight picker callbacks across same-process Activity recreation (#7557). + // Remove when the installed Expo SDK includes both Activity recreation fixes. { nodeModulesPath: "node_modules/expo-modules-core", patchPrefix: "expo-modules-core+",