Repository: expo/expo
Pull request #35809: [updates] Migrate `IUpdatesController` to use coroutines
Author: login: alanjhughes
Labels: name: bot: suggestions
description: ExpoBot has some suggestions
name: bot: fingerprint changed
Created: 2025-04-01T09:42:26Z | Merged: 2025-04-08T18:55:11Z

# Why
Currently, we rely a lot on callbacks in updates. Reducing them will be a multi step process. This PR starts with the `IUpdatesController` interface.

# How
Convert the functions related to checking and fetching updates to suspend functions. I've also removed the use of `wait` and `notify` in favour of `CompletableDeferred`. Note, there is an upstream issue that is currently causing a failure in `ErrorRecovery.kt`. I've addressed it [here](https://github.com/facebook/react-native/pull/50400) but it won't be in RC4.

# Test Plan
Running bare-expo in release mode and CI



Diff:
diff --git a/packages/expo-updates/android/src/main/java/expo/modules/updates/DisabledUpdatesController.kt b/packages/expo-updates/android/src/main/java/expo/modules/updates/DisabledUpdatesController.kt
index b4a1a8501a7ab3..1820bbd996078e 100644
--- a/packages/expo-updates/android/src/main/java/expo/modules/updates/DisabledUpdatesController.kt
+++ b/packages/expo-updates/android/src/main/java/expo/modules/updates/DisabledUpdatesController.kt
@@ -11,13 +11,22 @@ import expo.modules.updates.events.IUpdatesEventManager
 import expo.modules.updates.events.UpdatesEventManager
 import expo.modules.updates.launcher.Launcher
 import expo.modules.updates.launcher.NoDatabaseLauncher
-import expo.modules.updates.logging.UpdatesErrorCode
 import expo.modules.updates.logging.UpdatesLogger
 import expo.modules.updates.procedures.RecreateReactContextProcedure
 import expo.modules.updates.statemachine.UpdatesStateMachine
 import expo.modules.updates.statemachine.UpdatesStateValue
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
 import java.io.File
 import java.lang.ref.WeakReference
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
 import kotlin.time.DurationUnit
 import kotlin.time.toDuration
 
@@ -34,6 +43,7 @@ class DisabledUpdatesController(
 ) : IUpdatesController {
   /** Keep the activity for [RecreateReactContextProcedure] to relaunch the app. */
   private var weakActivity: WeakReference<Activity>? = null
+  private val controllerScope = CoroutineScope(Dispatchers.IO)
 
   private val logger = UpdatesLogger(context.filesDir)
   override val eventManager: IUpdatesEventManager = UpdatesEventManager(logger)
@@ -55,18 +65,14 @@ class DisabledUpdatesController(
     }
 
   private var launcher: Launcher? = null
-  private var isLoaderTaskFinished = false
   override var updatesDirectory: File? = null
+  private val loaderTaskFinishedDeferred = CompletableDeferred<Unit>()
+  private val loaderTaskFinishedMutex = Mutex()
 
-  @get:Synchronized
   override val launchAssetFile: String?
     get() {
-      while (!isLoaderTaskFinished) {
-        try {
-          (this as java.lang.Object).wait()
-        } catch (e: InterruptedException) {
-          logger.error("Interrupted while waiting for launch asset file", e, UpdatesErrorCode.InitializationError)
-        }
+      runBlocking {
+        loaderTaskFinishedDeferred.await()
       }
       return launcher?.launchAssetFile
     }
@@ -121,45 +127,40 @@ class DisabledUpdatesController(
     )
   }
 
-  override fun relaunchReactApplicationForModule(callback: IUpdatesController.ModuleCallback<Unit>) {
+  override suspend fun relaunchReactApplicationForModule() = suspendCancellableCoroutine { continuation ->
     val procedure = RecreateReactContextProcedure(
       context,
       weakActivity,
       object : Launcher.LauncherCallback {
         override fun onFailure(e: Exception) {
-          callback.onFailure(e.toCodedException())
+          continuation.resumeWithException(e.toCodedException())
         }
 
         override fun onSuccess() {
-          callback.onSuccess(Unit)
+          continuation.resume(Unit)
         }
       }
     )
     stateMachine.queueExecution(procedure)
   }
 
-  override fun checkForUpdate(
-    callback: IUpdatesController.ModuleCallback<IUpdatesController.CheckForUpdateResult>
-  ) {
-    callback.onFailure(UpdatesDisabledException("Updates.checkForUpdateAsync() is not supported when expo-updates is not enabled."))
+  override suspend fun checkForUpdate(): IUpdatesController.CheckForUpdateResult {
+    throw UpdatesDisabledException("Updates.checkForUpdateAsync() is not supported when expo-updates is not enabled.")
   }
 
-  override fun fetchUpdate(
-    callback: IUpdatesController.ModuleCallback<IUpdatesController.FetchUpdateResult>
-  ) {
-    callback.onFailure(UpdatesDisabledException("Updates.fetchUpdateAsync() is not supported when expo-updates is not enabled."))
+  override suspend fun fetchUpdate(): IUpdatesController.FetchUpdateResult {
+    throw UpdatesDisabledException("Updates.fetchUpdateAsync() is not supported when expo-updates is not enabled.")
   }
 
-  override fun getExtraParams(callback: IUpdatesController.ModuleCallback<Bundle>) {
-    callback.onFailure(UpdatesDisabledException("Updates.getExtraParamsAsync() is not supported when expo-updates is not enabled."))
+  override suspend fun getExtraParams(): Bundle {
+    throw UpdatesDisabledException("Updates.getExtraParamsAsync() is not supported when expo-updates is not enabled.")
   }
 
-  override fun setExtraParam(
+  override suspend fun setExtraParam(
     key: String,
-    value: String?,
-    callback: IUpdatesController.ModuleCallback<Unit>
+    value: String?
   ) {
-    callback.onFailure(UpdatesDisabledException("Updates.setExtraParamAsync() is not supported when expo-updates is not enabled."))
+    throw UpdatesDisabledException("Updates.setExtraParamAsync() is not supported when expo-updates is not enabled.")
   }
 
   override fun setUpdateURLAndRequestHeadersOverride(configOverride: UpdatesConfigurationOverride?) {
@@ -168,11 +169,16 @@ class DisabledUpdatesController(
 
   @Synchronized
   private fun notifyController() {
-    if (launcher == null) {
-      throw AssertionError("UpdatesController.notifyController was called with a null launcher, which is an error. This method should only be called when an update is ready to launch.")
+    controllerScope.launch {
+      loaderTaskFinishedMutex.withLock {
+        if (!loaderTaskFinishedDeferred.isCompleted) {
+          if (launcher == null) {
+            throw AssertionError("UpdatesController.notifyController was called with a null launcher, which is an error. This method should only be called when an update is ready to launch.")
+          }
+          loaderTaskFinishedDeferred.complete(Unit)
+        }
+      }
     }
-    isLoaderTaskFinished = true
-    (this as java.lang.Object).notify()
   }
 
   companion object {
diff --git a/packages/expo-updates/android/src/main/java/expo/modules/updates/EnabledUpdatesController.kt b/packages/expo-updates/android/src/main/java/expo/modules/updates/EnabledUpdatesController.kt
index 08a134f18ed9eb..c9128503710187 100644
--- a/packages/expo-updates/android/src/main/java/expo/modules/updates/EnabledUpdatesController.kt
+++ b/packages/expo-updates/android/src/main/java/expo/modules/updates/EnabledUpdatesController.kt
@@ -26,11 +26,18 @@ import expo.modules.updates.procedures.StartupProcedure
 import expo.modules.updates.selectionpolicy.SelectionPolicyFactory
 import expo.modules.updates.statemachine.UpdatesStateMachine
 import expo.modules.updates.statemachine.UpdatesStateValue
+import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
 import java.io.File
 import java.lang.ref.WeakReference
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
 import kotlin.time.DurationUnit
 import kotlin.time.toDuration
 
@@ -52,8 +59,10 @@ class EnabledUpdatesController(
     updatesConfiguration.getRuntimeVersion()
   )
   private val stateMachine = UpdatesStateMachine(logger, eventManager, UpdatesStateValue.entries.toSet())
-  private val databaseHolder = DatabaseHolder(UpdatesDatabase.getInstance(context))
   private val controllerScope = CoroutineScope(Dispatchers.IO)
+  private val databaseHolder = DatabaseHolder(UpdatesDatabase.getInstance(context, Dispatchers.IO))
+  private val startupFinishedDeferred = CompletableDeferred<Unit>()
+  private val startupFinishedMutex = Mutex()
 
   private fun purgeUpdatesLogsOlderThanOneDay() {
     UpdatesLogReader(context.filesDir).purgeLogEntries {
@@ -70,9 +79,15 @@ class EnabledUpdatesController(
 
   @Synchronized
   private fun onStartupProcedureFinished() {
+    controllerScope.launch {
+      startupFinishedMutex.withLock {
+        if (!startupFinishedDeferred.isCompleted) {
+          startupFinishedDeferred.complete(Unit)
+        }
+      }
+    }
     isStartupFinished = true
     startupEndTimeMillis = System.currentTimeMillis()
-    (this@EnabledUpdatesController as java.lang.Object).notify()
   }
 
   private val startupProcedure = StartupProcedure(
@@ -103,18 +118,14 @@ class EnabledUpdatesController(
   private val localAssetFiles
     get() = startupProcedure.localAssetFiles
 
-  @get:Synchronized
   override val launchAssetFile: String?
     get() {
-      while (!isStartupFinished) {
-        try {
-          (this as java.lang.Object).wait()
-        } catch (e: InterruptedException) {
-          logger.error("Interrupted while waiting for launch asset file", e, UpdatesErrorCode.InitializationError)
-        }
+      runBlocking {
+        startupFinishedDeferred.await()
       }
       return startupProcedure.launchAssetFile
     }
+
   override val bundleAssetName: String?
     get() = startupProcedure.bundleAssetName
 
@@ -187,41 +198,41 @@ class EnabledUpdatesController(
     )
   }
 
-  override fun relaunchReactApplicationForModule(callback: IUpdatesController.ModuleCallback<Unit>) {
+  override suspend fun relaunchReactApplicationForModule() = suspendCancellableCoroutine { continuation ->
     val canRelaunch = launchedUpdate != null
     if (!canRelaunch) {
-      callback.onFailure(object : CodedException("ERR_UPDATES_RELOAD", "Cannot relaunch without a launched update.", null) {})
+      continuation.resumeWithException(object : CodedException("ERR_UPDATES_RELOAD", "Cannot relaunch without a launched update.", null) {})
     } else {
       relaunchReactApplication(
         shouldRunReaper = true,
         object : LauncherCallback {
           override fun onFailure(e: Exception) {
-            callback.onFailure(e.toCodedException())
+            continuation.resumeWithException(e.toCodedException())
           }
 
           override fun onSuccess() {
-            callback.onSuccess(Unit)
+            continuation.resume(Unit)
           }
         }
       )
     }
   }
 
-  override fun checkForUpdate(callback: IUpdatesController.ModuleCallback<IUpdatesController.CheckForUpdateResult>) {
+  override suspend fun checkForUpdate() = suspendCancellableCoroutine { continuation ->
     val procedure = CheckForUpdateProcedure(context, updatesConfiguration, databaseHolder, logger, fileDownloader, selectionPolicy, launchedUpdate) {
-      callback.onSuccess(it)
+      continuation.resume(it)
     }
     stateMachine.queueExecution(procedure)
   }
 
-  override fun fetchUpdate(callback: IUpdatesController.ModuleCallback<IUpdatesController.FetchUpdateResult>) {
+  override suspend fun fetchUpdate() = suspendCancellableCoroutine { continuation ->
     val procedure = FetchUpdateProcedure(context, updatesConfiguration, logger, databaseHolder, updatesDirectory, fileDownloader, selectionPolicy, launchedUpdate) {
-      callback.onSuccess(it)
+      continuation.resume(it)
     }
     stateMachine.queueExecution(procedure)
   }
 
-  override fun getExtraParams(callback: IUpdatesController.ModuleCallback<Bundle>) {
+  override suspend fun getExtraParams() = suspendCancellableCoroutine { continuation ->
     controllerScope.launch {
       try {
         val result = ManifestMetadata.getExtraParams(
@@ -239,17 +250,17 @@ class EnabledUpdatesController(
             }
           }
         }
-        callback.onSuccess(resultMap)
+        continuation.resume(resultMap)
       } catch (e: Exception) {
         databaseHolder.releaseDatabase()
-        callback.onFailure(e.toCodedException())
+        continuation.resumeWithException(e.toCodedException())
       }
     }
   }
 
-  override fun setExtraParam(key: String, value: String?, callback: IUpdatesController.ModuleCallback<Unit>) {
+  override suspend fun setExtraParam(key: String, value: String?) = suspendCancellableCoroutine { continuation ->
     controllerScope.launch {
-      try {
+      runCatching {
         ManifestMetadata.setExtraParam(
           databaseHolder.database,
           updatesConfiguration,
@@ -257,10 +268,10 @@ class EnabledUpdatesController(
           value
         )
         databaseHolder.releaseDatabase()
-        callback.onSuccess(Unit)
-      } catch (e: Exception) {
+        continuation.resume(Unit)
+      }.onFailure { e ->
         databaseHolder.releaseDatabase()
-        callback.onFailure(e.toCodedException())
+        continuation.resumeWithException(e.toCodedException())
       }
     }
   }
diff --git a/packages/expo-updates/android/src/main/java/expo/modules/updates/IUpdatesController.kt b/packages/expo-updates/android/src/main/java/expo/modules/updates/IUpdatesController.kt
index 74acd88d8dc785..073ef9906c8e30 100644
--- a/packages/expo-updates/android/src/main/java/expo/modules/updates/IUpdatesController.kt
+++ b/packages/expo-updates/android/src/main/java/expo/modules/updates/IUpdatesController.kt
@@ -3,7 +3,6 @@ package expo.modules.updates
 import android.os.Bundle
 import com.facebook.react.bridge.ReactContext
 import com.facebook.react.devsupport.interfaces.DevSupportManager
-import expo.modules.kotlin.exception.CodedException
 import expo.modules.updates.db.entity.AssetEntity
 import expo.modules.updates.db.entity.UpdateEntity
 import expo.modules.updates.events.IUpdatesEventManager
@@ -63,11 +62,6 @@ interface IUpdatesController {
    */
   fun start()
 
-  interface ModuleCallback<T> {
-    fun onSuccess(result: T)
-    fun onFailure(exception: CodedException)
-  }
-
   data class UpdatesModuleConstants(
     val launchedUpdate: UpdateEntity?,
     val launchDuration: Duration?,
@@ -130,8 +124,6 @@ interface IUpdatesController {
   }
   fun getConstantsForModule(): UpdatesModuleConstants
 
-  fun relaunchReactApplicationForModule(callback: ModuleCallback<Unit>)
-
   sealed class CheckForUpdateResult(private val status: Status) {
     private enum class Status {
       NO_UPDATE_AVAILABLE,
@@ -145,7 +137,6 @@ interface IUpdatesController {
     class RollBackToEmbedded(val commitTime: Date) : CheckForUpdateResult(Status.ROLL_BACK_TO_EMBEDDED)
     class ErrorResult(val error: Exception) : CheckForUpdateResult(Status.ERROR)
   }
-  fun checkForUpdate(callback: ModuleCallback<CheckForUpdateResult>)
 
   sealed class FetchUpdateResult(private val status: Status) {
     private enum class Status {
@@ -160,11 +151,16 @@ interface IUpdatesController {
     class RollBackToEmbedded : FetchUpdateResult(Status.ROLL_BACK_TO_EMBEDDED)
     class ErrorResult(val error: Exception) : FetchUpdateResult(Status.ERROR)
   }
-  fun fetchUpdate(callback: ModuleCallback<FetchUpdateResult>)
 
-  fun getExtraParams(callback: ModuleCallback<Bundle>)
+  suspend fun relaunchReactApplicationForModule()
+
+  suspend fun checkForUpdate(): CheckForUpdateResult
+
+  suspend fun fetchUpdate(): FetchUpdateResult
+
+  suspend fun getExtraParams(): Bundle
 
-  fun setExtraParam(key: String, value: String?, callback: ModuleCallback<Unit>)
+  suspend fun setExtraParam(key: String, value: String?)
 
   fun setUpdateURLAndRequestHeadersOverride(configOverride: UpdatesConfigurationOverride?)
 }
diff --git a/packages/expo-updates/android/src/main/java/expo/modules/updates/UpdatesDevLauncherController.kt b/packages/expo-updates/android/src/main/java/expo/modules/updates/UpdatesDevLauncherController.kt
index d1ab9a92d4844e..ab4b3c206ed9fe 100644
--- a/packages/expo-updates/android/src/main/java/expo/modules/updates/UpdatesDevLauncherController.kt
+++ b/packages/expo-updates/android/src/main/java/expo/modules/updates/UpdatesDevLauncherController.kt
@@ -29,9 +29,12 @@ import expo.modules.updates.selectionpolicy.SelectionPolicyFactory
 import expo.modules.updates.statemachine.UpdatesStateContext
 import expo.modules.updatesinterface.UpdatesInterface
 import expo.modules.updatesinterface.UpdatesInterfaceCallbacks
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.suspendCancellableCoroutine
 import org.json.JSONObject
 import java.io.File
 import java.lang.ref.WeakReference
+import kotlin.coroutines.resume
 
 /**
  * Main entry point to expo-updates in development builds with expo-dev-client. Similar to EnabledUpdatesController
@@ -59,7 +62,7 @@ class UpdatesDevLauncherController(
   private var previousUpdatesConfiguration: UpdatesConfiguration? = null
   private var updatesConfiguration: UpdatesConfiguration? = initialUpdatesConfiguration
 
-  private val databaseHolder = DatabaseHolder(UpdatesDatabase.getInstance(context))
+  private val databaseHolder = DatabaseHolder(UpdatesDatabase.getInstance(context, Dispatchers.IO))
 
   private var mSelectionPolicy: SelectionPolicy? = null
   private var defaultSelectionPolicy: SelectionPolicy = SelectionPolicyFactory.createFilterAwarePolicy(
@@ -67,12 +70,15 @@ class UpdatesDevLauncherController(
   )
   private val selectionPolicy: SelectionPolicy
     get() = mSelectionPolicy ?: defaultSelectionPolicy
+
   private fun setNextSelectionPolicy(selectionPolicy: SelectionPolicy?) {
     mSelectionPolicy = selectionPolicy
   }
+
   private fun resetSelectionPolicyToDefault() {
     mSelectionPolicy = null
   }
+
   private fun setDefaultSelectionPolicy(selectionPolicy: SelectionPolicy) {
     defaultSelectionPolicy = selectionPolicy
   }
@@ -193,7 +199,8 @@ class UpdatesDevLauncherController(
           )
         }
 
-        val update = updateResponse.manifestUpdateResponsePart?.update ?: return Loader.OnUpdateResponseLoadedResult(shouldDownloadManifestIfPresentInResponse = false)
+        val update = updateResponse.manifestUpdateResponsePart?.update
+          ?: return Loader.OnUpdateResponseLoadedResult(shouldDownloadManifestIfPresentInResponse = false)
         return Loader.OnUpdateResponseLoadedResult(shouldDownloadManifestIfPresentInResponse = callback.onManifestLoaded(update.manifest.getRawJson()))
       }
     })
@@ -220,9 +227,11 @@ class UpdatesDevLauncherController(
       UpdatesConfigurationValidationResult.INVALID_NOT_ENABLED -> {
         throw Exception("Failed to load update: UpdatesConfiguration object is not enabled")
       }
+
       UpdatesConfigurationValidationResult.INVALID_MISSING_URL -> {
         throw Exception("Failed to load update: UpdatesConfiguration object must include a valid update URL")
       }
+
       UpdatesConfigurationValidationResult.INVALID_MISSING_RUNTIME_VERSION -> {
         throw Exception("Failed to load update: UpdatesConfiguration object must include a valid runtime version")
       }
@@ -335,35 +344,28 @@ class UpdatesDevLauncherController(
     )
   }
 
-  override fun relaunchReactApplicationForModule(
-    callback: IUpdatesController.ModuleCallback<Unit>
-  ) {
+  override suspend fun relaunchReactApplicationForModule() = suspendCancellableCoroutine { continuation ->
     this.updatesInterfaceCallbacks?.get()?.onRequestRelaunch()
-    callback.onSuccess(Unit)
+    continuation.resume(Unit)
   }
 
-  override fun checkForUpdate(
-    callback: IUpdatesController.ModuleCallback<IUpdatesController.CheckForUpdateResult>
-  ) {
-    callback.onFailure(NotAvailableInDevClientException("Updates.checkForUpdateAsync() is not supported in development builds."))
+  override suspend fun checkForUpdate(): IUpdatesController.CheckForUpdateResult {
+    throw NotAvailableInDevClientException("Updates.checkForUpdateAsync() is not supported in development builds.")
   }
 
-  override fun fetchUpdate(
-    callback: IUpdatesController.ModuleCallback<IUpdatesController.FetchUpdateResult>
-  ) {
-    callback.onFailure(NotAvailableInDevClientException("Updates.fetchUpdateAsync() is not supported in development builds."))
+  override suspend fun fetchUpdate(): IUpdatesController.FetchUpdateResult {
+    throw NotAvailableInDevClientException("Updates.fetchUpdateAsync() is not supported in development builds.")
   }
 
-  override fun getExtraParams(callback: IUpdatesController.ModuleCallback<Bundle>) {
-    callback.onFailure(NotAvailableInDevClientException("Updates.getExtraParamsAsync() is not supported in development builds."))
+  override suspend fun getExtraParams(): Bundle {
+    throw NotAvailableInDevClientException("Updates.getExtraParamsAsync() is not supported in development builds.")
   }
 
-  override fun setExtraParam(
+  override suspend fun setExtraParam(
     key: String,
-    value: String?,
-    callback: IUpdatesController.ModuleCallback<Unit>
+    value: String?
   ) {
-    callback.onFailure(NotAvailableInDevClientException("Updates.setExtraParamAsync() is not supported in development builds."))
+    throw NotAvailableInDevClientException("Updates.setExtraParamAsync() is not supported in development builds.")
   }
 
   override fun setUpdateURLAndRequestHeadersOverride(configOverride: UpdatesConfigurationOverride?) {
diff --git a/packages/expo-updates/android/src/main/java/expo/modules/updates/UpdatesModule.kt b/packages/expo-updates/android/src/main/java/expo/modules/updates/UpdatesModule.kt
index 5543b5a28560c1..06794ba10c5062 100644
--- a/packages/expo-updates/android/src/main/java/expo/modules/updates/UpdatesModule.kt
+++ b/packages/expo-updates/android/src/main/java/expo/modules/updates/UpdatesModule.kt
@@ -18,9 +18,7 @@ import expo.modules.updates.logging.UpdatesLogEntry
 import expo.modules.updates.logging.UpdatesLogReader
 import expo.modules.updates.logging.UpdatesLogger
 import expo.modules.updates.statemachine.UpdatesStateContext
-import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.cancel
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import java.io.File
@@ -43,8 +41,6 @@ class UpdatesModule : Module(), IUpdatesEventManagerObserver {
   private val context: Context
     get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
 
-  private val moduleScope = CoroutineScope(Dispatchers.IO)
-
   override fun definition() = ModuleDefinition {
     Name("ExpoUpdates")
 
@@ -68,143 +64,129 @@ class UpdatesModule : Module(), IUpdatesEventManagerObserver {
     }
 
     AsyncFunction("reload") { promise: Promise ->
-      UpdatesController.instance.relaunchReactApplicationForModule(
-        object : IUpdatesController.ModuleCallback<Unit> {
-          override fun onSuccess(result: Unit) {
-            promise.resolve(null)
-          }
-
-          override fun onFailure(exception: CodedException) {
-            promise.reject(exception)
-          }
+      appContext.modulesQueue.launch {
+        runCatching {
+          UpdatesController.instance.relaunchReactApplicationForModule()
+          promise.resolve()
+        }.onFailure {
+          promise.reject(it as CodedException)
         }
-      )
+      }
     }
 
     AsyncFunction("checkForUpdateAsync") { promise: Promise ->
-      UpdatesController.instance.checkForUpdate(
-        object : IUpdatesController.ModuleCallback<IUpdatesController.CheckForUpdateResult> {
-          override fun onSuccess(result: IUpdatesController.CheckForUpdateResult) {
-            when (result) {
-              is IUpdatesController.CheckForUpdateResult.ErrorResult -> {
-                promise.reject("ERR_UPDATES_CHECK", "Failed to check for update", result.error)
-              }
-
-              is IUpdatesController.CheckForUpdateResult.NoUpdateAvailable -> {
-                promise.resolve(
-                  Bundle().apply {
-                    putBoolean("isRollBackToEmbedded", false)
-                    putBoolean("isAvailable", false)
-                    putString("reason", result.reason.value)
-                  }
-                )
-              }
+      appContext.modulesQueue.launch {
+        runCatching {
+          when (val result = UpdatesController.instance.checkForUpdate()) {
+            is IUpdatesController.CheckForUpdateResult.ErrorResult -> {
+              promise.reject("ERR_UPDATES_CHECK", "Failed to check for update", result.error)
+            }
 
-              is IUpdatesController.CheckForUpdateResult.RollBackToEmbedded -> {
-                promise.resolve(
-                  Bundle().apply {
-                    putBoolean("isRollBackToEmbedded", true)
-                    putBoolean("isAvailable", false)
-                  }
-                )
-              }
+            is IUpdatesController.CheckForUpdateResult.NoUpdateAvailable -> {
+              promise.resolve(
+                Bundle().apply {
+                  putBoolean("isRollBackToEmbedded", false)
+                  putBoolean("isAvailable", false)
+                  putString("reason", result.reason.value)
+                }
+              )
+            }
 
-              is IUpdatesController.CheckForUpdateResult.UpdateAvailable -> {
-                promise.resolve(
-                  Bundle().apply {
-                    putBoolean("isRollBackToEmbedded", false)
-                    putBoolean("isAvailable", true)
-                    putString(
-                      "manifestString",
-                      result.update.manifest.toString()
-                    )
-                  }
-                )
-              }
+            is IUpdatesController.CheckForUpdateResult.RollBackToEmbedded -> {
+              promise.resolve(
+                Bundle().apply {
+                  putBoolean("isRollBackToEmbedded", true)
+                  putBoolean("isAvailable", false)
+                }
+              )
             }
-          }
 
-          override fun onFailure(exception: CodedException) {
-            promise.reject(exception)
+            is IUpdatesController.CheckForUpdateResult.UpdateAvailable -> {
+              promise.resolve(
+                Bundle().apply {
+                  putBoolean("isRollBackToEmbedded", false)
+                  putBoolean("isAvailable", true)
+                  putString(
+                    "manifestString",
+                    result.update.manifest.toString()
+                  )
+                }
+              )
+            }
           }
+        }.onFailure {
+          promise.reject(it as CodedException)
         }
-      )
+      }
     }
 
     AsyncFunction("fetchUpdateAsync") { promise: Promise ->
-      UpdatesController.instance.fetchUpdate(
-        object : IUpdatesController.ModuleCallback<IUpdatesController.FetchUpdateResult> {
-          override fun onSuccess(result: IUpdatesController.FetchUpdateResult) {
-            when (result) {
-              is IUpdatesController.FetchUpdateResult.ErrorResult -> {
-                promise.reject("ERR_UPDATES_FETCH", "Failed to download new update", result.error)
-              }
-
-              is IUpdatesController.FetchUpdateResult.Failure -> {
-                promise.resolve(
-                  Bundle().apply {
-                    putBoolean("isRollBackToEmbedded", false)
-                    putBoolean("isNew", false)
-                  }
-                )
-              }
+      appContext.modulesQueue.launch {
+        runCatching {
+          when (val result = UpdatesController.instance.fetchUpdate()) {
+            is IUpdatesController.FetchUpdateResult.ErrorResult -> {
+              promise.reject("ERR_UPDATES_FETCH", "Failed to download new update", result.error)
+            }
 
-              is IUpdatesController.FetchUpdateResult.RollBackToEmbedded -> {
-                promise.resolve(
-                  Bundle().apply {
-                    putBoolean("isRollBackToEmbedded", true)
-                    putBoolean("isNew", false)
-                  }
-                )
-              }
+            is IUpdatesController.FetchUpdateResult.Failure -> {
+              promise.resolve(
+                Bundle().apply {
+                  putBoolean("isRollBackToEmbedded", false)
+                  putBoolean("isNew", false)
+                }
+              )
+            }
 
-              is IUpdatesController.FetchUpdateResult.Success -> {
-                promise.resolve(
-                  Bundle().apply {
-                    putBoolean("isRollBackToEmbedded", false)
-                    putBoolean("isNew", true)
-                    putString("manifestString", result.update.manifest.toString())
-                  }
-                )
-              }
+            is IUpdatesController.FetchUpdateResult.RollBackToEmbedded -> {
+              promise.resolve(
+                Bundle().apply {
+                  putBoolean("isRollBackToEmbedded", true)
+                  putBoolean("isNew", false)
+                }
+              )
             }
-          }
 
-          override fun onFailure(exception: CodedException) {
-            promise.reject(exception)
+            is IUpdatesController.FetchUpdateResult.Success -> {
+              promise.resolve(
+                Bundle().apply {
+                  putBoolean("isRollBackToEmbedded", false)
+                  putBoolean("isNew", true)
+                  putString("manifestString", result.update.manifest.toString())
+                }
+              )
+            }
           }
+        }.onFailure {
+          promise.reject(it as CodedException)
         }
-      )
+      }
     }
 
     AsyncFunction("getExtraParamsAsync") { promise: Promise ->
       logger.debug("Called getExtraParamsAsync")
-      UpdatesController.instance.getExtraParams(object : IUpdatesController.ModuleCallback<Bundle> {
-        override fun onSuccess(result: Bundle) {
+      appContext.modulesQueue.launch {
+        runCatching {
+          val result = UpdatesController.instance.getExtraParams()
           promise.resolve(result)
+        }.onFailure {
+          promise.reject(it as CodedException)
         }
-
-        override fun onFailure(exception: CodedException) {
-          promise.reject(exception)
-        }
-      })
+      }
     }
 
     AsyncFunction("setExtraParamAsync") { key: String, value: String?, promise: Promise ->
       logger.debug("Called setExtraParamAsync with key = $key, value = $value")
-      UpdatesController.instance.setExtraParam(
-        key,
-        value,
-        object : IUpdatesController.ModuleCallback<Unit> {
-          override fun onSuccess(result: Unit) {
-            promise.resolve(null)
-          }
-
-          override fun onFailure(exception: CodedException) {
-            promise.reject(exception)
-          }
+      appContext.modulesQueue.launch {
+        runCatching {
+          UpdatesController.instance.setExtraParam(
+            key,
+            value
+          )
+          promise.resolve()
+        }.onFailure {
+          promise.reject(it as CodedException)
         }
-      )
+      }
     }
 
     AsyncFunction("readLogEntriesAsync") Coroutine { maxAge: Long ->
@@ -212,7 +194,7 @@ class UpdatesModule : Module(), IUpdatesEventManagerObserver {
     }
 
     AsyncFunction("clearLogEntriesAsync") { promise: Promise ->
-      moduleScope.launch {
+      appContext.modulesQueue.launch {
         clearLogEntries(context.filesDir) { error ->
           if (error != null) {
             promise.reject(
@@ -230,43 +212,36 @@ class UpdatesModule : Module(), IUpdatesEventManagerObserver {
     Function("setUpdateURLAndRequestHeadersOverride") { configOverride: UpdatesConfigurationOverrideParam? ->
       UpdatesController.instance.setUpdateURLAndRequestHeadersOverride(configOverride?.toUpdatesConfigurationOverride())
     }
-
-    OnDestroy {
-      try {
-        moduleScope.cancel()
-      } catch (e: IllegalStateException) {
-        logger.error("The scope does not have a job in it", e)
-      }
-    }
   }
 
   companion object {
     private val TAG = UpdatesModule::class.java.simpleName
 
-    internal suspend fun readLogEntries(filesDirectory: File, maxAge: Long) = withContext(Dispatchers.IO) {
-      val reader = UpdatesLogReader(filesDirectory)
-      val date = Date()
-      val epoch = Date(date.time - maxAge)
-      reader.getLogEntries(epoch)
-        .mapNotNull { UpdatesLogEntry.create(it) }
-        .map { entry ->
-          Bundle().apply {
-            putLong("timestamp", entry.timestamp)
-            putString("message", entry.message)
-            putString("code", entry.code)
-            putString("level", entry.level)
-            if (entry.updateId != null) {
-              putString("updateId", entry.updateId)
-            }
-            if (entry.assetId != null) {
-              putString("assetId", entry.assetId)
-            }
-            if (entry.stacktrace != null) {
-              putStringArray("stacktrace", entry.stacktrace.toTypedArray())
+    internal suspend fun readLogEntries(filesDirectory: File, maxAge: Long) =
+      withContext(Dispatchers.IO) {
+        val reader = UpdatesLogReader(filesDirectory)
+        val date = Date()
+        val epoch = Date(date.time - maxAge)
+        reader.getLogEntries(epoch)
+          .mapNotNull { UpdatesLogEntry.create(it) }
+          .map { entry ->
+            Bundle().apply {
+              putLong("timestamp", entry.timestamp)
+              putString("message", entry.message)
+              putString("code", entry.code)
+              putString("level", entry.level)
+              if (entry.updateId != null) {
+                putString("updateId", entry.updateId)
+              }
+              if (entry.assetId != null) {
+                putString("assetId", entry.assetId)
+              }
+              if (entry.stacktrace != null) {
+                putStringArray("stacktrace", entry.stacktrace.toTypedArray())
+              }
             }
           }
-        }
-    }
+      }
 
     internal suspend fun clearLogEntries(filesDirectory: File, completionHandler: (_: Exception?) -> Unit) {
       val reader = UpdatesLogReader(filesDirectory)
diff --git a/packages/expo-updates/android/src/main/java/expo/modules/updates/db/UpdatesDatabase.kt b/packages/expo-updates/android/src/main/java/expo/modules/updates/db/UpdatesDatabase.kt
index c655c0403860a4..f516184a6446f0 100644
--- a/packages/expo-updates/android/src/main/java/expo/modules/updates/db/UpdatesDatabase.kt
+++ b/packages/expo-updates/android/src/main/java/expo/modules/updates/db/UpdatesDatabase.kt
@@ -14,6 +14,8 @@ import expo.modules.updates.db.entity.AssetEntity
 import expo.modules.updates.db.entity.JSONDataEntity
 import expo.modules.updates.db.entity.UpdateAssetEntity
 import expo.modules.updates.db.entity.UpdateEntity
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.asExecutor
 import java.util.*
 
 /**
@@ -53,27 +55,38 @@ abstract class UpdatesDatabase : RoomDatabase() {
   abstract fun jsonDataDao(): JSONDataDao?
 
   companion object {
-    private var instance: UpdatesDatabase? = null
-
     private const val DB_NAME = "updates.db"
 
-    @JvmStatic @Synchronized
-    fun getInstance(context: Context): UpdatesDatabase {
-      if (instance == null) {
-        instance = Room.databaseBuilder(context, UpdatesDatabase::class.java, DB_NAME)
-          .addMigrations(MIGRATION_4_5)
-          .addMigrations(MIGRATION_5_6)
-          .addMigrations(MIGRATION_6_7)
-          .addMigrations(MIGRATION_7_8)
-          .addMigrations(MIGRATION_8_9)
-          .addMigrations(MIGRATION_9_10)
-          .addMigrations(MIGRATION_10_11)
-          .addMigrations(MIGRATION_11_12)
-          .fallbackToDestructiveMigration()
+    @Volatile
+    private var INSTANCE: UpdatesDatabase? = null
+
+    fun getInstance(context: Context, dispatcher: CoroutineDispatcher?): UpdatesDatabase {
+      return INSTANCE ?: synchronized(this) {
+        val instance = Room.databaseBuilder(
+          context.applicationContext,
+          UpdatesDatabase::class.java,
+          DB_NAME
+        ).apply {
+          if (dispatcher != null) {
+            setQueryExecutor(dispatcher.asExecutor())
+          }
+        }.addMigrations(
+          MIGRATION_4_5,
+          MIGRATION_5_6,
+          MIGRATION_6_7,
+          MIGRATION_7_8,
+          MIGRATION_8_9,
+          MIGRATION_9_10,
+          MIGRATION_10_11,
+          MIGRATION_11_12
+        )
           .allowMainThreadQueries()
+          .fallbackToDestructiveMigration()
           .build()
+
+        INSTANCE = instance
+        instance
       }
-      return instance!!
     }
 
     private fun SupportSQLiteDatabase.runInTransaction(block: SupportSQLiteDatabase.() -> Unit) {
@@ -90,13 +103,7 @@ abstract class UpdatesDatabase : RoomDatabase() {
       // https://www.sqlite.org/lang_altertable.html#otheralter
       try {
         execSQL("PRAGMA foreign_keys=OFF")
-        beginTransaction()
-        try {
-          block()
-          setTransactionSuccessful()
-        } finally {
-          endTransaction()
-        }
+        runInTransaction(block)
       } finally {
         execSQL("PRAGMA foreign_keys=ON")
       }


Reviews:
user: login: alanjhughes
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-01T09:55:24Z
user: login: Kudo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T16:32:30Z
user: login: alanjhughes
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-01T18:21:10Z
user: login: alanjhughes
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-01T18:30:15Z
user: login: Kudo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T04:53:44Z
user: login: Kudo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T05:01:01Z
user: login: alanjhughes
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-02T05:20:21Z
user: login: Kudo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T07:25:07Z
user: login: Kudo
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T07:25:32Z
user: login: wschurman
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-03T00:11:59Z
user: login: wschurman
body: Very cool.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-03T00:12:08Z

Review comments:
diff_hunk: @@ -53,27 +55,38 @@ abstract class UpdatesDatabase : RoomDatabase() {
   abstract fun jsonDataDao(): JSONDataDao?
 
   companion object {
-    private var instance: UpdatesDatabase? = null
-
     private const val DB_NAME = "updates.db"
 
-    @JvmStatic @Synchronized
-    fun getInstance(context: Context): UpdatesDatabase {
-      if (instance == null) {
-        instance = Room.databaseBuilder(context, UpdatesDatabase::class.java, DB_NAME)
-          .addMigrations(MIGRATION_4_5)
-          .addMigrations(MIGRATION_5_6)
-          .addMigrations(MIGRATION_6_7)
-          .addMigrations(MIGRATION_7_8)
-          .addMigrations(MIGRATION_8_9)
-          .addMigrations(MIGRATION_9_10)
-          .addMigrations(MIGRATION_10_11)
-          .addMigrations(MIGRATION_11_12)
-          .fallbackToDestructiveMigration()
+    @Volatile
+    private var INSTANCE: UpdatesDatabase? = null
+
+    fun getInstance(context: Context, dispatcher: CoroutineDispatcher?): UpdatesDatabase {
+      return INSTANCE ?: synchronized(this) {
+        val instance = Room.databaseBuilder(
+          context.applicationContext,
+          UpdatesDatabase::class.java,
+          DB_NAME
+        ).apply {
+          if (dispatcher != null) {
+            setQueryExecutor(dispatcher.asExecutor())
+          }
+        }.addMigrations(
+          MIGRATION_4_5,
+          MIGRATION_5_6,
+          MIGRATION_6_7,
+          MIGRATION_7_8,
+          MIGRATION_8_9,
+          MIGRATION_9_10,
+          MIGRATION_10_11,
+          MIGRATION_11_12
+        )
           .allowMainThreadQueries()
path: packages/expo-updates/android/src/main/java/expo/modules/updates/db/UpdatesDatabase.kt
user: login: alanjhughes
body: We'll eventually want to remove this and not run any queries on the main thread
created_at: 2025-04-01T09:55:24Z
author_association: COLLABORATOR
diff_hunk: @@ -68,143 +68,129 @@ class UpdatesModule : Module(), IUpdatesEventManagerObserver {
     }
 
     AsyncFunction("reload") { promise: Promise ->
-      UpdatesController.instance.relaunchReactApplicationForModule(
-        object : IUpdatesController.ModuleCallback<Unit> {
-          override fun onSuccess(result: Unit) {
-            promise.resolve(null)
-          }
-
-          override fun onFailure(exception: CodedException) {
-            promise.reject(exception)
-          }
+      moduleScope.launch {
+        runCatching {
+          UpdatesController.instance.relaunchReactApplicationForModule()
+          promise.resolve()
path: packages/expo-updates/android/src/main/java/expo/modules/updates/UpdatesModule.kt
user: login: Kudo
body: ```suggestion
          promise.resolve(null)
```

maybe keep original behavior
created_at: 2025-04-01T16:17:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -68,143 +68,129 @@ class UpdatesModule : Module(), IUpdatesEventManagerObserver {
     }
 
     AsyncFunction("reload") { promise: Promise ->
-      UpdatesController.instance.relaunchReactApplicationForModule(
-        object : IUpdatesController.ModuleCallback<Unit> {
-          override fun onSuccess(result: Unit) {
-            promise.resolve(null)
-          }
-
-          override fun onFailure(exception: CodedException) {
-            promise.reject(exception)
-          }
+      moduleScope.launch {
+        runCatching {
+          UpdatesController.instance.relaunchReactApplicationForModule()
+          promise.resolve()
+        }.onFailure {
+          it.printStackTrace()
path: packages/expo-updates/android/src/main/java/expo/modules/updates/UpdatesModule.kt
user: login: Kudo
body: ```suggestion
          promise.reject(it as CodedException)
```
created_at: 2025-04-01T16:17:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -68,143 +68,129 @@ class UpdatesModule : Module(), IUpdatesEventManagerObserver {
     }
 
     AsyncFunction("reload") { promise: Promise ->
-      UpdatesController.instance.relaunchReactApplicationForModule(
-        object : IUpdatesController.ModuleCallback<Unit> {
-          override fun onSuccess(result: Unit) {
-            promise.resolve(null)
-          }
-
-          override fun onFailure(exception: CodedException) {
-            promise.reject(exception)
-          }
+      moduleScope.launch {
+        runCatching {
+          UpdatesController.instance.relaunchReactApplicationForModule()
+          promise.resolve()
+        }.onFailure {
+          it.printStackTrace()
         }
-      )
+      }
     }
 
     AsyncFunction("checkForUpdateAsync") { promise: Promise ->
-      UpdatesController.instance.checkForUpdate(
-        object : IUpdatesController.ModuleCallback<IUpdatesController.CheckForUpdateResult> {
-          override fun onSuccess(result: IUpdatesController.CheckForUpdateResult) {
-            when (result) {
-              is IUpdatesController.CheckForUpdateResult.ErrorResult -> {
-                promise.reject("ERR_UPDATES_CHECK", "Failed to check for update", result.error)
-              }
-
-              is IUpdatesController.CheckForUpdateResult.NoUpdateAvailable -> {
-                promise.resolve(
-                  Bundle().apply {
-                    putBoolean("isRollBackToEmbedded", false)
-                    putBoolean("isAvailable", false)
-                    putString("reason", result.reason.value)
-                  }
-                )
-              }
+      moduleScope.launch {
+        runCatching {
+          when (val result = UpdatesController.instance.checkForUpdate()) {
+            is IUpdatesController.CheckForUpdateResult.ErrorResult -> {
+              promise.reject("ERR_UPDATES_CHECK", "Failed to check for update", result.error)
+            }
 
-              is IUpdatesController.CheckForUpdateResult.RollBackToEmbedded -> {
-                promise.resolve(
-                  Bundle().apply {
-                    putBoolean("isRollBackToEmbedded", true)
-                    putBoolean("isAvailable", false)
-                  }
-                )
-              }
+            is IUpdatesController.CheckForUpdateResult.NoUpdateAvailable -> {
+              promise.resolve(
+                Bundle().apply {
+                  putBoolean("isRollBackToEmbedded", false)
+                  putBoolean("isAvailable", false)
+                  putString("reason", result.reason.value)
+                }
+              )
+            }
 
-              is IUpdatesController.CheckForUpdateResult.UpdateAvailable -> {
-                promise.resolve(
-                  Bundle().apply {
-                    putBoolean("isRollBackToEmbedded", false)
-                    putBoolean("isAvailable", true)
-                    putString(
-                      "manifestString",
-                      result.update.manifest.toString()
-                    )
-                  }
-                )
-              }
+            is IUpdatesController.CheckForUpdateResult.RollBackToEmbedded -> {
+              promise.resolve(
+                Bundle().apply {
+                  putBoolean("isRollBackToEmbedded", true)
+                  putBoolean("isAvailable", false)
+                }
+              )
             }
-          }
 
-          override fun onFailure(exception: CodedException) {
-            promise.reject(exception)
+            is IUpdatesController.CheckForUpdateResult.UpdateAvailable -> {
+              promise.resolve(
+                Bundle().apply {
+                  putBoolean("isRollBackToEmbedded", false)
+                  putBoolean("isAvailable", true)
+                  putString(
+                    "manifestString",
+                    result.update.manifest.toString()
+                  )
+                }
+              )
+            }
           }
+        }.onFailure {
+          promise.reject(it as CodedException)
         }
-      )
+      }
     }
 
     AsyncFunction("fetchUpdateAsync") { promise: Promise ->
-      UpdatesController.instance.fetchUpdate(
-        object : IUpdatesController.ModuleCallback<IUpdatesController.FetchUpdateResult> {
-          override fun onSuccess(result: IUpdatesController.FetchUpdateResult) {
-            when (result) {
-              is IUpdatesController.FetchUpdateResult.ErrorResult -> {
-                promise.reject("ERR_UPDATES_FETCH", "Failed to download new update", result.error)
-              }
-
-              is IUpdatesController.FetchUpdateResult.Failure -> {
-                promise.resolve(
-                  Bundle().apply {
-                    putBoolean("isRollBackToEmbedded", false)
-                    putBoolean("isNew", false)
-                  }
-                )
-              }
+      moduleScope.launch {
path: packages/expo-updates/android/src/main/java/expo/modules/updates/UpdatesModule.kt
user: login: Kudo
body: note that the `moduleScope` is now binded to Dispatchers.IO which is a thread pool under the hood. not sure if we like to have this behavior change. the other way is to use the coroutine support from AsyncFunction: https://docs.expo.dev/modules/module-api/#kotlin-coroutines-

that would bind the execution on the original expo-modules-core default queue
created_at: 2025-04-01T16:23:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -68,143 +68,129 @@ class UpdatesModule : Module(), IUpdatesEventManagerObserver {
     }
 
     AsyncFunction("reload") { promise: Promise ->
-      UpdatesController.instance.relaunchReactApplicationForModule(
-        object : IUpdatesController.ModuleCallback<Unit> {
-          override fun onSuccess(result: Unit) {
-            promise.resolve(null)
-          }
-
-          override fun onFailure(exception: CodedException) {
-            promise.reject(exception)
-          }
+      moduleScope.launch {
+        runCatching {
+          UpdatesController.instance.relaunchReactApplicationForModule()
+          promise.resolve()
path: packages/expo-updates/android/src/main/java/expo/modules/updates/UpdatesModule.kt
user: login: alanjhughes
body: This is the same. We provide null if the user doesn't https://github.com/expo/expo/blob/07f4c6cbd9da1eae04b3736aada3cd0d44288a12/packages/expo-modules-core/android/src/main/java/expo/modules/kotlin/Promise.kt#L12
created_at: 2025-04-01T18:21:10Z
author_association: COLLABORATOR
diff_hunk: @@ -68,143 +68,129 @@ class UpdatesModule : Module(), IUpdatesEventManagerObserver {
     }
 
     AsyncFunction("reload") { promise: Promise ->
-      UpdatesController.instance.relaunchReactApplicationForModule(
-        object : IUpdatesController.ModuleCallback<Unit> {
-          override fun onSuccess(result: Unit) {
-            promise.resolve(null)
-          }
-
-          override fun onFailure(exception: CodedException) {
-            promise.reject(exception)
-          }
+      moduleScope.launch {
+        runCatching {
+          UpdatesController.instance.relaunchReactApplicationForModule()
+          promise.resolve()
+        }.onFailure {
+          it.printStackTrace()
         }
-      )
+      }
     }
 
     AsyncFunction("checkForUpdateAsync") { promise: Promise ->
-      UpdatesController.instance.checkForUpdate(
-        object : IUpdatesController.ModuleCallback<IUpdatesController.CheckForUpdateResult> {
-          override fun onSuccess(result: IUpdatesController.CheckForUpdateResult) {
-            when (result) {
-              is IUpdatesController.CheckForUpdateResult.ErrorResult -> {
-                promise.reject("ERR_UPDATES_CHECK", "Failed to check for update", result.error)
-              }
-
-              is IUpdatesController.CheckForUpdateResult.NoUpdateAvailable -> {
-                promise.resolve(
-                  Bundle().apply {
-                    putBoolean("isRollBackToEmbedded", false)
-                    putBoolean("isAvailable", false)
-                    putString("reason", result.reason.value)
-                  }
-                )
-              }
+      moduleScope.launch {
+        runCatching {
+          when (val result = UpdatesController.instance.checkForUpdate()) {
+            is IUpdatesController.CheckForUpdateResult.ErrorResult -> {
+              promise.reject("ERR_UPDATES_CHECK", "Failed to check for update", result.error)
+            }
 
-              is IUpdatesController.CheckForUpdateResult.RollBackToEmbedded -> {
-                promise.resolve(
-                  Bundle().apply {
-                    putBoolean("isRollBackToEmbedded", true)
-                    putBoolean("isAvailable", false)
-                  }
-                )
-              }
+            is IUpdatesController.CheckForUpdateResult.NoUpdateAvailable -> {
+              promise.resolve(
+                Bundle().apply {
+                  putBoolean("isRollBackToEmbedded", false)
+                  putBoolean("isAvailable", false)
+                  putString("reason", result.reason.value)
+                }
+              )
+            }
 
-              is IUpdatesController.CheckForUpdateResult.UpdateAvailable -> {
-                promise.resolve(
-                  Bundle().apply {
-                    putBoolean("isRollBackToEmbedded", false)
-                    putBoolean("isAvailable", true)
-                    putString(
-                      "manifestString",
-                      result.update.manifest.toString()
-                    )
-                  }
-                )
-              }
+            is IUpdatesController.CheckForUpdateResult.RollBackToEmbedded -> {
+              promise.resolve(
+                Bundle().apply {
+                  putBoolean("isRollBackToEmbedded", true)
+                  putBoolean("isAvailable", false)
+                }
+              )
             }
-          }
 
-          override fun onFailure(exception: CodedException) {
-            promise.reject(exception)
+            is IUpdatesController.CheckForUpdateResult.UpdateAvailable -> {
+              promise.resolve(
+                Bundle().apply {
+                  putBoolean("isRollBackToEmbedded", false)
+                  putBoolean("isAvailable", true)
+                  putString(
+                    "manifestString",
+                    result.update.manifest.toString()
+                  )
+                }
+              )
+            }
           }
+        }.onFailure {
+          promise.reject(it as CodedException)
         }
-      )
+      }
     }
 
     AsyncFunction("fetchUpdateAsync") { promise: Promise ->
-      UpdatesController.instance.fetchUpdate(
-        object : IUpdatesController.ModuleCallback<IUpdatesController.FetchUpdateResult> {
-          override fun onSuccess(result: IUpdatesController.FetchUpdateResult) {
-            when (result) {
-              is IUpdatesController.FetchUpdateResult.ErrorResult -> {
-                promise.reject("ERR_UPDATES_FETCH", "Failed to download new update", result.error)
-              }
-
-              is IUpdatesController.FetchUpdateResult.Failure -> {
-                promise.resolve(
-                  Bundle().apply {
-                    putBoolean("isRollBackToEmbedded", false)
-                    putBoolean("isNew", false)
-                  }
-                )
-              }
+      moduleScope.launch {
path: packages/expo-updates/android/src/main/java/expo/modules/updates/UpdatesModule.kt
user: login: alanjhughes
body: There's currently an issue when using 
```kotlin 
AsyncFunction("suspendFunction") Coroutine { promise: Promise ->
```
Type conversion to a Promise fails when it's the only argument. I'll look into that.

In any case, fetching an update seems more appropriate on the IO dispatcher IMO. The default queue in core is a serial queue. I'm not sure there's any reason why we would want to delay fetching an update immediately when it's requested.
created_at: 2025-04-01T18:30:15Z
author_association: COLLABORATOR
diff_hunk: @@ -68,143 +68,129 @@ class UpdatesModule : Module(), IUpdatesEventManagerObserver {
     }
 
     AsyncFunction("reload") { promise: Promise ->
-      UpdatesController.instance.relaunchReactApplicationForModule(
-        object : IUpdatesController.ModuleCallback<Unit> {
-          override fun onSuccess(result: Unit) {
-            promise.resolve(null)
-          }
-
-          override fun onFailure(exception: CodedException) {
-            promise.reject(exception)
-          }
+      moduleScope.launch {
+        runCatching {
+          UpdatesController.instance.relaunchReactApplicationForModule()
+          promise.resolve()
path: packages/expo-updates/android/src/main/java/expo/modules/updates/UpdatesModule.kt
user: login: Kudo
body: good to know, thanks!
created_at: 2025-04-02T04:53:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -68,143 +68,129 @@ class UpdatesModule : Module(), IUpdatesEventManagerObserver {
     }
 
     AsyncFunction("reload") { promise: Promise ->
-      UpdatesController.instance.relaunchReactApplicationForModule(
-        object : IUpdatesController.ModuleCallback<Unit> {
-          override fun onSuccess(result: Unit) {
-            promise.resolve(null)
-          }
-
-          override fun onFailure(exception: CodedException) {
-            promise.reject(exception)
-          }
+      moduleScope.launch {
+        runCatching {
+          UpdatesController.instance.relaunchReactApplicationForModule()
+          promise.resolve()
+        }.onFailure {
+          it.printStackTrace()
         }
-      )
+      }
     }
 
     AsyncFunction("checkForUpdateAsync") { promise: Promise ->
-      UpdatesController.instance.checkForUpdate(
-        object : IUpdatesController.ModuleCallback<IUpdatesController.CheckForUpdateResult> {
-          override fun onSuccess(result: IUpdatesController.CheckForUpdateResult) {
-            when (result) {
-              is IUpdatesController.CheckForUpdateResult.ErrorResult -> {
-                promise.reject("ERR_UPDATES_CHECK", "Failed to check for update", result.error)
-              }
-
-              is IUpdatesController.CheckForUpdateResult.NoUpdateAvailable -> {
-                promise.resolve(
-                  Bundle().apply {
-                    putBoolean("isRollBackToEmbedded", false)
-                    putBoolean("isAvailable", false)
-                    putString("reason", result.reason.value)
-                  }
-                )
-              }
+      moduleScope.launch {
+        runCatching {
+          when (val result = UpdatesController.instance.checkForUpdate()) {
+            is IUpdatesController.CheckForUpdateResult.ErrorResult -> {
+              promise.reject("ERR_UPDATES_CHECK", "Failed to check for update", result.error)
+            }
 
-              is IUpdatesController.CheckForUpdateResult.RollBackToEmbedded -> {
-                promise.resolve(
-                  Bundle().apply {
-                    putBoolean("isRollBackToEmbedded", true)
-                    putBoolean("isAvailable", false)
-                  }
-                )
-              }
+            is IUpdatesController.CheckForUpdateResult.NoUpdateAvailable -> {
+              promise.resolve(
+                Bundle().apply {
+                  putBoolean("isRollBackToEmbedded", false)
+                  putBoolean("isAvailable", false)
+                  putString("reason", result.reason.value)
+                }
+              )
+            }
 
-              is IUpdatesController.CheckForUpdateResult.UpdateAvailable -> {
-                promise.resolve(
-                  Bundle().apply {
-                    putBoolean("isRollBackToEmbedded", false)
-                    putBoolean("isAvailable", true)
-                    putString(
-                      "manifestString",
-                      result.update.manifest.toString()
-                    )
-                  }
-                )
-              }
+            is IUpdatesController.CheckForUpdateResult.RollBackToEmbedded -> {
+              promise.resolve(
+                Bundle().apply {
+                  putBoolean("isRollBackToEmbedded", true)
+                  putBoolean("isAvailable", false)
+                }
+              )
             }
-          }
 
-          override fun onFailure(exception: CodedException) {
-            promise.reject(exception)
+            is IUpdatesController.CheckForUpdateResult.UpdateAvailable -> {
+              promise.resolve(
+                Bundle().apply {
+                  putBoolean("isRollBackToEmbedded", false)
+                  putBoolean("isAvailable", true)
+                  putString(
+                    "manifestString",
+                    result.update.manifest.toString()
+                  )
+                }
+              )
+            }
           }
+        }.onFailure {
+          promise.reject(it as CodedException)
         }
-      )
+      }
     }
 
     AsyncFunction("fetchUpdateAsync") { promise: Promise ->
-      UpdatesController.instance.fetchUpdate(
-        object : IUpdatesController.ModuleCallback<IUpdatesController.FetchUpdateResult> {
-          override fun onSuccess(result: IUpdatesController.FetchUpdateResult) {
-            when (result) {
-              is IUpdatesController.FetchUpdateResult.ErrorResult -> {
-                promise.reject("ERR_UPDATES_FETCH", "Failed to download new update", result.error)
-              }
-
-              is IUpdatesController.FetchUpdateResult.Failure -> {
-                promise.resolve(
-                  Bundle().apply {
-                    putBoolean("isRollBackToEmbedded", false)
-                    putBoolean("isNew", false)
-                  }
-                )
-              }
+      moduleScope.launch {
path: packages/expo-updates/android/src/main/java/expo/modules/updates/UpdatesModule.kt
user: login: Kudo
body: it's not specific to `fetchUpdateAsync`, but also to the other functions in this pr. for example, what would happen if `reload` be called in parallel. that would probably depend on how we lock critical section. if we are confident for the UpdatesController to support parallel calls, then it would be fine to use thread pool here.
created_at: 2025-04-02T05:01:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -68,143 +68,129 @@ class UpdatesModule : Module(), IUpdatesEventManagerObserver {
     }
 
     AsyncFunction("reload") { promise: Promise ->
-      UpdatesController.instance.relaunchReactApplicationForModule(
-        object : IUpdatesController.ModuleCallback<Unit> {
-          override fun onSuccess(result: Unit) {
-            promise.resolve(null)
-          }
-
-          override fun onFailure(exception: CodedException) {
-            promise.reject(exception)
-          }
+      moduleScope.launch {
+        runCatching {
+          UpdatesController.instance.relaunchReactApplicationForModule()
+          promise.resolve()
+        }.onFailure {
+          it.printStackTrace()
         }
-      )
+      }
     }
 
     AsyncFunction("checkForUpdateAsync") { promise: Promise ->
-      UpdatesController.instance.checkForUpdate(
-        object : IUpdatesController.ModuleCallback<IUpdatesController.CheckForUpdateResult> {
-          override fun onSuccess(result: IUpdatesController.CheckForUpdateResult) {
-            when (result) {
-              is IUpdatesController.CheckForUpdateResult.ErrorResult -> {
-                promise.reject("ERR_UPDATES_CHECK", "Failed to check for update", result.error)
-              }
-
-              is IUpdatesController.CheckForUpdateResult.NoUpdateAvailable -> {
-                promise.resolve(
-                  Bundle().apply {
-                    putBoolean("isRollBackToEmbedded", false)
-                    putBoolean("isAvailable", false)
-                    putString("reason", result.reason.value)
-                  }
-                )
-              }
+      moduleScope.launch {
+        runCatching {
+          when (val result = UpdatesController.instance.checkForUpdate()) {
+            is IUpdatesController.CheckForUpdateResult.ErrorResult -> {
+              promise.reject("ERR_UPDATES_CHECK", "Failed to check for update", result.error)
+            }
 
-              is IUpdatesController.CheckForUpdateResult.RollBackToEmbedded -> {
-                promise.resolve(
-                  Bundle().apply {
-                    putBoolean("isRollBackToEmbedded", true)
-                    putBoolean("isAvailable", false)
-                  }
-                )
-              }
+            is IUpdatesController.CheckForUpdateResult.NoUpdateAvailable -> {
+              promise.resolve(
+                Bundle().apply {
+                  putBoolean("isRollBackToEmbedded", false)
+                  putBoolean("isAvailable", false)
+                  putString("reason", result.reason.value)
+                }
+              )
+            }
 
-              is IUpdatesController.CheckForUpdateResult.UpdateAvailable -> {
-                promise.resolve(
-                  Bundle().apply {
-                    putBoolean("isRollBackToEmbedded", false)
-                    putBoolean("isAvailable", true)
-                    putString(
-                      "manifestString",
-                      result.update.manifest.toString()
-                    )
-                  }
-                )
-              }
+            is IUpdatesController.CheckForUpdateResult.RollBackToEmbedded -> {
+              promise.resolve(
+                Bundle().apply {
+                  putBoolean("isRollBackToEmbedded", true)
+                  putBoolean("isAvailable", false)
+                }
+              )
             }
-          }
 
-          override fun onFailure(exception: CodedException) {
-            promise.reject(exception)
+            is IUpdatesController.CheckForUpdateResult.UpdateAvailable -> {
+              promise.resolve(
+                Bundle().apply {
+                  putBoolean("isRollBackToEmbedded", false)
+                  putBoolean("isAvailable", true)
+                  putString(
+                    "manifestString",
+                    result.update.manifest.toString()
+                  )
+                }
+              )
+            }
           }
+        }.onFailure {
+          promise.reject(it as CodedException)
         }
-      )
+      }
     }
 
     AsyncFunction("fetchUpdateAsync") { promise: Promise ->
-      UpdatesController.instance.fetchUpdate(
-        object : IUpdatesController.ModuleCallback<IUpdatesController.FetchUpdateResult> {
-          override fun onSuccess(result: IUpdatesController.FetchUpdateResult) {
-            when (result) {
-              is IUpdatesController.FetchUpdateResult.ErrorResult -> {
-                promise.reject("ERR_UPDATES_FETCH", "Failed to download new update", result.error)
-              }
-
-              is IUpdatesController.FetchUpdateResult.Failure -> {
-                promise.resolve(
-                  Bundle().apply {
-                    putBoolean("isRollBackToEmbedded", false)
-                    putBoolean("isNew", false)
-                  }
-                )
-              }
+      moduleScope.launch {
path: packages/expo-updates/android/src/main/java/expo/modules/updates/UpdatesModule.kt
user: login: alanjhughes
body: Good point! As an aside, if we use the serial queue, doesn't that mean cancellation will only happen when the appContext is destroyed? That would only happen on reload 
created_at: 2025-04-02T05:20:21Z
author_association: COLLABORATOR
diff_hunk: @@ -68,143 +68,129 @@ class UpdatesModule : Module(), IUpdatesEventManagerObserver {
     }
 
     AsyncFunction("reload") { promise: Promise ->
-      UpdatesController.instance.relaunchReactApplicationForModule(
-        object : IUpdatesController.ModuleCallback<Unit> {
-          override fun onSuccess(result: Unit) {
-            promise.resolve(null)
-          }
-
-          override fun onFailure(exception: CodedException) {
-            promise.reject(exception)
-          }
+      moduleScope.launch {
+        runCatching {
+          UpdatesController.instance.relaunchReactApplicationForModule()
+          promise.resolve()
+        }.onFailure {
+          it.printStackTrace()
         }
-      )
+      }
     }
 
     AsyncFunction("checkForUpdateAsync") { promise: Promise ->
-      UpdatesController.instance.checkForUpdate(
-        object : IUpdatesController.ModuleCallback<IUpdatesController.CheckForUpdateResult> {
-          override fun onSuccess(result: IUpdatesController.CheckForUpdateResult) {
-            when (result) {
-              is IUpdatesController.CheckForUpdateResult.ErrorResult -> {
-                promise.reject("ERR_UPDATES_CHECK", "Failed to check for update", result.error)
-              }
-
-              is IUpdatesController.CheckForUpdateResult.NoUpdateAvailable -> {
-                promise.resolve(
-                  Bundle().apply {
-                    putBoolean("isRollBackToEmbedded", false)
-                    putBoolean("isAvailable", false)
-                    putString("reason", result.reason.value)
-                  }
-                )
-              }
+      moduleScope.launch {
+        runCatching {
+          when (val result = UpdatesController.instance.checkForUpdate()) {
+            is IUpdatesController.CheckForUpdateResult.ErrorResult -> {
+              promise.reject("ERR_UPDATES_CHECK", "Failed to check for update", result.error)
+            }
 
-              is IUpdatesController.CheckForUpdateResult.RollBackToEmbedded -> {
-                promise.resolve(
-                  Bundle().apply {
-                    putBoolean("isRollBackToEmbedded", true)
-                    putBoolean("isAvailable", false)
-                  }
-                )
-              }
+            is IUpdatesController.CheckForUpdateResult.NoUpdateAvailable -> {
+              promise.resolve(
+                Bundle().apply {
+                  putBoolean("isRollBackToEmbedded", false)
+                  putBoolean("isAvailable", false)
+                  putString("reason", result.reason.value)
+                }
+              )
+            }
 
-              is IUpdatesController.CheckForUpdateResult.UpdateAvailable -> {
-                promise.resolve(
-                  Bundle().apply {
-                    putBoolean("isRollBackToEmbedded", false)
-                    putBoolean("isAvailable", true)
-                    putString(
-                      "manifestString",
-                      result.update.manifest.toString()
-                    )
-                  }
-                )
-              }
+            is IUpdatesController.CheckForUpdateResult.RollBackToEmbedded -> {
+              promise.resolve(
+                Bundle().apply {
+                  putBoolean("isRollBackToEmbedded", true)
+                  putBoolean("isAvailable", false)
+                }
+              )
             }
-          }
 
-          override fun onFailure(exception: CodedException) {
-            promise.reject(exception)
+            is IUpdatesController.CheckForUpdateResult.UpdateAvailable -> {
+              promise.resolve(
+                Bundle().apply {
+                  putBoolean("isRollBackToEmbedded", false)
+                  putBoolean("isAvailable", true)
+                  putString(
+                    "manifestString",
+                    result.update.manifest.toString()
+                  )
+                }
+              )
+            }
           }
+        }.onFailure {
+          promise.reject(it as CodedException)
         }
-      )
+      }
     }
 
     AsyncFunction("fetchUpdateAsync") { promise: Promise ->
-      UpdatesController.instance.fetchUpdate(
-        object : IUpdatesController.ModuleCallback<IUpdatesController.FetchUpdateResult> {
-          override fun onSuccess(result: IUpdatesController.FetchUpdateResult) {
-            when (result) {
-              is IUpdatesController.FetchUpdateResult.ErrorResult -> {
-                promise.reject("ERR_UPDATES_FETCH", "Failed to download new update", result.error)
-              }
-
-              is IUpdatesController.FetchUpdateResult.Failure -> {
-                promise.resolve(
-                  Bundle().apply {
-                    putBoolean("isRollBackToEmbedded", false)
-                    putBoolean("isNew", false)
-                  }
-                )
-              }
+      moduleScope.launch {
path: packages/expo-updates/android/src/main/java/expo/modules/updates/UpdatesModule.kt
user: login: Kudo
body: correct. i’m not so concerned for cancellation because both AppContext moduleCoroutine and this UpdatesModule.moduleCoroutine are destroyed when reload. and react native reload is happened as a last step for Updates.reloadAsync. i’m more concerned for the steps before react native reload.

well, i think that’s fine either to use thread pool because you have some locks in UpdatesController. i’m leaving the final call for you
created_at: 2025-04-02T07:25:06Z
author_association: CONTRIBUTOR

PR comments:
user: login: alanjhughes
created_at: 2025-04-01T09:42:43Z
body: * **#36004** <a href="https://app.graphite.dev/github/pr/expo/expo/36004?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#35994** <a href="https://app.graphite.dev/github/pr/expo/expo/35994?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#35839** <a href="https://app.graphite.dev/github/pr/expo/expo/35839?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#35809** <a href="https://app.graphite.dev/github/pr/expo/expo/35809?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/expo/expo/35809?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* `main`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: COLLABORATOR
user: login: expo-bot
created_at: 2025-04-01T09:44:48Z
body: <!-- pr-labeler comment -->
The Pull Request introduced fingerprint changes against the base commit: 3d883e92ac553dfbfbfab926a32b64e12c74f6cc
<details><summary>Fingerprint diff</summary>

```json
[
  {
    "op": "changed",
    "beforeSource": {
      "type": "dir",
      "filePath": "../../packages/expo-updates/android",
      "reasons": [
        "expoAutolinkingAndroid"
      ],
      "hash": "22d14ece37fcbb1cd279848697237fa9e81835e6"
    },
    "afterSource": {
      "type": "dir",
      "filePath": "../../packages/expo-updates/android",
      "reasons": [
        "expoAutolinkingAndroid"
      ],
      "hash": "ed0c1e86c5fd55be96341132243517cbec5d748d"
    }
  }
]
```

</details>

---
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*

author_association: COLLABORATOR
user: login: github-actions[bot]
created_at: 2025-04-01T16:06:43Z
body: Subscribed to pull request
| File Patterns | Mentions |
| - | - |
| packages/expo-updates/\*\* | @wschurman, @kudo, @douglowder |

Generated by [CodeMention](https://github.com/expo/expo/actions/workflows/codemention.yaml)
<!-- codemention header -->
author_association: CONTRIBUTOR
user: login: expo-bot
created_at: 2025-04-08T10:44:07Z
body: <!-- Made with ❤️ by `expotools code-review` -->

*Hi there! 👋 I'm a bot whose goal is to ensure your contributions meet our guidelines.*

I've found some issues in your pull request that should be addressed (click on them for more details) 👇

<details>
  <summary><strong>⚠️ Suggestion</strong>: Missing changelog entries</summary>

&NewLine;
Your changes should be noted in the changelog, e.g.: 
`- Migrate `IUpdatesController` to use coroutines ([#35809](https://github.com/expo/expo/pull/35809) by [@alanjhughes](https://github.com/alanjhughes))`
Read [Updating Changelogs](https://github.com/expo/expo/blob/main/guides/contributing/Updating%20Changelogs.md) guide and consider adding an appropriate entry to the following changelogs: 
- [`packages/expo-updates/CHANGELOG.md`](https://github.com/expo/expo/blob/@alanhughes/updates-controller-interface-coroutines/packages/expo-updates/CHANGELOG.md)
</details>


---
*Generated by ExpoBot 🤖 against 3024d2787dc22eed50fcf26dc1ff9b8637976542*
author_association: COLLABORATOR
user: login: alanjhughes
created_at: 2025-04-08T18:54:45Z
body: ### Merge activity

* **Apr 8, 2:54 PM EDT**: A user started a stack merge that includes this pull request via [Graphite](https://app.graphite.dev/github/pr/expo/expo/35809).
* **Apr 8, 2:55 PM EDT**: A user merged this pull request with [Graphite](https://app.graphite.dev/github/pr/expo/expo/35809).

author_association: COLLABORATOR