@callstack/react-native-brownfield 5.3.0 → 6.0.1

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.
Files changed (97) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/android/build.gradle +1 -0
  3. package/android/consumer-rules.pro +6 -0
  4. package/android/src/main/java/com/callstack/reactnativebrownfield/BrownfieldPerformance.kt +384 -0
  5. package/android/src/main/java/com/callstack/reactnativebrownfield/BrownfieldPerformanceModule.kt +15 -0
  6. package/android/src/main/java/com/callstack/reactnativebrownfield/ReactDelegateWrapper.kt +8 -5
  7. package/android/src/main/java/com/callstack/reactnativebrownfield/ReactNativeBrownfield.kt +78 -23
  8. package/android/src/main/java/com/callstack/reactnativebrownfield/ReactNativeBrownfieldPackage.kt +1 -0
  9. package/android/src/main/java/com/callstack/reactnativebrownfield/ReactNativeFragment.kt +89 -3
  10. package/android/src/main/java/com/callstack/reactnativebrownfield/constants/ReactNativeFragmentArgNames.kt +2 -0
  11. package/android/src/test/java/com/callstack/reactnativebrownfield/PerformanceMetricsTest.kt +54 -0
  12. package/gradle-plugin/brownfield/gradle.properties +1 -1
  13. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNSourceSets.kt +59 -15
  14. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/AndroidArchiveLibrary.kt +41 -4
  15. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/AgpCompatibilityMatrixTest.kt +6 -8
  16. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/LegacyConsumerConfigurationTest.kt +6 -4
  17. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/RNBundleTaskStubs.kt +27 -0
  18. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/utils/AndroidArchiveLibraryNamespaceFallbackTest.kt +137 -0
  19. package/ios/BrownfieldDisplayMetrics.swift +265 -0
  20. package/ios/BrownfieldPerformanceBridge.h +8 -0
  21. package/ios/BrownfieldPerformanceBridge.mm +103 -0
  22. package/ios/Expo/ExpoHostRuntime.swift +60 -14
  23. package/ios/Expo/ExpoUpdatesStartup.swift +50 -0
  24. package/ios/JSBundleLoadObserver.swift +56 -19
  25. package/ios/JSBundleTimingObserver.h +26 -0
  26. package/ios/JSBundleTimingObserver.m +140 -0
  27. package/ios/ReactHostPreloading.swift +7 -3
  28. package/ios/ReactNativeBrownfield.swift +82 -14
  29. package/ios/ReactNativeBrownfield.xcodeproj/project.pbxproj +16 -0
  30. package/ios/Vanilla/ReactNativeHostRuntime.swift +1 -1
  31. package/ios/Views/ReactNativeView.swift +21 -3
  32. package/ios/Views/ReactNativeViewController.swift +44 -42
  33. package/lib/commonjs/expo-config-plugin/android/utils/constants.js +1 -1
  34. package/lib/commonjs/expo-config-plugin/ios/podfileHelpers.js +2 -2
  35. package/lib/commonjs/expo-config-plugin/ios/podfileHelpers.js.map +1 -1
  36. package/lib/commonjs/expo-config-plugin/ios/withIosFrameworkFiles.js +1 -1
  37. package/lib/commonjs/expo-config-plugin/ios/withIosFrameworkFiles.js.map +1 -1
  38. package/lib/commonjs/expo-config-plugin/ios/xcodeHelpers.js +2 -2
  39. package/lib/commonjs/expo-config-plugin/ios/xcodeHelpers.js.map +1 -1
  40. package/lib/commonjs/expo-config-plugin/template/android/ReactNativeHostManager.vanilla.kt +5 -7
  41. package/lib/commonjs/expo-config-plugin/template/android/consumer-rules.pro +7 -0
  42. package/lib/commonjs/expo-config-plugin/template/ios/ExpoModulesProviderExtension.swift +7 -0
  43. package/lib/commonjs/index.js +1 -1
  44. package/lib/commonjs/index.js.map +1 -1
  45. package/lib/commonjs/scaffold/index.js +1 -1
  46. package/lib/commonjs/scaffold/index.js.map +1 -1
  47. package/lib/commonjs/scaffold/projectFiles.js +1 -1
  48. package/lib/commonjs/scaffold/projectFiles.js.map +1 -1
  49. package/lib/module/expo-config-plugin/android/utils/constants.js +1 -1
  50. package/lib/module/expo-config-plugin/ios/podfileHelpers.js +2 -2
  51. package/lib/module/expo-config-plugin/ios/podfileHelpers.js.map +1 -1
  52. package/lib/module/expo-config-plugin/ios/withIosFrameworkFiles.js +1 -1
  53. package/lib/module/expo-config-plugin/ios/withIosFrameworkFiles.js.map +1 -1
  54. package/lib/module/expo-config-plugin/ios/xcodeHelpers.js +2 -2
  55. package/lib/module/expo-config-plugin/ios/xcodeHelpers.js.map +1 -1
  56. package/lib/module/expo-config-plugin/template/android/ReactNativeHostManager.vanilla.kt +5 -7
  57. package/lib/module/expo-config-plugin/template/android/consumer-rules.pro +7 -0
  58. package/lib/module/expo-config-plugin/template/ios/ExpoModulesProviderExtension.swift +7 -0
  59. package/lib/module/index.js +1 -1
  60. package/lib/module/index.js.map +1 -1
  61. package/lib/module/scaffold/index.js +1 -1
  62. package/lib/module/scaffold/index.js.map +1 -1
  63. package/lib/module/scaffold/projectFiles.js +1 -1
  64. package/lib/module/scaffold/projectFiles.js.map +1 -1
  65. package/lib/typescript/commonjs/src/expo-config-plugin/android/utils/constants.d.ts +2 -2
  66. package/lib/typescript/commonjs/src/expo-config-plugin/ios/podfileHelpers.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/src/expo-config-plugin/ios/withIosFrameworkFiles.d.ts +5 -0
  68. package/lib/typescript/commonjs/src/expo-config-plugin/ios/withIosFrameworkFiles.d.ts.map +1 -1
  69. package/lib/typescript/commonjs/src/expo-config-plugin/ios/xcodeHelpers.d.ts +5 -0
  70. package/lib/typescript/commonjs/src/expo-config-plugin/ios/xcodeHelpers.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/src/index.d.ts +7 -0
  72. package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
  73. package/lib/typescript/commonjs/src/scaffold/index.d.ts.map +1 -1
  74. package/lib/typescript/commonjs/src/scaffold/projectFiles.d.ts +2 -2
  75. package/lib/typescript/commonjs/src/scaffold/projectFiles.d.ts.map +1 -1
  76. package/lib/typescript/module/src/expo-config-plugin/android/utils/constants.d.ts +2 -2
  77. package/lib/typescript/module/src/expo-config-plugin/ios/podfileHelpers.d.ts.map +1 -1
  78. package/lib/typescript/module/src/expo-config-plugin/ios/withIosFrameworkFiles.d.ts +5 -0
  79. package/lib/typescript/module/src/expo-config-plugin/ios/withIosFrameworkFiles.d.ts.map +1 -1
  80. package/lib/typescript/module/src/expo-config-plugin/ios/xcodeHelpers.d.ts +5 -0
  81. package/lib/typescript/module/src/expo-config-plugin/ios/xcodeHelpers.d.ts.map +1 -1
  82. package/lib/typescript/module/src/index.d.ts +7 -0
  83. package/lib/typescript/module/src/index.d.ts.map +1 -1
  84. package/lib/typescript/module/src/scaffold/index.d.ts.map +1 -1
  85. package/lib/typescript/module/src/scaffold/projectFiles.d.ts +2 -2
  86. package/lib/typescript/module/src/scaffold/projectFiles.d.ts.map +1 -1
  87. package/package.json +2 -2
  88. package/src/expo-config-plugin/android/utils/constants.ts +1 -1
  89. package/src/expo-config-plugin/ios/podfileHelpers.ts +47 -13
  90. package/src/expo-config-plugin/ios/withIosFrameworkFiles.ts +11 -1
  91. package/src/expo-config-plugin/ios/xcodeHelpers.ts +67 -9
  92. package/src/expo-config-plugin/template/android/ReactNativeHostManager.vanilla.kt +5 -7
  93. package/src/expo-config-plugin/template/android/consumer-rules.pro +7 -0
  94. package/src/expo-config-plugin/template/ios/ExpoModulesProviderExtension.swift +7 -0
  95. package/src/index.ts +13 -1
  96. package/src/scaffold/index.ts +60 -4
  97. package/src/scaffold/projectFiles.ts +5 -3
