expo 53.0.13 → 53.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,7 +32,7 @@ buildscript {
32
32
  def reactNativeVersion = project.extensions.getByType(ExpoModuleExtension).reactNativeVersion
33
33
 
34
34
  group = 'host.exp.exponent'
35
- version = '53.0.13'
35
+ version = '53.0.15'
36
36
 
37
37
  expoModule {
38
38
  // We can't prebuild the module because it depends on the generated files.
@@ -43,7 +43,7 @@ android {
43
43
  namespace "expo.core"
44
44
  defaultConfig {
45
45
  versionCode 1
46
- versionName "53.0.13"
46
+ versionName "53.0.15"
47
47
  consumerProguardFiles("proguard-rules.pro")
48
48
  }
49
49
  testOptions {
@@ -77,6 +77,8 @@ dependencies { dependencyHandler ->
77
77
  testImplementation 'androidx.test:core:1.5.0'
78
78
  testImplementation "com.google.truth:truth:1.1.2"
79
79
  testImplementation 'io.mockk:mockk:1.13.5'
80
+ testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2'
81
+ testImplementation 'org.robolectric:robolectric:4.15.1'
80
82
 
81
83
  if (useLegacyAutolinking) {
82
84
  // Link expo modules as dependencies of the adapter. It uses `api` configuration so they all will be visible for the app as well.
@@ -1,13 +1,20 @@
1
1
  package expo.modules
2
2
 
3
+ import android.annotation.SuppressLint
3
4
  import android.app.Activity
4
5
  import android.content.Context
5
6
  import android.content.Intent
7
+ import android.content.pm.ActivityInfo
6
8
  import android.content.res.Configuration
9
+ import android.os.Build
10
+ import android.os.Build.VERSION
7
11
  import android.os.Bundle
12
+ import android.util.Log
8
13
  import android.view.KeyEvent
9
14
  import android.view.ViewGroup
15
+ import androidx.annotation.VisibleForTesting
10
16
  import androidx.collection.ArrayMap
17
+ import androidx.lifecycle.lifecycleScope
11
18
  import com.facebook.react.ReactActivity
12
19
  import com.facebook.react.ReactActivityDelegate
13
20
  import com.facebook.react.ReactDelegate
@@ -18,17 +25,22 @@ import com.facebook.react.ReactNativeHost
18
25
  import com.facebook.react.ReactRootView
19
26
  import com.facebook.react.bridge.ReactContext
20
27
  import com.facebook.react.modules.core.PermissionListener
28
+ import expo.modules.core.interfaces.ReactActivityHandler.DelayLoadAppHandler
21
29
  import expo.modules.core.interfaces.ReactActivityLifecycleListener
22
30
  import expo.modules.kotlin.Utils
23
31
  import expo.modules.rncompatibility.ReactNativeFeatureFlags
32
+ import kotlinx.coroutines.CoroutineStart
33
+ import kotlinx.coroutines.launch
24
34
  import java.lang.reflect.Field
25
35
  import java.lang.reflect.Method
26
36
  import java.lang.reflect.Modifier
37
+ import kotlin.coroutines.resume
38
+ import kotlin.coroutines.suspendCoroutine
27
39
 
28
40
  class ReactActivityDelegateWrapper(
29
41
  private val activity: ReactActivity,
30
42
  private val isNewArchitectureEnabled: Boolean,
31
- private var delegate: ReactActivityDelegate
43
+ @get:VisibleForTesting internal var delegate: ReactActivityDelegate
32
44
  ) : ReactActivityDelegate(activity, null) {
33
45
  constructor(activity: ReactActivity, delegate: ReactActivityDelegate) :
34
46
  this(activity, false, delegate)
@@ -44,9 +56,14 @@ class ReactActivityDelegateWrapper(
44
56
  private val _reactHost: ReactHost? by lazy {
45
57
  delegate.reactHost
46
58
  }
59
+ private val delayLoadAppHandler: DelayLoadAppHandler? by lazy {
60
+ reactActivityHandlers.asSequence()
61
+ .mapNotNull { it.getDelayLoadAppHandler(activity, reactNativeHost) }
62
+ .firstOrNull()
63
+ }
47
64
 
48
65
  /**
49
- * When the app delay for `loadApp`, the ReactInstanceManager's lifecycle will be disrupted.
66
+ * When the app delay for `loadApp`, the React Native lifecycle will be disrupted.
50
67
  * This flag indicates we should emit `onResume` after `loadApp`.
51
68
  */
52
69
  private var shouldEmitPendingResume = false
@@ -82,53 +99,12 @@ class ReactActivityDelegateWrapper(
82
99
  }
83
100
 
84
101
  override fun loadApp(appKey: String?) {
85
- // Give modules a chance to wrap the ReactRootView in a container ViewGroup. If some module
86
- // wants to do this, we override the functionality of `loadApp` and call `setContentView` with
87
- // the new container view instead.
88
- val rootViewContainer = reactActivityHandlers.asSequence()
89
- .mapNotNull { it.createReactRootViewContainer(activity) }
90
- .firstOrNull()
91
- if (rootViewContainer != null) {
92
- val mReactDelegate = ReactActivityDelegate::class.java.getDeclaredField("mReactDelegate")
93
- mReactDelegate.isAccessible = true
94
- val reactDelegate = mReactDelegate[delegate] as ReactDelegate
95
-
96
- reactDelegate.loadApp(appKey)
97
- val reactRootView = reactDelegate.reactRootView
98
- (reactRootView?.parent as? ViewGroup)?.removeView(reactRootView)
99
- rootViewContainer.addView(reactRootView, ViewGroup.LayoutParams.MATCH_PARENT)
100
- activity.setContentView(rootViewContainer)
101
- reactActivityLifecycleListeners.forEach { listener ->
102
- listener.onContentChanged(activity)
103
- }
104
- return
105
- }
106
-
107
- val delayLoadAppHandler = reactActivityHandlers.asSequence()
108
- .mapNotNull { it.getDelayLoadAppHandler(activity, reactNativeHost) }
109
- .firstOrNull()
110
- if (delayLoadAppHandler != null) {
111
- shouldEmitPendingResume = true
112
- delayLoadAppHandler.whenReady {
113
- Utils.assertMainThread()
114
- invokeDelegateMethod<Unit, String?>("loadApp", arrayOf(String::class.java), arrayOf(appKey))
115
- reactActivityLifecycleListeners.forEach { listener ->
116
- listener.onContentChanged(activity)
117
- }
118
- if (shouldEmitPendingResume) {
119
- shouldEmitPendingResume = false
120
- onResume()
121
- }
122
- }
123
- return
124
- }
125
-
126
- invokeDelegateMethod<Unit, String?>("loadApp", arrayOf(String::class.java), arrayOf(appKey))
127
- reactActivityLifecycleListeners.forEach { listener ->
128
- listener.onContentChanged(activity)
102
+ activity.lifecycleScope.launch(start = CoroutineStart.UNDISPATCHED) {
103
+ loadAppImpl(appKey, supportsDelayLoad = true)
129
104
  }
130
105
  }
131
106
 
107
+ @SuppressLint("DiscouragedPrivateApi")
132
108
  override fun onCreate(savedInstanceState: Bundle?) {
133
109
  // Give handlers a chance as early as possible to replace the wrapped delegate object.
134
110
  // If they do, we call the new wrapped delegate's `onCreate` instead of overriding it here.
@@ -144,38 +120,49 @@ class ReactActivityDelegateWrapper(
144
120
  mDelegateField.set(activity, newDelegate)
145
121
  delegate = newDelegate
146
122
 
147
- invokeDelegateMethod<Unit, Bundle?>("onCreate", arrayOf(Bundle::class.java), arrayOf(savedInstanceState))
123
+ delegate.onCreate(savedInstanceState)
148
124
  } else {
149
125
  // Since we just wrap `ReactActivityDelegate` but not inherit it, in its `onCreate`,
150
126
  // the calls to `createRootView()` or `getMainComponentName()` have no chances to be our wrapped methods.
151
127
  // Instead we intercept `ReactActivityDelegate.onCreate` and replace the `mReactDelegate` with our version.
152
128
  // That's not ideal but works.
153
- val launchOptions = composeLaunchOptions()
154
- val reactDelegate: ReactDelegate
155
- if (ReactNativeFeatureFlags.enableBridgelessArchitecture) {
156
- reactDelegate = ReactDelegate(
157
- plainActivity,
158
- reactHost,
159
- mainComponentName,
160
- launchOptions
161
- )
162
- } else {
163
- reactDelegate = object : ReactDelegate(
164
- plainActivity,
165
- reactNativeHost,
166
- mainComponentName,
167
- launchOptions
168
- ) {
169
- override fun createRootView(): ReactRootView {
170
- return this@ReactActivityDelegateWrapper.createRootView() ?: super.createRootView()
129
+
130
+ activity.lifecycleScope.launch(start = CoroutineStart.UNDISPATCHED) {
131
+ awaitDelayLoadAppWhenReady(delayLoadAppHandler)
132
+
133
+ if (VERSION.SDK_INT >= Build.VERSION_CODES.O && isWideColorGamutEnabled) {
134
+ activity.window.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
135
+ }
136
+
137
+ val launchOptions = composeLaunchOptions()
138
+ val reactDelegate: ReactDelegate
139
+ if (ReactNativeFeatureFlags.enableBridgelessArchitecture) {
140
+ reactDelegate = ReactDelegate(
141
+ plainActivity,
142
+ reactHost,
143
+ mainComponentName,
144
+ launchOptions
145
+ )
146
+ } else {
147
+ reactDelegate = object : ReactDelegate(
148
+ plainActivity,
149
+ reactNativeHost,
150
+ mainComponentName,
151
+ launchOptions,
152
+ isFabricEnabled
153
+ ) {
154
+ override fun createRootView(): ReactRootView {
155
+ return this@ReactActivityDelegateWrapper.createRootView() ?: super.createRootView()
156
+ }
171
157
  }
172
158
  }
173
- }
174
- val mReactDelegate = ReactActivityDelegate::class.java.getDeclaredField("mReactDelegate")
175
- mReactDelegate.isAccessible = true
176
- mReactDelegate.set(delegate, reactDelegate)
177
- if (mainComponentName != null) {
178
- loadApp(mainComponentName)
159
+
160
+ val mReactDelegate = ReactActivityDelegate::class.java.getDeclaredField("mReactDelegate")
161
+ mReactDelegate.isAccessible = true
162
+ mReactDelegate.set(delegate, reactDelegate)
163
+ if (mainComponentName != null) {
164
+ loadAppImpl(mainComponentName, supportsDelayLoad = false)
165
+ }
179
166
  }
180
167
  }
181
168
 
@@ -188,7 +175,7 @@ class ReactActivityDelegateWrapper(
188
175
  if (shouldEmitPendingResume) {
189
176
  return
190
177
  }
191
- invokeDelegateMethod<Unit>("onResume")
178
+ delegate.onResume()
192
179
  reactActivityLifecycleListeners.forEach { listener ->
193
180
  listener.onResume(activity)
194
181
  }
@@ -204,14 +191,26 @@ class ReactActivityDelegateWrapper(
204
191
  reactActivityLifecycleListeners.forEach { listener ->
205
192
  listener.onPause(activity)
206
193
  }
207
- return invokeDelegateMethod("onPause")
194
+ if (delayLoadAppHandler != null) {
195
+ try {
196
+ // For the delay load case, we may enter a different call flow than react-native.
197
+ // For example, Activity stopped before delay load finished.
198
+ // We stop before the ReactActivityDelegate gets a chance to set up.
199
+ // In this case, we should catch the exceptions.
200
+ delegate.onPause()
201
+ } catch (e: Exception) {
202
+ Log.e(TAG, "Exception occurred during onPause with delayed app loading", e)
203
+ }
204
+ } else {
205
+ delegate.onPause()
206
+ }
208
207
  }
209
208
 
210
209
  override fun onUserLeaveHint() {
211
210
  reactActivityLifecycleListeners.forEach { listener ->
212
211
  listener.onUserLeaveHint(activity)
213
212
  }
214
- return invokeDelegateMethod("onUserLeaveHint")
213
+ delegate.onUserLeaveHint()
215
214
  }
216
215
 
217
216
  override fun onDestroy() {
@@ -224,7 +223,19 @@ class ReactActivityDelegateWrapper(
224
223
  reactActivityLifecycleListeners.forEach { listener ->
225
224
  listener.onDestroy(activity)
226
225
  }
227
- return invokeDelegateMethod("onDestroy")
226
+ if (delayLoadAppHandler != null) {
227
+ try {
228
+ // For the delay load case, we may enter a different call flow than react-native.
229
+ // For example, Activity stopped before delay load finished.
230
+ // We stop before the ReactActivityDelegate gets a chance to set up.
231
+ // In this case, we should catch the exceptions.
232
+ delegate.onDestroy()
233
+ } catch (e: Exception) {
234
+ Log.e(TAG, "Exception occurred during onDestroy with delayed app loading", e)
235
+ }
236
+ } else {
237
+ delegate.onDestroy()
238
+ }
228
239
  }
229
240
 
230
241
  override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
@@ -318,6 +329,10 @@ class ReactActivityDelegateWrapper(
318
329
  return invokeDelegateMethod("isFabricEnabled")
319
330
  }
320
331
 
332
+ override fun isWideColorGamutEnabled(): Boolean {
333
+ return invokeDelegateMethod("isWideColorGamutEnabled")
334
+ }
335
+
321
336
  override fun composeLaunchOptions(): Bundle? {
322
337
  return invokeDelegateMethod("composeLaunchOptions")
323
338
  }
@@ -341,8 +356,9 @@ class ReactActivityDelegateWrapper(
341
356
  return method!!.invoke(delegate) as T
342
357
  }
343
358
 
359
+ @VisibleForTesting
344
360
  @Suppress("UNCHECKED_CAST")
345
- private fun <T, A> invokeDelegateMethod(
361
+ internal fun <T, A> invokeDelegateMethod(
346
362
  name: String,
347
363
  argTypes: Array<Class<*>>,
348
364
  args: Array<A>
@@ -356,5 +372,68 @@ class ReactActivityDelegateWrapper(
356
372
  return method!!.invoke(delegate, *args) as T
357
373
  }
358
374
 
375
+ private suspend fun loadAppImpl(appKey: String?, supportsDelayLoad: Boolean) {
376
+ // Give modules a chance to wrap the ReactRootView in a container ViewGroup. If some module
377
+ // wants to do this, we override the functionality of `loadApp` and call `setContentView` with
378
+ // the new container view instead.
379
+ val rootViewContainer = reactActivityHandlers.asSequence()
380
+ .mapNotNull { it.createReactRootViewContainer(activity) }
381
+ .firstOrNull()
382
+ if (rootViewContainer != null) {
383
+ val mReactDelegate = ReactActivityDelegate::class.java.getDeclaredField("mReactDelegate")
384
+ mReactDelegate.isAccessible = true
385
+ val reactDelegate = mReactDelegate[delegate] as ReactDelegate
386
+
387
+ reactDelegate.loadApp(appKey)
388
+ val reactRootView = reactDelegate.reactRootView
389
+ (reactRootView?.parent as? ViewGroup)?.removeView(reactRootView)
390
+ rootViewContainer.addView(reactRootView, ViewGroup.LayoutParams.MATCH_PARENT)
391
+ activity.setContentView(rootViewContainer)
392
+ reactActivityLifecycleListeners.forEach { listener ->
393
+ listener.onContentChanged(activity)
394
+ }
395
+ return
396
+ }
397
+
398
+ if (supportsDelayLoad) {
399
+ awaitDelayLoadAppWhenReady(delayLoadAppHandler)
400
+ invokeDelegateMethod<Unit, String?>("loadApp", arrayOf(String::class.java), arrayOf(appKey))
401
+ reactActivityLifecycleListeners.forEach { listener ->
402
+ listener.onContentChanged(activity)
403
+ }
404
+ if (shouldEmitPendingResume) {
405
+ shouldEmitPendingResume = false
406
+ onResume()
407
+ }
408
+ return
409
+ }
410
+
411
+ invokeDelegateMethod<Unit, String?>("loadApp", arrayOf(String::class.java), arrayOf(appKey))
412
+ reactActivityLifecycleListeners.forEach { listener ->
413
+ listener.onContentChanged(activity)
414
+ }
415
+ if (shouldEmitPendingResume) {
416
+ shouldEmitPendingResume = false
417
+ onResume()
418
+ }
419
+ }
420
+
421
+ private suspend fun awaitDelayLoadAppWhenReady(delayLoadAppHandler: DelayLoadAppHandler?) {
422
+ if (delayLoadAppHandler == null) {
423
+ return
424
+ }
425
+ shouldEmitPendingResume = true
426
+ suspendCoroutine { continuation ->
427
+ delayLoadAppHandler.whenReady {
428
+ Utils.assertMainThread()
429
+ continuation.resume(Unit)
430
+ }
431
+ }
432
+ }
433
+
359
434
  //endregion
435
+
436
+ companion object {
437
+ private val TAG = ReactActivityDelegate::class.simpleName
438
+ }
360
439
  }
@@ -7,8 +7,8 @@ import expo.modules.kotlin.exception.CodedException
7
7
  internal class FetchUnknownException :
8
8
  CodedException("Unknown error")
9
9
 
10
- internal class FetchRequestCancelledException :
11
- CodedException("Cancelled request")
10
+ internal class FetchRequestCanceledException :
11
+ CodedException("Fetch request has been canceled")
12
12
 
13
13
  internal class FetchAndroidContextLostException :
14
14
  CodedException("The Android context has been lost")
@@ -43,6 +43,6 @@ internal class NativeRequest(appContext: AppContext, internal val response: Nati
43
43
  fun cancel() {
44
44
  val task = this.task ?: return
45
45
  task.cancel()
46
- response.emitRequestCancelled()
46
+ response.emitRequestCanceled()
47
47
  }
48
48
  }
@@ -3,6 +3,7 @@
3
3
  package expo.modules.fetch
4
4
 
5
5
  import android.util.Log
6
+ import expo.modules.core.logging.localizedMessageWithCauseLocalizedMessage
6
7
  import expo.modules.kotlin.AppContext
7
8
  import expo.modules.kotlin.sharedobjects.SharedObject
8
9
  import kotlinx.coroutines.CoroutineScope
@@ -68,14 +69,14 @@ internal class NativeResponse(appContext: AppContext, private val coroutineScope
68
69
  if (isInvalidState(ResponseState.BODY_STREAMING_STARTED)) {
69
70
  return
70
71
  }
71
- state = ResponseState.BODY_STREAMING_CANCELLED
72
+ state = ResponseState.BODY_STREAMING_CANCELED
72
73
  }
73
74
 
74
- fun emitRequestCancelled() {
75
- val error = FetchRequestCancelledException()
75
+ fun emitRequestCanceled() {
76
+ val error = FetchRequestCanceledException()
76
77
  this.error = error
77
78
  if (state == ResponseState.BODY_STREAMING_STARTED) {
78
- emit("didFailWithError", error)
79
+ emit("didFailWithError", error.localizedMessageWithCauseLocalizedMessage())
79
80
  }
80
81
  state = ResponseState.ERROR_RECEIVED
81
82
  }
@@ -97,7 +98,7 @@ internal class NativeResponse(appContext: AppContext, private val coroutineScope
97
98
  //region Callback implementations
98
99
 
99
100
  override fun onFailure(call: Call, e: IOException) {
100
- // Canceled request should be handled by emitRequestCancelled
101
+ // Canceled request should be handled by emitRequestCanceled
101
102
  if (e.message === "Canceled") {
102
103
  return
103
104
  }
@@ -106,14 +107,14 @@ internal class NativeResponse(appContext: AppContext, private val coroutineScope
106
107
  ResponseState.STARTED,
107
108
  ResponseState.RESPONSE_RECEIVED,
108
109
  ResponseState.BODY_STREAMING_STARTED,
109
- ResponseState.BODY_STREAMING_CANCELLED
110
+ ResponseState.BODY_STREAMING_CANCELED
110
111
  )
111
112
  ) {
112
113
  return
113
114
  }
114
115
 
115
116
  if (state == ResponseState.BODY_STREAMING_STARTED) {
116
- emit("didFailWithError", e)
117
+ emit("didFailWithError", e.localizedMessageWithCauseLocalizedMessage())
117
118
  }
118
119
  error = e
119
120
  state = ResponseState.ERROR_RECEIVED
@@ -174,7 +175,7 @@ internal class NativeResponse(appContext: AppContext, private val coroutineScope
174
175
  if (isInvalidState(
175
176
  ResponseState.RESPONSE_RECEIVED,
176
177
  ResponseState.BODY_STREAMING_STARTED,
177
- ResponseState.BODY_STREAMING_CANCELLED
178
+ ResponseState.BODY_STREAMING_CANCELED
178
179
  )
179
180
  ) {
180
181
  break
@@ -190,7 +191,7 @@ internal class NativeResponse(appContext: AppContext, private val coroutineScope
190
191
  } catch (e: IOException) {
191
192
  this.error = e
192
193
  if (state == ResponseState.BODY_STREAMING_STARTED) {
193
- emit("didFailWithError", e)
194
+ emit("didFailWithError", e.localizedMessageWithCauseLocalizedMessage())
194
195
  }
195
196
  state = ResponseState.ERROR_RECEIVED
196
197
  }
@@ -8,6 +8,6 @@ internal enum class ResponseState(val intValue: Int) {
8
8
  RESPONSE_RECEIVED(2),
9
9
  BODY_COMPLETED(3),
10
10
  BODY_STREAMING_STARTED(4),
11
- BODY_STREAMING_CANCELLED(5),
11
+ BODY_STREAMING_CANCELED(5),
12
12
  ERROR_RECEIVED(6)
13
13
  }
@@ -0,0 +1,336 @@
1
+ package expo.modules
2
+
3
+ import android.app.Activity
4
+ import android.app.Application
5
+ import android.content.Context
6
+ import com.facebook.react.ReactActivity
7
+ import com.facebook.react.ReactActivityDelegate
8
+ import com.facebook.react.ReactApplication
9
+ import com.facebook.react.ReactHost
10
+ import com.facebook.react.ReactNativeHost
11
+ import com.facebook.react.ReactRootView
12
+ import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
13
+ import com.facebook.react.defaults.DefaultReactActivityDelegate
14
+ import com.facebook.react.interfaces.fabric.ReactSurface
15
+ import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags
16
+ import com.facebook.soloader.SoLoader
17
+ import expo.modules.core.interfaces.Package
18
+ import expo.modules.core.interfaces.ReactActivityHandler
19
+ import expo.modules.core.interfaces.ReactActivityHandler.DelayLoadAppHandler
20
+ import expo.modules.core.interfaces.ReactActivityLifecycleListener
21
+ import io.mockk.MockKAnnotations
22
+ import io.mockk.every
23
+ import io.mockk.impl.annotations.RelaxedMockK
24
+ import io.mockk.mockk
25
+ import io.mockk.mockkObject
26
+ import io.mockk.mockkStatic
27
+ import io.mockk.slot
28
+ import io.mockk.spyk
29
+ import io.mockk.unmockkAll
30
+ import io.mockk.verify
31
+ import kotlinx.coroutines.Dispatchers
32
+ import kotlinx.coroutines.ExperimentalCoroutinesApi
33
+ import kotlinx.coroutines.test.UnconfinedTestDispatcher
34
+ import kotlinx.coroutines.test.runTest
35
+ import kotlinx.coroutines.test.setMain
36
+ import org.junit.After
37
+ import org.junit.Before
38
+ import org.junit.Test
39
+ import org.junit.runner.RunWith
40
+ import org.robolectric.Robolectric
41
+ import org.robolectric.RobolectricTestRunner
42
+ import org.robolectric.android.controller.ActivityController
43
+ import org.robolectric.annotation.Config
44
+
45
+ @RunWith(RobolectricTestRunner::class)
46
+ @Config(application = MockApplication::class)
47
+ internal class ReactActivityDelegateWrapperDelayLoadTest {
48
+ @RelaxedMockK
49
+ private lateinit var delayLoadAppHandler: DelayLoadAppHandler
50
+
51
+ private lateinit var mockPackageWithDelay: MockPackageWithDelayHandler
52
+ private lateinit var mockPackageWithoutDelay: MockPackageWithoutDelayHandler
53
+ private lateinit var activityController: ActivityController<MockActivity>
54
+ private val activity: MockActivity
55
+ get() = activityController.get()
56
+
57
+ @OptIn(ExperimentalCoroutinesApi::class)
58
+ @Before
59
+ fun setUp() {
60
+ SoLoader.setInTestMode()
61
+ mockkObject(ExpoModulesPackage.Companion)
62
+ mockkStatic(ReactNativeFeatureFlags::class)
63
+ every { ReactNativeFeatureFlags.enableBridgelessArchitecture() } returns true
64
+ every { ReactNativeFeatureFlags.enableFabricRenderer() } returns true
65
+ Dispatchers.setMain(UnconfinedTestDispatcher())
66
+
67
+ MockKAnnotations.init(this)
68
+
69
+ mockPackageWithDelay = MockPackageWithDelayHandler(delayLoadAppHandler)
70
+ mockPackageWithoutDelay = MockPackageWithoutDelayHandler()
71
+ }
72
+
73
+ @After
74
+ fun tearDown() {
75
+ unmockkAll()
76
+ }
77
+
78
+ @Test
79
+ fun `should proceed loadApp immediately when no delayLoadAppHandler`() = runTest {
80
+ every { ExpoModulesPackage.Companion.packageList } returns listOf(mockPackageWithoutDelay)
81
+
82
+ activityController = Robolectric.buildActivity(MockActivity::class.java)
83
+ .also {
84
+ val activity = it.get()
85
+ (activity.application as MockApplication).bindCurrentActivity(activity)
86
+ }
87
+ .setup()
88
+ val spyDelegateWrapper = activity.reactActivityDelegate as ReactActivityDelegateWrapper
89
+ verify { spyDelegateWrapper.invokeDelegateMethod("loadApp", arrayOf(String::class.java), arrayOf("main")) }
90
+ }
91
+
92
+ @Test
93
+ fun `should block loadApp until delayLoadAppHandler finished`() = runTest {
94
+ every { ExpoModulesPackage.Companion.packageList } returns listOf(mockPackageWithDelay)
95
+
96
+ val callbackSlot = slot<Runnable>()
97
+ every { delayLoadAppHandler.whenReady(capture(callbackSlot)) } answers {
98
+ // Don't call the callback immediately to simulate delay
99
+ }
100
+
101
+ activityController = Robolectric.buildActivity(MockActivity::class.java)
102
+ .also {
103
+ val activity = it.get()
104
+ (activity.application as MockApplication).bindCurrentActivity(activity)
105
+ }
106
+ .setup()
107
+
108
+ val spyDelegateWrapper = activity.reactActivityDelegate as ReactActivityDelegateWrapper
109
+
110
+ verify(exactly = 0) { spyDelegateWrapper.invokeDelegateMethod("loadApp", arrayOf(String::class.java), arrayOf("main")) }
111
+ callbackSlot.captured.run()
112
+ verify(exactly = 1) { spyDelegateWrapper.invokeDelegateMethod("loadApp", arrayOf(String::class.java), arrayOf("main")) }
113
+ }
114
+
115
+ @Test
116
+ fun `should call lifecycle methods in correct order with delay load`() = runTest {
117
+ every { ExpoModulesPackage.Companion.packageList } returns listOf(mockPackageWithDelay)
118
+
119
+ val callbackSlot = slot<Runnable>()
120
+ every { delayLoadAppHandler.whenReady(capture(callbackSlot)) } answers {
121
+ // Don't call the callback immediately to simulate delay
122
+ }
123
+
124
+ activityController = Robolectric.buildActivity(MockActivity::class.java)
125
+ .also {
126
+ val activity = it.get()
127
+ (activity.application as MockApplication).bindCurrentActivity(activity)
128
+ }
129
+ .setup()
130
+ val spyDelegateWrapper = activity.reactActivityDelegate as ReactActivityDelegateWrapper
131
+ val spyDelegate = spyDelegateWrapper.delegate
132
+
133
+ verify(exactly = 1) { spyDelegateWrapper.onCreate(any()) }
134
+ verify(exactly = 1) { spyDelegateWrapper.onResume() }
135
+ verify(exactly = 0) { spyDelegate.onResume() }
136
+
137
+ callbackSlot.captured.run()
138
+ verify(exactly = 2) { spyDelegateWrapper.onResume() }
139
+ verify(exactly = 1) { spyDelegate.onResume() }
140
+ verify(exactly = 0) { spyDelegateWrapper.onPause() }
141
+ verify(exactly = 0) { spyDelegateWrapper.onDestroy() }
142
+ verify(exactly = 0) { spyDelegate.onPause() }
143
+ verify(exactly = 0) { spyDelegate.onDestroy() }
144
+
145
+ activityController.pause().stop().destroy()
146
+ verify(exactly = 1) { spyDelegateWrapper.onPause() }
147
+ verify(exactly = 1) { spyDelegateWrapper.onDestroy() }
148
+ verify(exactly = 1) { spyDelegate.onPause() }
149
+ verify(exactly = 1) { spyDelegate.onDestroy() }
150
+ }
151
+
152
+ @Test
153
+ fun `should have normal lifecycle when no delayLoadHandler`() = runTest {
154
+ every { ExpoModulesPackage.Companion.packageList } returns listOf(mockPackageWithoutDelay)
155
+
156
+ activityController = Robolectric.buildActivity(MockActivity::class.java)
157
+ .also {
158
+ val activity = it.get()
159
+ (activity.application as MockApplication).bindCurrentActivity(activity)
160
+ }
161
+ .setup()
162
+ val spyDelegateWrapper = activity.reactActivityDelegate as ReactActivityDelegateWrapper
163
+ val spyDelegate = spyDelegateWrapper.delegate
164
+
165
+ verify(exactly = 1) { spyDelegateWrapper.onCreate(any()) }
166
+ verify(exactly = 1) { spyDelegateWrapper.onResume() }
167
+ verify(exactly = 1) { spyDelegate.onResume() }
168
+
169
+ activityController.pause().stop().destroy()
170
+ verify(exactly = 1) { spyDelegateWrapper.onPause() }
171
+ verify(exactly = 1) { spyDelegateWrapper.onDestroy() }
172
+ verify(exactly = 1) { spyDelegate.onPause() }
173
+ verify(exactly = 1) { spyDelegate.onDestroy() }
174
+ }
175
+
176
+ @Test
177
+ fun `should cancel pending resume if activity pause before delay load finished`() = runTest {
178
+ every { ExpoModulesPackage.Companion.packageList } returns listOf(mockPackageWithDelay)
179
+
180
+ val callbackSlot = slot<Runnable>()
181
+ every { delayLoadAppHandler.whenReady(capture(callbackSlot)) } answers {
182
+ // Don't call the callback immediately to simulate delay
183
+ }
184
+
185
+ activityController = Robolectric.buildActivity(MockActivity::class.java)
186
+ .also {
187
+ val activity = it.get()
188
+ (activity.application as MockApplication).bindCurrentActivity(activity)
189
+ }
190
+ .setup()
191
+ val spyDelegateWrapper = activity.reactActivityDelegate as ReactActivityDelegateWrapper
192
+ val spyDelegate = spyDelegateWrapper.delegate
193
+
194
+ verify(exactly = 1) { spyDelegateWrapper.onCreate(any()) }
195
+ verify(exactly = 1) { spyDelegateWrapper.onResume() }
196
+ verify(exactly = 0) { spyDelegate.onResume() }
197
+
198
+ activityController.pause()
199
+ callbackSlot.captured.run()
200
+ verify(exactly = 0) { spyDelegate.onResume() }
201
+ }
202
+
203
+ @Test
204
+ fun `should cancel pending resume if activity stop before delay load finished`() = runTest {
205
+ every { ExpoModulesPackage.Companion.packageList } returns listOf(mockPackageWithDelay)
206
+
207
+ val callbackSlot = slot<Runnable>()
208
+ every { delayLoadAppHandler.whenReady(capture(callbackSlot)) } answers {
209
+ // Don't call the callback immediately to simulate delay
210
+ }
211
+
212
+ activityController = Robolectric.buildActivity(MockActivity::class.java)
213
+ .also {
214
+ val activity = it.get()
215
+ (activity.application as MockApplication).bindCurrentActivity(activity)
216
+ }
217
+ .setup()
218
+ val spyDelegateWrapper = activity.reactActivityDelegate as ReactActivityDelegateWrapper
219
+ val spyDelegate = spyDelegateWrapper.delegate
220
+
221
+ verify(exactly = 1) { spyDelegateWrapper.onCreate(any()) }
222
+ verify(exactly = 1) { spyDelegateWrapper.onResume() }
223
+ verify(exactly = 0) { spyDelegate.onResume() }
224
+
225
+ activityController.pause().stop()
226
+ callbackSlot.captured.run()
227
+ verify(exactly = 0) { spyDelegate.onResume() }
228
+ }
229
+
230
+ @Test
231
+ fun `should cancel pending resume if activity destroy before delay load finished`() = runTest {
232
+ every { ExpoModulesPackage.Companion.packageList } returns listOf(mockPackageWithDelay)
233
+
234
+ val callbackSlot = slot<Runnable>()
235
+ every { delayLoadAppHandler.whenReady(capture(callbackSlot)) } answers {
236
+ // Don't call the callback immediately to simulate delay
237
+ }
238
+
239
+ activityController = Robolectric.buildActivity(MockActivity::class.java)
240
+ .also {
241
+ val activity = it.get()
242
+ (activity.application as MockApplication).bindCurrentActivity(activity)
243
+ }
244
+ .setup()
245
+ val spyDelegateWrapper = activity.reactActivityDelegate as ReactActivityDelegateWrapper
246
+ val spyDelegate = spyDelegateWrapper.delegate
247
+
248
+ verify(exactly = 1) { spyDelegateWrapper.onCreate(any()) }
249
+ verify(exactly = 1) { spyDelegateWrapper.onResume() }
250
+ verify(exactly = 0) { spyDelegate.onResume() }
251
+
252
+ activityController.pause().stop().destroy()
253
+ callbackSlot.captured.run()
254
+ verify(exactly = 0) { spyDelegate.onResume() }
255
+ }
256
+ }
257
+
258
+ internal class MockPackageWithDelayHandler(delayHandler: DelayLoadAppHandler) : Package {
259
+ private val reactActivityLifecycleListener = mockk<ReactActivityLifecycleListener>(relaxed = true)
260
+ private val reactActivityHandler = mockk<ReactActivityHandler>(relaxed = true)
261
+
262
+ init {
263
+ every { reactActivityHandler.createReactRootViewContainer(any()) } returns null
264
+ every { reactActivityHandler.onDidCreateReactActivityDelegate(any(), any()) } returns null
265
+ every { reactActivityHandler.getDelayLoadAppHandler(any(), any()) } returns delayHandler
266
+ }
267
+
268
+ override fun createReactActivityLifecycleListeners(activityContext: Context?): List<ReactActivityLifecycleListener> {
269
+ return listOf(reactActivityLifecycleListener)
270
+ }
271
+
272
+ override fun createReactActivityHandlers(activityContext: Context?): List<ReactActivityHandler> {
273
+ return listOf(reactActivityHandler)
274
+ }
275
+ }
276
+
277
+ internal class MockPackageWithoutDelayHandler : Package {
278
+ private val reactActivityLifecycleListener = mockk<ReactActivityLifecycleListener>(relaxed = true)
279
+ private val reactActivityHandler = mockk<ReactActivityHandler>(relaxed = true)
280
+
281
+ init {
282
+ every { reactActivityHandler.createReactRootViewContainer(any()) } returns null
283
+ every { reactActivityHandler.onDidCreateReactActivityDelegate(any(), any()) } returns null
284
+ every { reactActivityHandler.getDelayLoadAppHandler(any(), any()) } returns null
285
+ }
286
+
287
+ override fun createReactActivityLifecycleListeners(activityContext: Context?): List<ReactActivityLifecycleListener> {
288
+ return listOf(reactActivityLifecycleListener)
289
+ }
290
+
291
+ override fun createReactActivityHandlers(activityContext: Context?): List<ReactActivityHandler> {
292
+ return listOf(reactActivityHandler)
293
+ }
294
+ }
295
+
296
+ internal class MockApplication : Application(), ReactApplication {
297
+ private var currentActivity: Activity? = null
298
+
299
+ override fun onCreate() {
300
+ super.onCreate()
301
+ setTheme(androidx.appcompat.R.style.Theme_AppCompat)
302
+ }
303
+
304
+ internal fun bindCurrentActivity(activity: Activity?) {
305
+ currentActivity = activity
306
+ }
307
+
308
+ override val reactNativeHost: ReactNativeHost = mockk<ReactNativeHost>(relaxed = true)
309
+
310
+ override val reactHost: ReactHost by lazy {
311
+ mockk<ReactHost>(relaxed = true)
312
+ .also {
313
+ val mockReactSurface = mockk<ReactSurface>(relaxed = true)
314
+ every { mockReactSurface.view } returns ReactRootView(currentActivity)
315
+ every { it.createSurface(any(), any(), any()) } returns mockReactSurface
316
+ }
317
+ }
318
+ }
319
+
320
+ internal class MockActivity : ReactActivity() {
321
+ override fun getMainComponentName(): String = "main"
322
+
323
+ override fun createReactActivityDelegate(): ReactActivityDelegate = spyk(
324
+ ReactActivityDelegateWrapper(
325
+ this,
326
+ BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
327
+ spyk(
328
+ object : DefaultReactActivityDelegate(
329
+ this,
330
+ mainComponentName,
331
+ fabricEnabled
332
+ ) {}
333
+ )
334
+ )
335
+ )
336
+ }
@@ -1,8 +1,8 @@
1
1
  {
2
- "@expo/fingerprint": "~0.13.1",
2
+ "@expo/fingerprint": "~0.13.3",
3
3
  "@expo/metro-runtime": "~5.0.4",
4
4
  "@expo/vector-icons": "^14.1.0",
5
- "@expo/ui": "~0.1.1-alpha.9",
5
+ "@expo/ui": "~0.1.1-alpha.10",
6
6
  "@react-native-async-storage/async-storage": "2.1.2",
7
7
  "@react-native-community/datetimepicker": "8.4.1",
8
8
  "@react-native-masked-view/masked-view": "0.3.2",
@@ -17,34 +17,34 @@
17
17
  "expo-app-auth": "~11.1.0",
18
18
  "expo-app-loader-provider": "~8.0.0",
19
19
  "expo-apple-authentication": "~7.2.4",
20
- "expo-application": "~6.1.4",
21
- "expo-asset": "~11.1.5",
20
+ "expo-application": "~6.1.5",
21
+ "expo-asset": "~11.1.6",
22
22
  "expo-audio": "~0.4.7",
23
23
  "expo-auth-session": "~6.2.0",
24
- "expo-av": "~15.1.6",
25
- "expo-background-fetch": "~13.1.5",
26
- "expo-background-task": "~0.2.7",
24
+ "expo-av": "~15.1.7",
25
+ "expo-background-fetch": "~13.1.6",
26
+ "expo-background-task": "~0.2.8",
27
27
  "expo-battery": "~9.1.4",
28
28
  "expo-blur": "~14.1.5",
29
29
  "expo-brightness": "~13.1.4",
30
- "expo-build-properties": "~0.14.6",
30
+ "expo-build-properties": "~0.14.8",
31
31
  "expo-calendar": "~14.1.4",
32
- "expo-camera": "~16.1.9",
33
- "expo-cellular": "~7.1.4",
32
+ "expo-camera": "~16.1.10",
33
+ "expo-cellular": "~7.1.5",
34
34
  "expo-checkbox": "~4.1.4",
35
- "expo-clipboard": "~7.1.4",
35
+ "expo-clipboard": "~7.1.5",
36
36
  "expo-constants": "~17.1.6",
37
37
  "expo-contacts": "~14.2.5",
38
38
  "expo-crypto": "~14.1.5",
39
- "expo-dev-client": "~5.2.1",
39
+ "expo-dev-client": "~5.2.2",
40
40
  "expo-device": "~7.1.4",
41
41
  "expo-document-picker": "~13.1.6",
42
- "expo-file-system": "~18.1.10",
43
- "expo-font": "~13.3.1",
44
- "expo-gl": "~15.1.6",
42
+ "expo-file-system": "~18.1.11",
43
+ "expo-font": "~13.3.2",
44
+ "expo-gl": "~15.1.7",
45
45
  "expo-google-app-auth": "~8.3.0",
46
46
  "expo-haptics": "~14.1.4",
47
- "expo-image": "~2.3.0",
47
+ "expo-image": "~2.3.1",
48
48
  "expo-image-loader": "~5.1.0",
49
49
  "expo-image-manipulator": "~13.1.7",
50
50
  "expo-image-picker": "~16.1.4",
@@ -52,24 +52,24 @@
52
52
  "expo-insights": "~0.9.3",
53
53
  "expo-keep-awake": "~14.1.4",
54
54
  "expo-linear-gradient": "~14.1.5",
55
- "expo-linking": "~7.1.5",
56
- "expo-local-authentication": "~16.0.4",
57
- "expo-localization": "~16.1.5",
58
- "expo-location": "~18.1.5",
59
- "expo-mail-composer": "~14.1.4",
55
+ "expo-linking": "~7.1.6",
56
+ "expo-local-authentication": "~16.0.5",
57
+ "expo-localization": "~16.1.6",
58
+ "expo-location": "~18.1.6",
59
+ "expo-mail-composer": "~14.1.5",
60
60
  "expo-manifests": "~0.16.5",
61
61
  "expo-maps": "~0.11.0",
62
62
  "expo-media-library": "~17.1.7",
63
63
  "expo-mesh-gradient": "~0.3.4",
64
64
  "expo-module-template": "~10.16.6",
65
- "expo-modules-core": "~2.4.0",
65
+ "expo-modules-core": "~2.4.1",
66
66
  "expo-navigation-bar": "~4.2.6",
67
67
  "expo-network": "~7.1.5",
68
68
  "expo-notifications": "~0.31.3",
69
69
  "expo-print": "~14.1.4",
70
70
  "expo-live-photo": "~0.1.4",
71
- "expo-router": "~5.1.1",
72
- "expo-screen-capture": "~7.1.4",
71
+ "expo-router": "~5.1.2",
72
+ "expo-screen-capture": "~7.1.5",
73
73
  "expo-screen-orientation": "~8.1.7",
74
74
  "expo-secure-store": "~14.2.3",
75
75
  "expo-sensors": "~14.1.4",
@@ -77,18 +77,18 @@
77
77
  "expo-sms": "~13.1.4",
78
78
  "expo-speech": "~13.1.7",
79
79
  "expo-splash-screen": "~0.30.9",
80
- "expo-sqlite": "~15.2.12",
80
+ "expo-sqlite": "~15.2.13",
81
81
  "expo-status-bar": "~2.2.3",
82
82
  "expo-store-review": "~8.1.5",
83
83
  "expo-symbols": "~0.4.5",
84
84
  "expo-system-ui": "~5.0.9",
85
- "expo-task-manager": "~13.1.5",
85
+ "expo-task-manager": "~13.1.6",
86
86
  "expo-tracking-transparency": "~5.2.4",
87
87
  "expo-updates": "~0.28.15",
88
88
  "expo-video-thumbnails": "~9.1.3",
89
89
  "expo-video": "~2.2.2",
90
90
  "expo-web-browser": "~14.2.0",
91
- "jest-expo": "~53.0.7",
91
+ "jest-expo": "~53.0.8",
92
92
  "lottie-react-native": "7.2.2",
93
93
  "react": "19.0.0",
94
94
  "react-dom": "19.0.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo",
3
- "version": "53.0.13",
3
+ "version": "53.0.15",
4
4
  "description": "The Expo SDK",
5
5
  "main": "src/Expo.ts",
6
6
  "module": "src/Expo.ts",
@@ -73,20 +73,20 @@
73
73
  "homepage": "https://github.com/expo/expo/tree/main/packages/expo",
74
74
  "dependencies": {
75
75
  "@babel/runtime": "^7.20.0",
76
- "@expo/cli": "0.24.15",
77
- "@expo/config": "~11.0.10",
76
+ "@expo/cli": "0.24.16",
77
+ "@expo/config": "~11.0.11",
78
78
  "@expo/config-plugins": "~10.0.3",
79
- "@expo/fingerprint": "0.13.1",
79
+ "@expo/fingerprint": "0.13.3",
80
80
  "@expo/metro-config": "0.20.15",
81
81
  "@expo/vector-icons": "^14.0.0",
82
82
  "babel-preset-expo": "~13.2.1",
83
- "expo-asset": "~11.1.5",
83
+ "expo-asset": "~11.1.6",
84
84
  "expo-constants": "~17.1.6",
85
- "expo-file-system": "~18.1.10",
86
- "expo-font": "~13.3.1",
85
+ "expo-file-system": "~18.1.11",
86
+ "expo-font": "~13.3.2",
87
87
  "expo-keep-awake": "~14.1.4",
88
- "expo-modules-autolinking": "2.1.12",
89
- "expo-modules-core": "2.4.0",
88
+ "expo-modules-autolinking": "2.1.13",
89
+ "expo-modules-core": "2.4.1",
90
90
  "react-native-edge-to-edge": "1.6.0",
91
91
  "whatwg-url-without-unicode": "8.0.0-3"
92
92
  },
@@ -119,5 +119,5 @@
119
119
  "optional": true
120
120
  }
121
121
  },
122
- "gitHead": "ee5bda70ea20e9f1411fbdc346fca76d9ac57f57"
122
+ "gitHead": "ceb57fc0dd75de75dc7006e20792b29e947226f3"
123
123
  }