Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/bright-android-performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@callstack/react-native-brownfield': major
---

Add Android startup, TTID/TTFD, and JS/UI responsiveness metrics. Android's
`OnJSBundleLoaded` callback now receives `JSBundleTimings` instead of a Boolean.
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ package com.callstack.brownfield.android.example
object ReactNativeConstants {
const val MAIN_MODULE_NAME = "main"
const val APP_NAME = "Android (Expo 57)"
const val WAIT_FOR_FULL_DISPLAY = true
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ package com.callstack.brownfield.android.example
object ReactNativeConstants {
const val MAIN_MODULE_NAME = "main"
const val APP_NAME = "Android (Expo 58)"
const val WAIT_FOR_FULL_DISPLAY = true
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ package com.callstack.brownfield.android.example
object ReactNativeConstants {
const val MAIN_MODULE_NAME = "main"
const val APP_NAME = "Android (Expo Preview)"
const val WAIT_FOR_FULL_DISPLAY = true
}
4 changes: 4 additions & 0 deletions apps/AndroidApp/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
android:name=".ReferralsActivity"
android:exported="false"
android:theme="@style/Theme.AndroidBrownfieldApp" />
<activity
android:name=".ReactNativeActivity"
android:exported="false"
android:theme="@style/Theme.AndroidBrownfieldApp" />
<activity
android:name=".SettingsActivity"
android:exported="false"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class BrownfieldApplication : Application(), ReactApplication {
override fun onCreate() {
super.onCreate()

ReactNativeHostManager.initialize(this)
ReactNativeHostManager.initialize(this, BrownfieldMetricsLogger::logStartup)

StoreManager.shared.registerIfAbsent(BrownfieldStore.STORE_NAME) {
Store(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.callstack.brownfield.android.example

import android.util.Log
import com.callstack.reactnativebrownfield.BrownfieldDisplayInterval
import com.callstack.reactnativebrownfield.BrownfieldDisplayMetrics
import com.callstack.reactnativebrownfield.BrownfieldThreadMetrics
import com.callstack.reactnativebrownfield.JSBundleTimings

object BrownfieldMetricsLogger {
private const val TAG = "AndroidApp"

fun logStartup(timings: JSBundleTimings) {
Log.i(
TAG,
"[startup] jsBundleLoadTime=${number(timings.jsBundleLoadTime)} " +
"jsBundleEvaluationTime=${number(timings.jsBundleEvaluationTime)}"
)
for (interval in timings.timeline) {
val marker = interval["tag"] as? String ?: "unknown"
Log.i(
TAG,
"[startup][$marker] startMs=${number(interval["startMs"])} " +
"stopMs=${number(interval["stopMs"])}"
)
}
}

fun logDisplay(metrics: BrownfieldDisplayMetrics) {
val label = "[${metrics.moduleName}][ Display Event ]"
metrics.initialDisplay?.let { logInterval(it, "$label[TTID]") }
metrics.fullDisplay?.let { logInterval(it, "$label[TTFD]") }
}

private fun logInterval(interval: BrownfieldDisplayInterval, label: String) {
Log.i(
TAG,
"$label duration=${format(interval.duration)} " +
"JS={${thread(interval.jsThread)}} UI={${thread(interval.uiThread)}}"
)
}

private fun thread(metrics: BrownfieldThreadMetrics): String {
return "fps=${number(metrics.fps)} busyRatio=${number(metrics.busyRatio)} " +
"sampledMs=${format(metrics.sampledMs)} frames=${metrics.frameCount}"
}

private fun number(value: Any?): String = when (value) {
null -> "unavailable"
is Double -> format(value)
is Float -> format(value.toDouble())
is Long -> format(value.toDouble())
is Int -> format(value.toDouble())
is Number -> format(value.toDouble())
else -> value.toString()
}

private fun format(value: Double): String = "%.2f".format(value)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.callstack.brownfield.android.example
/** Keep in sync with `@callstack/brownfield-example-shared-tests` `e2eTestIds`. */
object E2eTestIds {
const val nativeAppGreeting = "brownfield-e2e-appleapp-greeting"
const val nativeAppOpenReactNative = "brownfield-e2e-appleapp-open-react-native"
const val nativeAppPostMessageSend = "brownfield-e2e-appleapp-post-message-send"
const val nativeAppPostMessageToast = "brownfield-e2e-appleapp-post-message-toast"
const val nativeAppNativeSettings = "brownfield-e2e-appleapp-native-settings"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,54 +3,42 @@ package com.callstack.brownfield.android.example
import android.app.Activity
import android.content.Intent
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Button
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.fragment.compose.AndroidFragment
import com.callstack.brownfield.android.example.components.EspressoTagAnchor
import com.callstack.brownfield.android.example.components.GreetingCard
import com.callstack.brownfield.android.example.components.PostMessageCard
import com.callstack.brownfield.android.example.components.PostMessageToast
import com.callstack.brownfield.android.example.ui.theme.AndroidBrownfieldAppTheme
import com.callstack.nativebrownfieldnavigation.BrownfieldNavigationDelegate
import com.callstack.nativebrownfieldnavigation.BrownfieldNavigationManager
import com.callstack.nativebrownfieldnavigation.UserType
import com.callstack.reactnativebrownfield.ReactNativeFragment
import com.callstack.reactnativebrownfield.constants.ReactNativeFragmentArgNames
import com.facebook.react.bridge.Callback
import com.facebook.react.bridge.Promise

class MainActivity : AppCompatActivity(), BrownfieldNavigationDelegate {

class MainActivity : AppCompatActivity() {
private val isDetoxE2E: Boolean
get() = intent?.getStringExtra("DetoxE2E") == "YES"

Expand All @@ -62,19 +50,11 @@ class MainActivity : AppCompatActivity(), BrownfieldNavigationDelegate {

override fun onResume() {
super.onResume()
// Own Brownfield navigation only while this activity is foregrounded.
BrownfieldNavigationManager.setDelegate(this)
if (isDetoxE2E) {
window.decorView.post { window.decorView.requestFocus() }
}
}

override fun onPause() {
// Release ownership before another host can become the active delegate.
BrownfieldNavigationManager.clearDelegate()
super.onPause()
}

override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (!hasFocus && isDetoxE2E) {
Expand All @@ -87,7 +67,7 @@ class MainActivity : AppCompatActivity(), BrownfieldNavigationDelegate {
enableEdgeToEdge()

if (savedInstanceState == null) {
ReactNativeHostManager.initialize(application) {
ReactNativeHostManager.initialize(application) { _ ->
if (!isDetoxE2E) {
Toast.makeText(
this,
Expand Down Expand Up @@ -115,40 +95,6 @@ class MainActivity : AppCompatActivity(), BrownfieldNavigationDelegate {
}
}
}

override fun navigateToSettings(user: UserType) {
startActivity(Intent(this, SettingsActivity::class.java))
}

override fun navigateToReferrals(userId: String) {
startActivity(
Intent(this, ReferralsActivity::class.java).putExtra(
ReferralsActivity.EXTRA_USER_ID,
userId
)
)
}

override fun requestNativeConfirmation(title: String, promise: Promise) {
runOnUiThread {
AlertDialog.Builder(this)
.setTitle(title)
.setPositiveButton("OK") { _, _ -> promise.resolve(true) }
.setNegativeButton("Cancel") { _, _ -> promise.resolve(false) }
.setCancelable(false)
.show()
}
}

override fun showNativeBanner(message: String, onDismiss: Callback) {
runOnUiThread {
AlertDialog.Builder(this)
.setMessage(message)
.setPositiveButton("Dismiss") { _, _ -> onDismiss.invoke() }
.setCancelable(false)
.show()
}
}
}

@Composable
Expand All @@ -173,20 +119,28 @@ private fun MainScreen(modifier: Modifier = Modifier) {
name = ReactNativeConstants.APP_NAME,
)

EspressoTagAnchor(tag = E2eTestIds.nativeAppOpenReactNative) {
Button(
onClick = {
activity?.let {
it.startActivity(
Intent(it, ReactNativeActivity::class.java)
// Forward the Detox flag so the RN host keeps E2E behaviour.
.putExtra("DetoxE2E", it.intent?.getStringExtra("DetoxE2E"))
)
}
}
) {
Text("Open React Native")
}
}

PostMessageCard(
onMessageReceived = { message -> postMessageToastText = message },
textInputEnabled = !isDetoxE2E,
)

Spacer(modifier = Modifier.height(1.dp))

ReactNativeView(
modifier = Modifier
.fillMaxWidth()
.height(520.dp)
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surface)
)
Spacer(modifier = Modifier.height(16.dp))
}

postMessageToastText?.let { message ->
Expand All @@ -198,36 +152,6 @@ private fun MainScreen(modifier: Modifier = Modifier) {
}
}

@Composable
fun ReactNativeView(
modifier: Modifier = Modifier
) {
val activity = LocalContext.current as? Activity
val brownfieldE2E = remember(activity) {
activity?.intent?.getStringExtra("DetoxE2E") == "YES"
}

AndroidFragment<ReactNativeFragment>(
modifier = modifier,
arguments = Bundle().apply {
putString(
ReactNativeFragmentArgNames.ARG_MODULE_NAME,
ReactNativeConstants.MAIN_MODULE_NAME
)
putBundle(
ReactNativeFragmentArgNames.ARG_LAUNCH_OPTIONS,
Bundle().apply {
putString(
"nativeOsVersionLabel",
"Android ${Build.VERSION.RELEASE}"
)
putBoolean("brownfieldE2E", brownfieldE2E)
}
)
}
)
}

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
Expand Down
Loading
Loading