diff --git a/docs/android.md b/docs/android.md index 9b83240c52..5a59eedaa4 100644 --- a/docs/android.md +++ b/docs/android.md @@ -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 diff --git a/patches/expo-modules-core+57.0.23.patch b/patches/expo-modules-core+57.0.23.patch new file mode 100644 index 0000000000..bfcc8932a5 --- /dev/null +++ b/patches/expo-modules-core+57.0.23.patch @@ -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() { + override fun launch(input: I, callback: ActivityResultCallback) { + 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 reregister( ++ key: String, ++ contract: AppContextActivityResultContract, ++ fallbackCallback: AppContextActivityResultFallbackCallback ++ ): 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 { ++ 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() ++ val launcher = registry.register("image", controller.get(), contract) { _, result -> ++ fallbackResults.add(result) ++ } ++ val results = mutableListOf() ++ ++ 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(), fallbackResults) ++ controller.pause().stop().destroy() ++ } ++ ++ @Test ++ fun retainedLauncherUsesReplacementThatResumesBeforePreviousActivityIsDestroyed() { ++ val previous = createActivity() ++ val provider = ActivityProvider(previous.get()) ++ val registry = AppContextActivityResultRegistry(provider) ++ val results = mutableListOf() ++ 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() ++ } ++} diff --git a/scripts/postinstall-patches.mjs b/scripts/postinstall-patches.mjs index 638c62de55..d82af94788 100644 --- a/scripts/postinstall-patches.mjs +++ b/scripts/postinstall-patches.mjs @@ -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+",