@@ -2,6 +2,7 @@ package com.callstack.reactnativebrownfield
2
2
 
3
3
  import android.app.Application
4
4
  import android.os.Bundle
5
+ import android.view.WindowManager
5
6
  import android.widget.FrameLayout
6
7
  import androidx.activity.OnBackPressedCallback
7
8
  import androidx.fragment.app.FragmentActivity
@@ -20,7 +21,7 @@ import java.util.concurrent.CopyOnWriteArrayList
20
21
  import java.util.concurrent.atomic.AtomicBoolean
21
22
 
22
23
  fun interface OnJSBundleLoaded {
23
- operator fun invoke(initialized: Boolean)
24
+ operator fun invoke(timings: JSBundleTimings)
24
25
  }
25
26
 
26
27
  fun interface OnMessageListener {
@@ -34,6 +35,7 @@ class ReactNativeBrownfield private constructor(val reactHost: ReactHost) {
34
35
  private lateinit var instance: ReactNativeBrownfield
35
36
  private val initialized = AtomicBoolean()
36
37
  private val nativeLibsLoaded = AtomicBoolean()
38
+ private lateinit var applicationContext: Application
37
39
 
38
40
  @JvmStatic
39
41
  val shared: ReactNativeBrownfield get() = instance
@@ -62,7 +64,9 @@ class ReactNativeBrownfield private constructor(val reactHost: ReactHost) {
62
64
  reactHost: ReactHost,
63
65
  onJSBundleLoaded: OnJSBundleLoaded? = null
64
66
  ) {
67
+ applicationContext = application
65
68
  if (!initialized.getAndSet(true)) {
69
+ AndroidJSBundleTimingObserver.install()
66
70
  loadNativeLibs(application)
67
71
  installAndPreload(reactHost, onJSBundleLoaded)
68
72
  } else {
@@ -76,14 +80,16 @@ class ReactNativeBrownfield private constructor(val reactHost: ReactHost) {
76
80
  onJSBundleLoaded: OnJSBundleLoaded? = null,
77
81
  reactHostFactory: () -> ReactHost
78
82
  ) {
83
+ applicationContext = application
79
84
  if (!initialized.getAndSet(true)) {
85
+ AndroidJSBundleTimingObserver.install()
80
86
  loadNativeLibs(application)
81
87
  installAndPreload(reactHostFactory(), onJSBundleLoaded)
82
88
  } else {
83
89
  invokeWhenJSBundleLoaded(onJSBundleLoaded)
84
90
  }
85
91
  }
86
-
92
+
87
93
  @JvmStatic
88
94
  @JvmOverloads
89
95
  fun initialize(
@@ -91,7 +97,9 @@ class ReactNativeBrownfield private constructor(val reactHost: ReactHost) {
91
97
  options: HashMap<String, Any>,
92
98
  onJSBundleLoaded: OnJSBundleLoaded? = null
93
99
  ) {
100
+ applicationContext = application
94
101
  if (!initialized.getAndSet(true)) {
102
+ AndroidJSBundleTimingObserver.install()
95
103
  loadNativeLibs(application)
96
104
  val reactHost = getDefaultReactHost(
97
105
  context = application,
@@ -123,11 +131,11 @@ class ReactNativeBrownfield private constructor(val reactHost: ReactHost) {
123
131
  initialize(application, options, onJSBundleLoaded)
124
132
  }
125
133
 
126
- private fun preloadReactNative(callback: ((Boolean) -> Unit)) {
134
+ private fun preloadReactNative(generation: Int) {
127
135
  shared.reactHost.addReactInstanceEventListener(object :
128
136
  ReactInstanceEventListener {
129
137
  override fun onReactContextInitialized(context: ReactContext) {
130
- callback(true)
138
+ AndroidJSBundleTimingObserver.contextInitialized(context, generation)
131
139
  shared.reactHost.removeReactInstanceEventListener(this)
132
140
  }
133
141
  })
@@ -135,10 +143,12 @@ class ReactNativeBrownfield private constructor(val reactHost: ReactHost) {
135
143
  }
136
144
 
137
145
  private fun installAndPreload(reactHost: ReactHost, onJSBundleLoaded: OnJSBundleLoaded?) {
146
+ AndroidJSBundleTimingObserver.install()
147
+ val generation = AndroidJSBundleTimingObserver.beginGeneration()
138
148
  instance = ReactNativeBrownfield(reactHost)
139
- preloadReactNative {
140
- onJSBundleLoaded?.invoke(true)
141
- }
149
+ reactHost.installBrownfieldPerformanceStop()
150
+ AndroidJSBundleTimingObserver.subscribe(onJSBundleLoaded)
151
+ preloadReactNative(generation)
142
152
  }
143
153
 
144
154
  private fun invokeWhenJSBundleLoaded(onJSBundleLoaded: OnJSBundleLoaded?) {
@@ -146,18 +156,21 @@ class ReactNativeBrownfield private constructor(val reactHost: ReactHost) {
146
156
  return
147
157
  }
148
158
 
149
- val reactHost = instance.reactHost
150
- reactHost.currentReactContext?.let {
151
- onJSBundleLoaded.invoke(true)
152
- return
153
- }
159
+ AndroidJSBundleTimingObserver.subscribe(onJSBundleLoaded)
160
+ }
154
161
 
155
- reactHost.addReactInstanceEventListener(object : ReactInstanceEventListener {
156
- override fun onReactContextInitialized(context: ReactContext) {
157
- onJSBundleLoaded.invoke(true)
158
- reactHost.removeReactInstanceEventListener(this)
159
- }
160
- })
162
+ internal fun currentReactContext(): ReactContext? =
163
+ if (::instance.isInitialized) instance.reactHost.currentReactContext else null
164
+
165
+ internal fun displayFrameIntervalNanos(): Long {
166
+ if (!::applicationContext.isInitialized) return 1_000_000_000L / 60
167
+ @Suppress("DEPRECATION")
168
+ val refreshRate = applicationContext
169
+ .getSystemService(WindowManager::class.java)
170
+ ?.defaultDisplay
171
+ ?.refreshRate
172
+ ?.takeIf { it > 0f } ?: 60f
173
+ return (1_000_000_000.0 / refreshRate).toLong()
161
174
  }
162
175
  }
163
176
 
@@ -215,10 +228,47 @@ class ReactNativeBrownfield private constructor(val reactHost: ReactHost) {
215
228
  reactDelegate: ReactDelegateWrapper?,
216
229
  launchOptions: Bundle?,
217
230
  lifecycleOwner: LifecycleOwner?,
231
+ ): FrameLayout {
232
+ return createViewInternal(
233
+ activity, moduleName, reactDelegate, launchOptions, lifecycleOwner, null, false
234
+ )
235
+ }
236
+
237
+ @JvmOverloads
238
+ fun createView(
239
+ activity: FragmentActivity?,
240
+ moduleName: String,
241
+ reactDelegate: ReactDelegateWrapper? = null,
242
+ launchOptions: Bundle? = null,
243
+ waitForFullDisplay: Boolean,
244
+ collectThreadMetrics: Boolean = false,
245
+ onMetrics: OnDisplayMetrics? = null,
246
+ ): FrameLayout {
247
+ val session = BrownfieldDisplaySession(
248
+ moduleName, waitForFullDisplay, collectThreadMetrics, onMetrics
249
+ )
250
+ val props = Bundle(launchOptions ?: Bundle()).apply {
251
+ putString("brownfieldPresentationID", session.id)
252
+ }
253
+ return createViewInternal(activity, moduleName, reactDelegate, props, activity, session, false)
254
+ }
255
+
256
+ internal fun createViewInternal(
257
+ activity: FragmentActivity?,
258
+ moduleName: String,
259
+ reactDelegate: ReactDelegateWrapper?,
260
+ launchOptions: Bundle?,
261
+ lifecycleOwner: LifecycleOwner?,
262
+ session: BrownfieldDisplaySession?,
263
+ requiresResume: Boolean,
218
264
  ): FrameLayout {
219
265
  val reactHost = shared.reactHost
220
- val resolvedDelegate =
221
- reactDelegate ?: ReactDelegateWrapper(activity, reactHost, moduleName, launchOptions)
266
+ val resolvedDelegate = when {
267
+ reactDelegate == null -> ReactDelegateWrapper(activity, reactHost, moduleName, launchOptions)
268
+ session != null && reactDelegate.brownfieldLaunchOptions !== launchOptions ->
269
+ reactDelegate.withLaunchOptions(launchOptions)
270
+ else -> reactDelegate
271
+ }
222
272
 
223
273
  val backPressedCallback: OnBackPressedCallback = object : OnBackPressedCallback(true) {
224
274
  override fun handleOnBackPressed() {
@@ -248,24 +298,29 @@ class ReactNativeBrownfield private constructor(val reactHost: ReactHost) {
248
298
  * In such a case, we set the lifeCycle observer.
249
299
  */
250
300
  if (reactDelegate == null) {
251
- lifecycleOwner?.lifecycle?.addObserver(getLifeCycleObserver(resolvedDelegate))
301
+ lifecycleOwner?.lifecycle?.addObserver(getLifeCycleObserver(resolvedDelegate, session))
252
302
  }
253
303
 
254
304
  resolvedDelegate.loadApp()
255
- return resolvedDelegate.reactRootView!!
305
+ return resolvedDelegate.reactRootView!!.also { session?.bind(it, requiresResume) }
256
306
  }
257
307
 
258
- private fun getLifeCycleObserver(reactDelegate: ReactDelegateWrapper): DefaultLifecycleObserver {
308
+ private fun getLifeCycleObserver(
309
+ reactDelegate: ReactDelegateWrapper,
310
+ session: BrownfieldDisplaySession?
311
+ ): DefaultLifecycleObserver {
259
312
  return object : DefaultLifecycleObserver {
260
313
  override fun onResume(owner: LifecycleOwner) {
261
314
  reactDelegate.onReactHostResume()
262
315
  }
263
316
 
264
317
  override fun onPause(owner: LifecycleOwner) {
318
+ session?.cancel()
265
319
  reactDelegate.onHostPause()
266
320
  }
267
321
 
268
322
  override fun onDestroy(owner: LifecycleOwner) {
323
+ session?.cancel()
269
324
  reactDelegate.onHostDestroy()
270
325
  owner.lifecycle.removeObserver(this) // Cleanup to avoid leaks
271
326
  }
@@ -17,6 +17,7 @@ class ReactNativeBrownfieldPackage : ReactPackage {
17
17
  override fun createNativeModules(reactContext: ReactApplicationContext): MutableList<NativeModule> {
18
18
  val modules = ArrayList<NativeModule>()
19
19
  modules.add(ReactNativeBrownfieldModule(reactContext))
20
+ modules.add(BrownfieldPerformanceModule(reactContext))
20
21
  return modules
21
22
  }
22
23
  }
@@ -17,6 +17,10 @@ class ReactNativeFragment : ReactFragment(), PermissionAwareActivity {
17
17
  private lateinit var permissionsCallback: Callback
18
18
  private var permissionListener: PermissionListener? = null
19
19
  private lateinit var moduleName: String
20
+ private var displaySession: BrownfieldDisplaySession? = null
21
+
22
+ /** Reattach after Fragment restoration; callbacks are intentionally not serialized. */
23
+ var onMetrics: OnDisplayMetrics? = null
20
24
 
21
25
  override fun onCreate(savedInstanceState: Bundle?) {
22
26
  /**
@@ -51,12 +55,34 @@ class ReactNativeFragment : ReactFragment(), PermissionAwareActivity {
51
55
  container: ViewGroup?,
52
56
  savedInstanceState: Bundle?
53
57
  ): View {
54
- return ReactNativeBrownfield.shared.createView(
58
+ val args = arguments
59
+ // Only the measured factories set this argument. Plain fragments keep their props as is.
60
+ val isMeasured = args?.containsKey(ReactNativeFragmentArgNames.ARG_WAIT_FOR_FULL_DISPLAY) == true
61
+ val session = if (isMeasured) {
62
+ BrownfieldDisplaySession(
63
+ moduleName,
64
+ args!!.getBoolean(ReactNativeFragmentArgNames.ARG_WAIT_FOR_FULL_DISPLAY, false),
65
+ args.getBoolean(ReactNativeFragmentArgNames.ARG_COLLECT_THREAD_METRICS, false),
66
+ onMetrics
67
+ )
68
+ } else {
69
+ null
70
+ }
71
+ displaySession = session
72
+ val launchOptions = Bundle(
73
+ args?.getBundle(ReactNativeFragmentArgNames.ARG_LAUNCH_OPTIONS) ?: Bundle()
74
+ ).apply { session?.let { putString("brownfieldPresentationID", it.id) } }
75
+ this.reactDelegate = ReactDelegateWrapper(
76
+ activity, this.reactHost, moduleName, launchOptions
77
+ )
78
+ return ReactNativeBrownfield.shared.createViewInternal(
55
79
  activity,
56
80
  moduleName,
57
81
  this.reactDelegate as ReactDelegateWrapper,
58
- launchOptions = null,
59
- lifecycleOwner = viewLifecycleOwner
82
+ launchOptions,
83
+ viewLifecycleOwner,
84
+ session,
85
+ true
60
86
  )
61
87
  }
62
88
 
@@ -69,9 +95,11 @@ class ReactNativeFragment : ReactFragment(), PermissionAwareActivity {
69
95
  } catch (_: ClassCastException) {
70
96
  (this.reactDelegate as ReactDelegateWrapper).onReactHostResume()
71
97
  }
98
+ displaySession?.setResumed(true)
72
99
  }
73
100
 
74
101
  override fun onPause() {
102
+ displaySession?.cancel()
75
103
  try {
76
104
  super.onPause()
77
105
  } catch (error: AssertionError) {
@@ -91,6 +119,12 @@ class ReactNativeFragment : ReactFragment(), PermissionAwareActivity {
91
119
  }
92
120
  }
93
121
 
122
+ override fun onDestroyView() {
123
+ displaySession?.cancel()
124
+ displaySession = null
125
+ super.onDestroyView()
126
+ }
127
+
94
128
  override fun onRequestPermissionsResult(
95
129
  requestCode: Int,
96
130
  permissions: Array<String>,
@@ -143,6 +177,26 @@ class ReactNativeFragment : ReactFragment(), PermissionAwareActivity {
143
177
  return fragment
144
178
  }
145
179
 
180
+ @JvmStatic
181
+ @JvmOverloads
182
+ fun createReactNativeFragment(
183
+ moduleName: String,
184
+ initialProps: Bundle?,
185
+ waitForFullDisplay: Boolean,
186
+ collectThreadMetrics: Boolean = false,
187
+ onMetrics: OnDisplayMetrics? = null
188
+ ): ReactNativeFragment {
189
+ return createReactNativeFragment(moduleName, initialProps).also { fragment ->
190
+ fragment.arguments?.putBoolean(
191
+ ReactNativeFragmentArgNames.ARG_WAIT_FOR_FULL_DISPLAY, waitForFullDisplay
192
+ )
193
+ fragment.arguments?.putBoolean(
194
+ ReactNativeFragmentArgNames.ARG_COLLECT_THREAD_METRICS, collectThreadMetrics
195
+ )
196
+ fragment.onMetrics = onMetrics
197
+ }
198
+ }
199
+
146
200
  @JvmStatic
147
201
  fun createReactNativeFragment(
148
202
  moduleName: String,
@@ -151,6 +205,22 @@ class ReactNativeFragment : ReactFragment(), PermissionAwareActivity {
151
205
  return createReactNativeFragment(moduleName, PropsBundle.fromHashMap(initialProps))
152
206
  }
153
207
 
208
+ @JvmStatic
209
+ @JvmOverloads
210
+ fun createReactNativeFragment(
211
+ moduleName: String,
212
+ initialProps: HashMap<String, *>,
213
+ waitForFullDisplay: Boolean,
214
+ collectThreadMetrics: Boolean = false,
215
+ onMetrics: OnDisplayMetrics? = null
216
+ ): ReactNativeFragment = createReactNativeFragment(
217
+ moduleName,
218
+ PropsBundle.fromHashMap(initialProps),
219
+ waitForFullDisplay,
220
+ collectThreadMetrics,
221
+ onMetrics
222
+ )
223
+
154
224
  @JvmStatic
155
225
  fun createReactNativeFragment(
156
226
  moduleName: String,
@@ -158,5 +228,21 @@ class ReactNativeFragment : ReactFragment(), PermissionAwareActivity {
158
228
  ): ReactNativeFragment {
159
229
  return createReactNativeFragment(moduleName, initialProps.toHashMap())
160
230
  }
231
+
232
+ @JvmStatic
233
+ @JvmOverloads
234
+ fun createReactNativeFragment(
235
+ moduleName: String,
236
+ initialProps: WritableMap,
237
+ waitForFullDisplay: Boolean,
238
+ collectThreadMetrics: Boolean = false,
239
+ onMetrics: OnDisplayMetrics? = null
240
+ ): ReactNativeFragment = createReactNativeFragment(
241
+ moduleName,
242
+ initialProps.toHashMap(),
243
+ waitForFullDisplay,
244
+ collectThreadMetrics,
245
+ onMetrics
246
+ )
161
247
  }
162
248
  }
@@ -16,5 +16,7 @@ class ReactNativeFragmentArgNames private constructor() :
16
16
 
17
17
  // re-export constants from ReactFragment to enable access
18
18
  const val ARG_LAUNCH_OPTIONS: String = "arg_launch_options"
19
+ const val ARG_WAIT_FOR_FULL_DISPLAY = "arg_wait_for_full_display"
20
+ const val ARG_COLLECT_THREAD_METRICS = "arg_collect_thread_metrics"
19
21
  }
20
22
  }
@@ -0,0 +1,54 @@
1
+ package com.callstack.reactnativebrownfield
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertNull
5
+ import org.junit.Test
6
+
7
+ class PerformanceMetricsTest {
8
+ @Test fun `pairs markers and sorts overlapping timeline`() {
9
+ val samples = JSBundleTimingAccumulator()
10
+ samples.record(BundleMarker.EVALUATION_START, 1, 20)
11
+ samples.record(BundleMarker.DOWNLOAD_START, 0, 10)
12
+ samples.record(BundleMarker.EVALUATION_END, 1, 40)
13
+ samples.record(BundleMarker.DOWNLOAD_END, 0, 30)
14
+
15
+ val result = samples.snapshot()
16
+ assertEquals(20.0, result.jsBundleLoadTime!!, 0.0)
17
+ assertEquals(20.0, result.jsBundleEvaluationTime!!, 0.0)
18
+ assertEquals(listOf("DOWNLOAD", "RUN_JS_BUNDLE"), result.timeline.map { it["tag"] })
19
+ }
20
+
21
+ @Test fun `keeps zero duration and rejects missing ambiguous and other instances`() {
22
+ val zero = JSBundleTimingAccumulator().apply {
23
+ record(BundleMarker.EVALUATION_START, 1, 5)
24
+ record(BundleMarker.EVALUATION_END, 1, 5)
25
+ }.snapshot()
26
+ assertEquals(0.0, zero.jsBundleEvaluationTime!!, 0.0)
27
+
28
+ val missing = JSBundleTimingAccumulator().apply {
29
+ record(BundleMarker.DOWNLOAD_END, 0, 8)
30
+ record(BundleMarker.EVALUATION_START, 2, 9)
31
+ record(BundleMarker.EVALUATION_END, 2, 10)
32
+ }.snapshot()
33
+ assertNull(missing.jsBundleLoadTime)
34
+ assertNull(missing.jsBundleEvaluationTime)
35
+
36
+ val ambiguous = JSBundleTimingAccumulator().apply {
37
+ record(BundleMarker.DOWNLOAD_START, 0, 1)
38
+ record(BundleMarker.DOWNLOAD_START, 0, 2)
39
+ record(BundleMarker.DOWNLOAD_END, 0, 3)
40
+ }.snapshot()
41
+ assertNull(ambiguous.jsBundleLoadTime)
42
+ }
43
+
44
+ @Test fun `frame accumulator includes unfinished deadline excess`() {
45
+ val samples = BrownfieldFrameSamples()
46
+ samples.begin(0)
47
+ samples.record(20_000_000, 10_000_000)
48
+ val result = samples.snapshot(50_000_000)
49
+ assertEquals(20.0, result.fps!!, 0.001)
50
+ assertEquals(0.6, result.busyRatio!!, 0.001)
51
+ assertEquals(50.0, result.sampledMs, 0.001)
52
+ assertEquals(1, result.frameCount)
53
+ }
54
+ }
@@ -1,6 +1,6 @@
1
1
  PROJECT_ID=com.callstack.react.brownfield
2
2
  ARTIFACT_ID=brownfield-gradle-plugin
3
- VERSION=2.0.0-alpha10
3
+ VERSION=2.0.0-alpha11
4
4
  GROUP=com.callstack.react
5
5
  IMPLEMENTATION_CLASS=com.callstack.react.brownfield.plugin.RNBrownfieldPlugin
6
6
 
@@ -3,13 +3,16 @@ package com.callstack.react.brownfield.plugin
3
3
  import com.android.build.api.dsl.AndroidSourceSet
4
4
  import com.android.build.api.dsl.LibraryExtension
5
5
  import com.android.build.api.variant.LibraryAndroidComponentsExtension
6
+ import com.android.build.api.variant.SourceDirectories
6
7
  import com.callstack.react.brownfield.exceptions.NameSpaceNotFound
7
8
  import com.callstack.react.brownfield.processors.VariantHelper
8
9
  import com.callstack.react.brownfield.utils.Extension
9
10
  import com.callstack.react.brownfield.utils.Utils
10
11
  import com.callstack.react.brownfield.utils.capitalized
12
+ import org.gradle.api.GradleException
11
13
  import org.gradle.api.Project
12
14
  import org.gradle.api.Task
15
+ import org.gradle.api.file.DirectoryProperty
13
16
  import org.gradle.api.tasks.Copy
14
17
  import java.io.File
15
18
 
@@ -63,26 +66,67 @@ object RNSourceSets {
63
66
  val capitalizedBundledAssetsVariantName = bundledAssetsVariantName.capitalized()
64
67
  val appProject = getAppProject()
65
68
 
66
- val bundlePathSegments =
67
- listOf(
68
- // outputs for RN <= 0.81
69
- "createBundle${capitalizedBundledAssetsVariantName}JsAndAssets",
70
- // outputs for RN >= 0.82
71
- "react/$bundledAssetsVariantName",
72
- )
69
+ val jsBundleTaskName = "createBundle${capitalizedBundledAssetsVariantName}JsAndAssets"
73
70
 
74
- val appBuildDir = getAppBuildDir()
75
- bundlePathSegments.forEach { segment ->
76
- variant.sources.assets?.addStaticSourceDirectory("$appBuildDir/generated/assets/$segment")
77
- variant.sources.res?.addStaticSourceDirectory("$appBuildDir/generated/res/$segment")
78
- }
71
+ /**
72
+ * Wire the bundle task's own `jsBundleDir`/`resourcesDir` outputs as *generated*
73
+ * source directories
74
+ */
75
+ wireGeneratedSourceDirectory(appProject, jsBundleTaskName, "getJsBundleDir", variant.sources.assets)
76
+ wireGeneratedSourceDirectory(appProject, jsBundleTaskName, "getResourcesDir", variant.sources.res)
79
77
 
80
78
  if (Utils.hasExpoUpdates(appProject, variantName)) {
81
- val updateResourcesPathSegment = VariantHelper.getExpoUpdatesResourcesTaskName(variant.name)
82
- variant.sources.assets?.addStaticSourceDirectory("$appBuildDir/generated/assets/$updateResourcesPathSegment")
83
- variant.sources.res?.addStaticSourceDirectory("$appBuildDir/generated/res/$updateResourcesPathSegment")
79
+ /**
80
+ * expo-updates registers `create<Variant>UpdatesResources` as
81
+ * `ExpoUpdatesPlugin.CreateUpdatesResourcesTask`, with a single
82
+ * `assetDir: DirectoryProperty` output (no `res` output exists for this task).
83
+ */
84
+ val updatesResourcesTaskName = VariantHelper.getExpoUpdatesResourcesTaskName(variant.name)
85
+ wireGeneratedSourceDirectory(appProject, updatesResourcesTaskName, "getAssetDir", variant.sources.assets)
86
+ }
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Wires `taskName`'s `getterName` output (expected to be a `DirectoryProperty`) as a
92
+ * generated source directory for `sources`.
93
+ *
94
+ * Resolving the task by name (rather than importing its class) keeps this plugin from
95
+ * depending on RNGP's/expo-updates' internal task types. If the task, or its expected output
96
+ * property, isn't there, this fails the build immediately with a clear message instead of
97
+ * silently producing an empty/stale source directory.
98
+ */
99
+ private fun wireGeneratedSourceDirectory(
100
+ appProject: Project,
101
+ taskName: String,
102
+ getterName: String,
103
+ sources: SourceDirectories?,
104
+ ) {
105
+ if (sources == null) return
106
+
107
+ val task =
108
+ appProject.tasks.findByName(taskName)
109
+ ?: throw GradleException(
110
+ "brownfield: expected task '$taskName' was not found in project " +
111
+ "'${appProject.path}'. Check that the plugin producing it is applied there.",
112
+ )
113
+
114
+ val getter =
115
+ runCatching { task.javaClass.getMethod(getterName) }.getOrElse {
116
+ throw GradleException(
117
+ "brownfield: task '$taskName' (${task.javaClass.name}) does not expose " +
118
+ "'$getterName()'. The plugin that registers it most likely changed its " +
119
+ "API; the brownfield Gradle plugin needs updating to match.",
120
+ it,
121
+ )
84
122
  }
123
+
124
+ check(DirectoryProperty::class.java.isAssignableFrom(getter.returnType)) {
125
+ "brownfield: '$taskName.$getterName()' no longer returns a DirectoryProperty."
85
126
  }
127
+
128
+ val taskProvider = appProject.tasks.named(taskName)
129
+ sources.addGeneratedSourceDirectory(taskProvider) { t -> getter.invoke(t) as DirectoryProperty }
86
130
  }
87
131
 
88
132
  private fun getLibraryNameSpace(): String {
@@ -3,6 +3,7 @@ package com.callstack.react.brownfield.utils
3
3
  import com.callstack.react.brownfield.shared.UnresolvedArtifactInfo
4
4
  import org.gradle.api.Project
5
5
  import java.io.File
6
+ import javax.xml.parsers.DocumentBuilderFactory
6
7
 
7
8
  class AndroidArchiveLibrary(
8
9
  private val project: Project,
@@ -46,11 +47,47 @@ class AndroidArchiveLibrary(
46
47
  val regex = Regex("""namespace\s*=?\s*["']([^"']+)["']""")
47
48
  val match = regex.find(text)
48
49
 
49
- val namespace =
50
- match?.groupValues?.get(1)
51
- ?: error("No namespace found in ${buildFile.path}")
50
+ return match?.groupValues?.get(1)
51
+ ?: getNameSpaceFromAndroidManifest(subProj)
52
+ ?: error(
53
+ "No namespace found in ${buildFile.path}, and no package attribute found in " +
54
+ "AndroidManifest.xml for ${artifact.moduleName}",
55
+ )
56
+ }
52
57
 
53
- return namespace
58
+ /**
59
+ * Falls back to the `package` attribute of the dependency's `AndroidManifest.xml` when the
60
+ * module declares no `namespace` in its `build.gradle`. The Android Gradle plugin itself
61
+ * derives the namespace this way for such libraries, so they build fine as a regular
62
+ * dependency but trip up [getNameSpaceFromBuildGradle]'s `build.gradle`-only regex.
63
+ *
64
+ * The manifest is parsed as XML and only the root `<manifest>` element's `package`
65
+ * attribute is read, so comments or child attributes such as `android:targetPackage`
66
+ * cannot be mistaken for the package name.
67
+ */
68
+ private fun getNameSpaceFromAndroidManifest(subProj: Project): String? {
69
+ val candidateManifests =
70
+ listOf(
71
+ File(subProj.projectDir, "src/main/AndroidManifest.xml"),
72
+ File(subProj.projectDir, "AndroidManifest.xml"),
73
+ )
74
+
75
+ return candidateManifests
76
+ .filter { it.exists() }
77
+ .firstNotNullOfOrNull { manifestFile ->
78
+ runCatching {
79
+ val document =
80
+ DocumentBuilderFactory.newInstance().apply {
81
+ isNamespaceAware = true
82
+ setFeature("http://apache.org/xml/features/disallow-doctype-decl", true)
83
+ setFeature("http://xml.org/sax/features/external-general-entities", false)
84
+ setFeature("http://xml.org/sax/features/external-parameter-entities", false)
85
+ }.newDocumentBuilder().parse(manifestFile)
86
+ document.documentElement
87
+ ?.getAttribute("package")
88
+ ?.takeIf { it.isNotBlank() }
89
+ }.getOrNull()
90
+ }
54
91
  }
55
92
 
56
93
  fun getManifestFile() = File(getExplodedAarRootDir(), "AndroidManifest.xml")
@@ -102,9 +102,10 @@ class AgpCompatibilityMatrixTest {
102
102
  File(projectDir, "local.properties").writeText("sdk.dir=$androidSdk\n")
103
103
  // Stands in for the RN app project. It has to be a real `com.android.application` because
104
104
  // the brownfield plugin wires into AGP's own app-side tasks; the extra registrations cover
105
- // the tasks the React Native Gradle plugin would otherwise contribute. Empty registrations
106
- // are enough: this test is about AGP API compatibility, not about what those tasks produce.
107
- File(projectDir, "app").mkdirs()
105
+ // the tasks the React Native Gradle plugin would otherwise contribute. The bundle tasks need
106
+ // their `jsBundleDir`/`resourcesDir` outputs, which the plugin wires as generated sources;
107
+ // the rest can stay empty, since this test is about AGP API compatibility.
108
+ writeRNBundleTaskStubs(File(projectDir, "app").apply { mkdirs() })
108
109
  File(projectDir, "app/build.gradle.kts").writeText(
109
110
  """
110
111
  plugins {
@@ -116,11 +117,8 @@ class AgpCompatibilityMatrixTest {
116
117
  compileSdk = 34
117
118
  }
118
119
 
119
- listOf(
120
- "generateAutolinkingPackageList",
121
- "createBundleDebugJsAndAssets",
122
- "createBundleReleaseJsAndAssets",
123
- ).forEach { name -> tasks.register(name) }
120
+ tasks.register("generateAutolinkingPackageList")
121
+ apply(from = "$RN_BUNDLE_TASK_STUBS_SCRIPT")
124
122
  """.trimIndent(),
125
123
  )
126
124
 
@@ -64,10 +64,12 @@ class LegacyConsumerConfigurationTest {
64
64
  """.trimIndent(),
65
65
  )
66
66
  File(projectDir, "local.properties").writeText("sdk.dir=$androidSdk\n")
67
- // ArtifactsResolver looks for an `app` project; an empty stub is enough, this test is about
68
- // the task container, not discovery.
69
- File(projectDir, "app").mkdirs()
70
- File(projectDir, "app/build.gradle.kts").writeText("")
67
+ // ArtifactsResolver looks for an `app` project. It only needs the RN bundle tasks whose
68
+ // outputs the plugin wires as generated sources; this test is about the task container,
69
+ // not discovery.
70
+ val appDir = File(projectDir, "app").apply { mkdirs() }
71
+ writeRNBundleTaskStubs(appDir)
72
+ File(appDir, "build.gradle.kts").writeText("apply(from = \"$RN_BUNDLE_TASK_STUBS_SCRIPT\")\n")
71
73
 
72
74
  // The `tasks.register("removeDependenciesFromModuleFile")` block below is verbatim from
73
75
  // the published setup docs. Do not "modernize" it — its whole purpose is to reproduce
@@ -0,0 +1,27 @@
1
+ package com.callstack.react.brownfield.plugin
2
+
3
+ import java.io.File
4
+
5
+ internal const val RN_BUNDLE_TASK_STUBS_SCRIPT = "rn-bundle-task-stubs.gradle.kts"
6
+
7
+ /**
8
+ * Writes a script plugin into [appDir] that stubs RNGP's `createBundle<Variant>JsAndAssets` tasks.
9
+ * The brownfield plugin wires their `jsBundleDir`/`resourcesDir` outputs as generated sources and
10
+ * fails fast when they are missing, so plain `tasks.register(name)` stubs are not enough.
11
+ * Apply it from the app build script with `apply(from = RN_BUNDLE_TASK_STUBS_SCRIPT)`.
12
+ */
13
+ internal fun writeRNBundleTaskStubs(appDir: File) {
14
+ File(appDir, RN_BUNDLE_TASK_STUBS_SCRIPT).writeText(
15
+ """
16
+ abstract class StubBundleTask : DefaultTask() {
17
+ @get:OutputDirectory abstract val jsBundleDir: DirectoryProperty
18
+ @get:OutputDirectory abstract val resourcesDir: DirectoryProperty
19
+ }
20
+
21
+ listOf(
22
+ "createBundleDebugJsAndAssets",
23
+ "createBundleReleaseJsAndAssets",
24
+ ).forEach { name -> tasks.register<StubBundleTask>(name) }
25
+ """.trimIndent(),
26
+ )
27
+ }