catalyst-core 0.2.0-beta.2 → 0.2.0-beta.3

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 (47) hide show
  1. package/dist/native/androidProject/app/build.gradle.kts +1 -0
  2. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +143 -4
  3. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +26 -6
  4. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MetricsMonitor.kt +143 -17
  5. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeBridge.kt +6 -0
  6. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/WebCacheManager.kt +26 -0
  7. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/BridgeUtils.kt +56 -1
  8. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +48 -8
  9. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/PerfDeliveryState.kt +36 -0
  10. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/PerfEventBuffer.kt +289 -0
  11. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/ProfilerNavigationState.kt +13 -0
  12. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/ProfilerLifecycleTest.kt +51 -0
  13. package/dist/native/bridge/WebBridge.js +2 -2
  14. package/dist/native/bridge/constants/NativeInterfaces.js +2 -1
  15. package/dist/native/bridge/perf/collectors/ApiCollector.js +22 -0
  16. package/dist/native/bridge/perf/collectors/CacheCollector.js +40 -0
  17. package/dist/native/bridge/perf/collectors/HookCollector.js +43 -0
  18. package/dist/native/bridge/perf/collectors/InsightsCollector.js +47 -0
  19. package/dist/native/bridge/perf/collectors/InteractionCollector.js +38 -0
  20. package/dist/native/bridge/perf/collectors/KeyboardCollector.js +25 -0
  21. package/dist/native/bridge/perf/collectors/NavigationCollector.js +53 -0
  22. package/dist/native/bridge/perf/collectors/NetworkTimingCollector.js +7 -0
  23. package/dist/native/bridge/perf/collectors/RenderCollector.js +40 -0
  24. package/dist/native/bridge/perf/collectors/ScrollCollector.js +25 -0
  25. package/dist/native/bridge/perf/collectors/ThreadCollector.js +27 -0
  26. package/dist/native/bridge/perf/core/constants.js +43 -0
  27. package/dist/native/bridge/perf/core/measure.js +40 -0
  28. package/dist/native/bridge/perf/core/session.js +34 -0
  29. package/dist/native/bridge/perf/core/store.js +1 -0
  30. package/dist/native/bridge/perf/core/summary.js +1 -0
  31. package/dist/native/bridge/perf/core/traceExport.js +1 -0
  32. package/dist/native/bridge/perf/core/waterfalls.js +1 -0
  33. package/dist/native/bridge/perf/index.js +81 -0
  34. package/dist/native/buildIos/config.js +1 -1
  35. package/dist/native/iosnativeWebView/Sources/Core/Constants/CatalystConstants.swift +19 -0
  36. package/dist/native/iosnativeWebView/Sources/Core/Constants/ConfigConstants.swift +4 -0
  37. package/dist/native/iosnativeWebView/Sources/Core/Utils/BootTimingUtility.swift +5 -1
  38. package/dist/native/iosnativeWebView/Sources/Core/Utils/CacheManager.swift +10 -0
  39. package/dist/native/iosnativeWebView/Sources/Core/Utils/DisplayLinkPerfMonitor.swift +89 -0
  40. package/dist/native/iosnativeWebView/Sources/Core/Utils/PerfEventBuffer.swift +373 -0
  41. package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeFileHandler.swift +35 -1
  42. package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeMessageValidator.swift +24 -11
  43. package/dist/native/iosnativeWebView/Sources/Core/WebView/NativeBridge.swift +9 -0
  44. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebView.swift +126 -0
  45. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +75 -1
  46. package/dist/native/iosnativeWebView/iosnativeWebViewTests/BridgeMessageValidatorTests.swift +13 -0
  47. package/package.json +1 -1
@@ -5,7 +5,9 @@ import android.util.Log
5
5
  import android.util.LruCache
6
6
  import android.webkit.WebResourceResponse
7
7
  import io.yourname.androidproject.BuildConfig
8
+ import io.yourname.androidproject.utils.PerfEventBuffer
8
9
  import kotlinx.coroutines.*
10
+ import org.json.JSONObject
9
11
  import java.io.*
10
12
  import java.net.HttpURLConnection
11
13
  import java.net.URL
@@ -36,6 +38,24 @@ class WebCacheManager(private val context: Context, private val properties: java
36
38
  (Runtime.getRuntime().maxMemory() / 1024 / memoryFraction).toInt()
37
39
  )
38
40
 
41
+ private fun emitCachePerfEvent(type: String, url: String, startMs: Long, statusCode: Int? = null) {
42
+ if (!BuildConfig.DEBUG) return
43
+ PerfEventBuffer.add(JSONObject().apply {
44
+ put("type", type)
45
+ put("url", url)
46
+ put("nativeStartMs", startMs)
47
+ put("durationMs", android.os.SystemClock.elapsedRealtime() - startMs)
48
+ put("resourceType", when {
49
+ url.endsWith(".js") -> "script"
50
+ url.endsWith(".css") -> "stylesheet"
51
+ url.endsWith(".png") || url.endsWith(".jpg") || url.endsWith(".jpeg") || url.endsWith(".svg") -> "image"
52
+ else -> "other"
53
+ })
54
+ put("thread", Thread.currentThread().name)
55
+ statusCode?.let { put("statusCode", it) }
56
+ })
57
+ }
58
+
39
59
  init {
40
60
  try {
41
61
  cacheDir.mkdirs()
@@ -81,6 +101,7 @@ class WebCacheManager(private val context: Context, private val properties: java
81
101
  @Suppress("UNUSED_PARAMETER")
82
102
  fun getCachedResponseSync(url: String, headers: Map<String, String>): WebResourceResponse? {
83
103
  return try {
104
+ val startMs = android.os.SystemClock.elapsedRealtime()
84
105
  val cacheKey = generateCacheKey(url)
85
106
  val currentTime = System.currentTimeMillis()
86
107
 
@@ -94,6 +115,7 @@ class WebCacheManager(private val context: Context, private val properties: java
94
115
  Log.d(TAG, "✅ Serving from memory cache (sync): $url")
95
116
  Log.d(FLOW_TAG, "ASSET cache-hit source=memory ageMs=$age url=$url")
96
117
  }
118
+ emitCachePerfEvent("cache-hit-memory", url, startMs)
97
119
  return memoryCacheEntry.toResponse()
98
120
  }
99
121
  if (BuildConfig.DEBUG) {
@@ -113,6 +135,7 @@ class WebCacheManager(private val context: Context, private val properties: java
113
135
  Log.d(TAG, "✅ Serving from disk cache (sync): $url")
114
136
  Log.d(FLOW_TAG, "ASSET cache-hit source=disk ageMs=$fileAge bytes=${cacheFile.length()} mime=${metadata.mimeType} encoding=${metadata.encoding} url=$url")
115
137
  }
138
+ emitCachePerfEvent("cache-hit-disk", url, startMs)
116
139
  return response
117
140
  }
118
141
  if (BuildConfig.DEBUG) {
@@ -125,6 +148,7 @@ class WebCacheManager(private val context: Context, private val properties: java
125
148
  Log.d(TAG, "❌ No valid cache available (sync): $url")
126
149
  Log.d(FLOW_TAG, "ASSET cache-miss url=$url")
127
150
  }
151
+ emitCachePerfEvent("cache-miss-fetch", url, startMs)
128
152
  null
129
153
  } catch (e: Exception) {
130
154
  Log.e(TAG, "Error in getCachedResponseSync for URL: $url: ${e.message}")
@@ -357,6 +381,7 @@ class WebCacheManager(private val context: Context, private val properties: java
357
381
  cacheKey: String
358
382
  ): WebResourceResponse? {
359
383
  var connection: HttpURLConnection? = null
384
+ val startMs = android.os.SystemClock.elapsedRealtime()
360
385
  try {
361
386
  connection = URL(url).openConnection() as HttpURLConnection
362
387
  headers.forEach { (key, value) ->
@@ -440,6 +465,7 @@ class WebCacheManager(private val context: Context, private val properties: java
440
465
  if (BuildConfig.DEBUG) {
441
466
  Log.d(FLOW_TAG, "ASSET network-fetch success status=${connection.responseCode} bytes=${responseBytes.size} contentType=$contentType replayMime=$mimeType replayEncoding=$encoding url=$url")
442
467
  }
468
+ emitCachePerfEvent("network-fetch-complete", url, startMs, connection.responseCode)
443
469
  return response
444
470
  }
445
471
  if (BuildConfig.DEBUG) {
@@ -5,6 +5,8 @@ import android.webkit.WebView
5
5
  import io.yourname.androidproject.CatalystConstants
6
6
  import org.json.JSONObject
7
7
  import android.os.Looper
8
+ import android.os.SystemClock
9
+ import io.yourname.androidproject.BuildConfig
8
10
 
9
11
  /**
10
12
  * Common utilities for native bridge operations
@@ -95,11 +97,64 @@ object BridgeUtils {
95
97
  ON_TRANSITION_CANCELLED("ON_TRANSITION_CANCELLED"),
96
98
  ON_TRANSITION_TIMEOUT("ON_TRANSITION_TIMEOUT"),
97
99
 
100
+ // Perf telemetry
101
+ CATALYST_PERF_EVENT("CATALYST_PERF_EVENT"),
98
102
  }
99
103
 
104
+ /**
105
+ * Emit a performance event directly to WebPerfCollector via
106
+ * window.__catalystPerfEvent(). Bypasses the WebBridge callback system
107
+ * to keep perf overhead minimal — no JS parsing, no handler lookup.
108
+ *
109
+ * Must be called from any thread — posts to main thread automatically.
110
+ *
111
+ * @param webView The WebView instance
112
+ * @param payload JSONObject with at minimum a "type" field
113
+ */
114
+ fun emitPerfEvent(webView: WebView, payload: JSONObject) {
115
+ if (!PerfEventBuffer.isEnabled()) return
116
+ if (Looper.myLooper() != Looper.getMainLooper()) {
117
+ // Capture caller thread name before marshalling to main thread
118
+ if (!payload.has("emitThread")) {
119
+ payload.put("emitThread", Thread.currentThread().name)
120
+ }
121
+ webView.post { emitPerfEvent(webView, payload) }
122
+ return
123
+ }
124
+ try {
125
+ // Use JSON.parse inside the JS call so we never deal with quote escaping
126
+ val jsonStr = payload.toString().replace("\\", "\\\\").replace("'", "\\'")
127
+ webView.evaluateJavascript("window.__catalystPerfEvent && window.__catalystPerfEvent('$jsonStr')", null)
128
+ } catch (e: Exception) {
129
+ Log.e(TAG, "emitPerfEvent failed: ${e.message}")
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Record that a @JavascriptInterface method was invoked (t0).
135
+ * Pair with bridgeCallDispatched() at notifyWeb() call site.
136
+ * No-op in release builds.
137
+ *
138
+ * @param callId Unique identifier for this call (e.g. "getDeviceInfo:<timestamp>")
139
+ * @param method Human-readable method name for ApiCollector.js
140
+ */
141
+ fun bridgeCallReceived(callId: String, method: String) {
142
+ if (!BuildConfig.DEBUG) return
143
+ PerfEventBuffer.bridgeCallReceived(callId, method)
144
+ }
145
+
146
+ /**
147
+ * Record that native dispatched the callback back to JS (t1).
148
+ * Must be called just before (or just after) notifyWeb().
149
+ */
150
+ fun bridgeCallDispatched(callId: String) {
151
+ if (!BuildConfig.DEBUG) return
152
+ PerfEventBuffer.bridgeCallDispatched(callId)
153
+ }
154
+
100
155
  /**
101
156
  * Send a callback to the web layer
102
- *
157
+ *
103
158
  * @param webView The WebView instance
104
159
  * @param event The web event to send
105
160
  * @param data The data to send (can be null)
@@ -2,27 +2,34 @@ package io.yourname.androidproject.utils
2
2
 
3
3
  import android.app.Activity
4
4
  import android.graphics.Rect
5
+ import android.os.SystemClock
5
6
  import android.util.TypedValue
6
7
  import android.view.View
7
8
  import android.view.ViewGroup
8
9
  import android.view.ViewTreeObserver
10
+ import android.webkit.WebView
9
11
  import androidx.constraintlayout.widget.ConstraintLayout
12
+ import org.json.JSONObject
10
13
 
11
14
  class KeyboardUtil(
12
15
  private val activity: Activity,
13
- private val webViewContainer: View
16
+ private val webViewContainer: View,
17
+ private val webView: WebView? = null,
14
18
  ) {
15
19
  private var originalHeight: Int = 0
16
20
  private var listener: ViewTreeObserver.OnGlobalLayoutListener? = null
17
-
21
+
18
22
  // Keyboard detection threshold in dp - commonly used values are 120-150dp
19
23
  private val keyboardThresholdDp = 120f
20
-
24
+
21
25
  // Calculate minimum keyboard height in pixels based on device density
22
26
  private val keyboardThresholdPx: Int by lazy {
23
27
  dpToPx(keyboardThresholdDp)
24
28
  }
25
-
29
+
30
+ // Track keyboard state to avoid spamming events on every layout pass
31
+ private var keyboardVisible = false
32
+
26
33
  /**
27
34
  * Convert density-independent pixels to pixels
28
35
  */
@@ -33,7 +40,7 @@ class KeyboardUtil(
33
40
  activity.resources.displayMetrics
34
41
  ).toInt()
35
42
  }
36
-
43
+
37
44
  /**
38
45
  * Convert pixels to density-independent pixels
39
46
  */
@@ -43,7 +50,7 @@ class KeyboardUtil(
43
50
 
44
51
  fun initialize() {
45
52
  val rootView = activity.findViewById<ViewGroup>(android.R.id.content)
46
-
53
+
47
54
  webViewContainer.post {
48
55
  originalHeight = webViewContainer.height
49
56
  }
@@ -53,21 +60,33 @@ class KeyboardUtil(
53
60
  val screenHeight = activity.resources.displayMetrics.heightPixels
54
61
  val visibleHeight = rect.height()
55
62
  val keyboardHeight = screenHeight - visibleHeight
56
-
63
+
57
64
  // Use density-independent threshold and consider screen size
58
65
  // Also add a minimum percentage of screen height as additional validation
59
66
  val minScreenPercentage = (screenHeight * 0.15).toInt() // 15% of screen height
60
67
  val effectiveThreshold = maxOf(keyboardThresholdPx, minScreenPercentage)
61
- if (keyboardHeight > effectiveThreshold) {
68
+ val isNowVisible = keyboardHeight > effectiveThreshold
69
+
70
+ if (isNowVisible) {
62
71
  // Keyboard visible - resize
63
72
  val params = webViewContainer.layoutParams as ConstraintLayout.LayoutParams
64
73
  params.height = originalHeight - keyboardHeight
65
74
  webViewContainer.layoutParams = params
75
+
76
+ if (!keyboardVisible) {
77
+ keyboardVisible = true
78
+ emitKeyboardEvent("keyboard-show", keyboardHeight)
79
+ }
66
80
  } else {
67
81
  // Keyboard hidden - restore
68
82
  val params = webViewContainer.layoutParams as ConstraintLayout.LayoutParams
69
83
  params.height = originalHeight
70
84
  webViewContainer.layoutParams = params
85
+
86
+ if (keyboardVisible) {
87
+ keyboardVisible = false
88
+ emitKeyboardEvent("keyboard-hide", 0)
89
+ }
71
90
  }
72
91
  }
73
92
  rootView.viewTreeObserver.addOnGlobalLayoutListener(listener)
@@ -79,4 +98,25 @@ class KeyboardUtil(
79
98
  rootView.viewTreeObserver.removeOnGlobalLayoutListener(it)
80
99
  }
81
100
  }
101
+
102
+ // ─── Perf telemetry ──────────────────────────────────────────────────────
103
+
104
+ /**
105
+ * Emit keyboard show/hide event to WebPerfCollector.
106
+ * Uses SystemClock.elapsedRealtime() so the timestamp is alignable with
107
+ * native Perfetto traces via window.__NATIVE_TIME_OFFSET.
108
+ */
109
+ private fun emitKeyboardEvent(type: String, keyboardHeight: Int) {
110
+ val wv = webView ?: return
111
+ try {
112
+ val payload = JSONObject().apply {
113
+ put("type", type)
114
+ put("nativeTime", SystemClock.elapsedRealtime())
115
+ put("keyboardHeight", keyboardHeight)
116
+ }
117
+ BridgeUtils.emitPerfEvent(wv, payload)
118
+ } catch (e: Exception) {
119
+ // Non-critical — swallow silently
120
+ }
121
+ }
82
122
  }
@@ -0,0 +1,36 @@
1
+ package io.yourname.androidproject.utils
2
+
3
+ internal class PerfDeliveryState<T> {
4
+ data class Batch<T>(val id: String, val payload: T, val generation: Long, val retryCount: Int = 0)
5
+
6
+ private var generation = 0L
7
+ private var sequence = 0L
8
+ private var inFlight: Batch<T>? = null
9
+
10
+ fun hasInFlight(): Boolean = inFlight != null
11
+
12
+ fun current(): Batch<T>? = inFlight
13
+
14
+ fun begin(payload: T): Batch<T>? {
15
+ if (inFlight != null) return null
16
+ return Batch("catalyst-${++sequence}", payload, generation).also { inFlight = it }
17
+ }
18
+
19
+ fun isCurrent(batch: Batch<T>): Boolean = inFlight?.id == batch.id && batch.generation == generation
20
+
21
+ fun acknowledge(batch: Batch<T>): Boolean {
22
+ if (!isCurrent(batch)) return false
23
+ inFlight = null
24
+ return true
25
+ }
26
+
27
+ fun retry(batch: Batch<T>, maxRetries: Int): Batch<T>? {
28
+ if (!isCurrent(batch) || batch.retryCount >= maxRetries) return null
29
+ return batch.copy(retryCount = batch.retryCount + 1).also { inFlight = it }
30
+ }
31
+
32
+ fun reset() {
33
+ generation++
34
+ inFlight = null
35
+ }
36
+ }
@@ -0,0 +1,289 @@
1
+ package io.yourname.androidproject.utils
2
+
3
+ import android.os.Handler
4
+ import android.os.Looper
5
+ import android.os.SystemClock
6
+ import android.webkit.WebView
7
+ import io.yourname.androidproject.BuildConfig
8
+ import org.json.JSONArray
9
+ import org.json.JSONObject
10
+ import java.util.concurrent.ConcurrentLinkedQueue
11
+ import java.util.concurrent.atomic.AtomicBoolean
12
+
13
+ /**
14
+ * PerfEventBuffer — native ring buffer for Catalyst profiler events.
15
+ *
16
+ * Collects all events from onCreate (boot timing, cache, API calls) and flushes
17
+ * them as a single JSON array to the web layer after onPageFinished.
18
+ *
19
+ * Design:
20
+ * - add() is safe from any thread (ConcurrentLinkedQueue)
21
+ * - scheduleFlush() called from onPageFinished — posts to main thread after 250ms
22
+ * so the JS environment is ready to receive events
23
+ * - flush() serialises all events to JSON array, calls window.__catalystPerfBatch()
24
+ * - CacheSummarizer: running tally of hit/miss/fetch + top-5 min-heap for InsightsCollector
25
+ * - Max ring buffer size: 500 events (oldest dropped if exceeded)
26
+ *
27
+ * API call timing:
28
+ * - bridgeCallReceived(callId, method) → stamps t0, stores in pendingCalls map
29
+ * - bridgeCallDispatched(callId) → stamps t1, computes durationMs, adds api-call event to buffer
30
+ */
31
+ object PerfEventBuffer {
32
+
33
+ private const val TAG = "PerfEventBuffer"
34
+ private const val MAX_BUFFER_SIZE = 500
35
+ private const val FLUSH_DELAY_MS = 250L
36
+ private const val FLUSH_RETRY_DELAY_MS = 250L
37
+ private const val MAX_FLUSH_RETRIES = 5
38
+ private const val PERIODIC_FLUSH_INTERVAL_MS = 4000L
39
+
40
+ private val buffer = ConcurrentLinkedQueue<JSONObject>()
41
+ private val mainHandler = Handler(Looper.getMainLooper())
42
+ private val flushed = AtomicBoolean(false)
43
+ @Volatile private var profilerEnabled = false
44
+ private val deliveryState = PerfDeliveryState<JSONArray>()
45
+
46
+ // Periodic flush state
47
+ @Volatile private var periodicWebView: WebView? = null
48
+ private var lastFlushedSize = 0
49
+ private val periodicRunnable: Runnable = object : Runnable {
50
+ override fun run() {
51
+ if (!isEnabled()) return
52
+ val wv = periodicWebView ?: return
53
+ val pendingBatch = deliveryState.current()
54
+ if (pendingBatch != null) {
55
+ deliver(wv, pendingBatch)
56
+ } else if (buffer.size > lastFlushedSize) {
57
+ val currentSize = buffer.size
58
+ android.util.Log.d(TAG, "[PerfBuffer] periodicFlush() — $currentSize events since last flush")
59
+ beginFlush(wv, includeCacheSummary = false)
60
+ }
61
+ mainHandler.postDelayed(this, PERIODIC_FLUSH_INTERVAL_MS)
62
+ }
63
+ }
64
+
65
+ // Pending API call timing: callId → t0 nativeMs + method
66
+ private val pendingCalls = java.util.concurrent.ConcurrentHashMap<String, Pair<Long, String>>()
67
+
68
+ // ─── CacheSummarizer ─────────────────────────────────────────────────────
69
+
70
+ private var cacheHits = 0
71
+ private var cacheMisses = 0
72
+ private var cacheFetches = 0
73
+ private var cacheTotalMs = 0L
74
+ // top-5 slowest: list of Pair(durationMs, filename)
75
+ private val cacheTopSlow = java.util.PriorityQueue<Pair<Long, String>>(
76
+ 6, compareBy { it.first }
77
+ )
78
+
79
+ private fun updateCacheSummary(type: String, durationMs: Long, url: String) {
80
+ val filename = try {
81
+ val path = java.net.URI(url).path
82
+ path.split("/").filter { it.isNotEmpty() }.lastOrNull()?.take(40) ?: url.take(40)
83
+ } catch (e: Exception) { url.take(40) }
84
+
85
+ when (type) {
86
+ "cache-hit-memory", "cache-hit-disk" -> cacheHits++
87
+ "cache-miss-fetch" -> cacheMisses++
88
+ "network-fetch-complete" -> {
89
+ cacheFetches++
90
+ cacheTotalMs += durationMs
91
+ cacheTopSlow.add(Pair(durationMs, filename))
92
+ if (cacheTopSlow.size > 5) cacheTopSlow.poll() // remove smallest
93
+ }
94
+ }
95
+ }
96
+
97
+ private fun buildCacheSummaryEvent(): JSONObject? {
98
+ val total = cacheHits + cacheMisses
99
+ if (total == 0) return null
100
+ val hitRate = if (total > 0) (cacheHits * 100.0 / total) else 0.0
101
+ val avgFetchMs = if (cacheFetches > 0) cacheTotalMs / cacheFetches else 0L
102
+ val topSlow = JSONArray()
103
+ cacheTopSlow.sortedByDescending { it.first }.forEach { (ms, name) ->
104
+ topSlow.put(JSONObject().apply {
105
+ put("filename", name)
106
+ put("durationMs", ms)
107
+ })
108
+ }
109
+ return JSONObject().apply {
110
+ put("type", "cache-summary")
111
+ put("hits", cacheHits)
112
+ put("misses", cacheMisses)
113
+ put("fetches", cacheFetches)
114
+ put("total", total)
115
+ put("hitRatePct", Math.round(hitRate * 10) / 10.0)
116
+ put("avgFetchMs", avgFetchMs)
117
+ put("topSlowest", topSlow)
118
+ put("nativeTime", SystemClock.elapsedRealtime())
119
+ }
120
+ }
121
+
122
+ // ─── Public API ──────────────────────────────────────────────────────────
123
+
124
+ fun configure(enabled: Boolean) {
125
+ profilerEnabled = BuildConfig.DEBUG && enabled
126
+ if (!profilerEnabled) reset()
127
+ }
128
+
129
+ fun isEnabled(): Boolean = profilerEnabled
130
+
131
+ /** Add a perf event to the buffer. Safe from any thread. */
132
+ fun add(event: JSONObject) {
133
+ if (!isEnabled()) return
134
+ if (buffer.size >= MAX_BUFFER_SIZE) {
135
+ buffer.poll() // drop oldest
136
+ }
137
+ // Update cache summary tally
138
+ val type = event.optString("type")
139
+ if (type.startsWith("cache-") || type == "network-fetch-complete") {
140
+ updateCacheSummary(type, event.optLong("durationMs", 0), event.optString("url"))
141
+ }
142
+ buffer.add(event)
143
+ android.util.Log.d(TAG, "[PerfBuffer] add() type=$type bufferSize=${buffer.size}")
144
+ }
145
+
146
+ /**
147
+ * Record start of an @JavascriptInterface bridge call.
148
+ * Call this immediately when the method is invoked on the JavascriptInterface.
149
+ */
150
+ fun bridgeCallReceived(callId: String, method: String) {
151
+ if (!isEnabled()) return
152
+ pendingCalls[callId] = Pair(SystemClock.elapsedRealtime(), method)
153
+ }
154
+
155
+ /**
156
+ * Record the end of immediate native bridge-handler work and add an api-call event.
157
+ */
158
+ fun bridgeCallDispatched(callId: String) {
159
+ if (!isEnabled()) return
160
+ val pending = pendingCalls.remove(callId) ?: return
161
+ val (t0, method) = pending
162
+ val t1 = SystemClock.elapsedRealtime()
163
+ val durationMs = t1 - t0
164
+ add(JSONObject().apply {
165
+ put("type", "api-call")
166
+ put("callId", callId)
167
+ put("method", method)
168
+ put("nativeStartMs", t0)
169
+ put("nativeEndMs", t1)
170
+ put("durationMs", durationMs)
171
+ put("thread", Thread.currentThread().name)
172
+ })
173
+ }
174
+
175
+ /**
176
+ * Schedule a flush after onPageFinished (initial/full page load).
177
+ * Also starts the periodic flush loop for subsequent SPA navigations.
178
+ */
179
+ fun scheduleFlush(webView: WebView) {
180
+ if (!isEnabled()) return
181
+ if (flushed.getAndSet(true)) {
182
+ android.util.Log.d(TAG, "[PerfBuffer] scheduleFlush() skipped — already flushed")
183
+ return
184
+ }
185
+ android.util.Log.d(TAG, "[PerfBuffer] scheduleFlush() scheduled in ${FLUSH_DELAY_MS}ms, bufferSize=${buffer.size}")
186
+ mainHandler.postDelayed({
187
+ beginFlush(webView, includeCacheSummary = true)
188
+ // Start periodic flush loop for SPA navigations after initial load
189
+ startPeriodicFlush(webView)
190
+ }, FLUSH_DELAY_MS)
191
+ }
192
+
193
+ /**
194
+ * Flush any pending events immediately (e.g. before a full URL navigation).
195
+ * Does NOT reset the buffer — only drains buffered events up to this point.
196
+ */
197
+ fun flushNow(webView: WebView) {
198
+ if (!isEnabled()) return
199
+ mainHandler.post { beginFlush(webView, includeCacheSummary = false) }
200
+ }
201
+
202
+ /**
203
+ * Start periodic flush loop — fires every 4s and flushes only if new events exist.
204
+ * Call after onPageFinished so the JS receiver is registered.
205
+ */
206
+ fun startPeriodicFlush(webView: WebView) {
207
+ if (!isEnabled()) return
208
+ mainHandler.removeCallbacks(periodicRunnable)
209
+ periodicWebView = webView
210
+ lastFlushedSize = 0
211
+ mainHandler.postDelayed(periodicRunnable, PERIODIC_FLUSH_INTERVAL_MS)
212
+ android.util.Log.d(TAG, "[PerfBuffer] startPeriodicFlush() — interval=${PERIODIC_FLUSH_INTERVAL_MS}ms")
213
+ }
214
+
215
+ /** Stop periodic flush (call on destroy / loadUrl reset). */
216
+ fun stopPeriodicFlush() {
217
+ mainHandler.removeCallbacks(periodicRunnable)
218
+ periodicWebView = null
219
+ }
220
+
221
+ /**
222
+ * Reset the buffer for the next page load.
223
+ * Call this from loadUrl so boot timing events for the new page are captured.
224
+ */
225
+ fun reset() {
226
+ stopPeriodicFlush()
227
+ buffer.clear()
228
+ deliveryState.reset()
229
+ pendingCalls.clear()
230
+ flushed.set(false)
231
+ lastFlushedSize = 0
232
+ cacheHits = 0
233
+ cacheMisses = 0
234
+ cacheFetches = 0
235
+ cacheTotalMs = 0L
236
+ cacheTopSlow.clear()
237
+ }
238
+
239
+ // ─── Flush ───────────────────────────────────────────────────────────────
240
+
241
+ /** Moves the current queue into an immutable in-flight batch until JS acknowledges it. */
242
+ private fun beginFlush(webView: WebView, includeCacheSummary: Boolean) {
243
+ if (!isEnabled()) return
244
+ if (Looper.myLooper() != Looper.getMainLooper()) {
245
+ mainHandler.post { beginFlush(webView, includeCacheSummary) }
246
+ return
247
+ }
248
+ if (deliveryState.hasInFlight()) return
249
+ val events = JSONArray()
250
+ while (buffer.isNotEmpty()) {
251
+ buffer.poll()?.let { events.put(it) }
252
+ }
253
+ if (includeCacheSummary) buildCacheSummaryEvent()?.let { events.put(it) }
254
+ if (events.length() == 0) return
255
+
256
+ val batch = deliveryState.begin(events) ?: return
257
+ deliver(webView, batch)
258
+ }
259
+
260
+ private fun deliver(webView: WebView, batch: PerfDeliveryState.Batch<JSONArray>) {
261
+ if (!isEnabled() || !deliveryState.isCurrent(batch)) return
262
+ try {
263
+ val json = batch.payload.toString().replace("\\", "\\\\").replace("'", "\\'")
264
+ val batchId = JSONObject.quote(batch.id)
265
+ webView.evaluateJavascript(
266
+ "window.__catalystPerfBatch ? window.__catalystPerfBatch($batchId, '$json') : false",
267
+ ) { result ->
268
+ if (result == "true") acknowledge(batch) else retry(webView, batch)
269
+ }
270
+ } catch (_: Exception) {
271
+ retry(webView, batch)
272
+ }
273
+ }
274
+
275
+ private fun acknowledge(batch: PerfDeliveryState.Batch<JSONArray>) {
276
+ if (deliveryState.acknowledge(batch)) {
277
+ lastFlushedSize = 0
278
+ }
279
+ }
280
+
281
+ private fun retry(webView: WebView, batch: PerfDeliveryState.Batch<JSONArray>) {
282
+ val retry = deliveryState.retry(batch, MAX_FLUSH_RETRIES)
283
+ if (retry == null) {
284
+ android.util.Log.w(TAG, "[PerfBuffer] batch ${batch.id} was not acknowledged; retaining until reset")
285
+ return
286
+ }
287
+ mainHandler.postDelayed({ deliver(webView, retry) }, FLUSH_RETRY_DELAY_MS)
288
+ }
289
+ }
@@ -0,0 +1,13 @@
1
+ package io.yourname.androidproject.utils
2
+
3
+ internal class ProfilerNavigationState {
4
+ private var isInitialNavigation = true
5
+
6
+ data class Navigation(val url: String, val shouldReset: Boolean)
7
+
8
+ fun begin(url: String): Navigation {
9
+ val shouldReset = !isInitialNavigation
10
+ isInitialNavigation = false
11
+ return Navigation(url, shouldReset)
12
+ }
13
+ }
@@ -0,0 +1,51 @@
1
+ package io.yourname.androidproject
2
+
3
+ import io.yourname.androidproject.utils.PerfDeliveryState
4
+ import io.yourname.androidproject.utils.ProfilerNavigationState
5
+ import org.junit.Assert.assertEquals
6
+ import org.junit.Assert.assertFalse
7
+ import org.junit.Assert.assertNotNull
8
+ import org.junit.Assert.assertNull
9
+ import org.junit.Assert.assertTrue
10
+ import org.junit.Test
11
+
12
+ class ProfilerLifecycleTest {
13
+ @Test
14
+ fun `offline startup and retry navigation record separate page lifecycles`() {
15
+ val state = ProfilerNavigationState()
16
+ val offlineStart = state.begin("file:///android_asset/offline/offline.html")
17
+ val retry = state.begin("https://example.com")
18
+
19
+ assertFalse(offlineStart.shouldReset)
20
+ assertEquals("file:///android_asset/offline/offline.html", offlineStart.url)
21
+ assertTrue(retry.shouldReset)
22
+ assertEquals("https://example.com", retry.url)
23
+ }
24
+
25
+ @Test
26
+ fun `batch remains in flight until acknowledged and retries retain its id`() {
27
+ val state = PerfDeliveryState<List<String>>()
28
+ val batch = state.begin(listOf("boot-activity-created"))
29
+
30
+ assertNotNull(batch)
31
+ assertNull(state.begin(listOf("boot-page-started")))
32
+
33
+ val retry = state.retry(batch!!, maxRetries = 2)
34
+ assertNotNull(retry)
35
+ assertEquals(batch.id, retry!!.id)
36
+ assertEquals(1, retry.retryCount)
37
+ assertTrue(state.acknowledge(retry))
38
+ assertNotNull(state.begin(listOf("boot-page-started")))
39
+ }
40
+
41
+ @Test
42
+ fun `reset invalidates stale delivery callbacks`() {
43
+ val state = PerfDeliveryState<List<String>>()
44
+ val batch = state.begin(listOf("boot-load-url"))!!
45
+
46
+ state.reset()
47
+
48
+ assertFalse(state.acknowledge(batch))
49
+ assertNull(state.retry(batch, maxRetries = 2))
50
+ }
51
+ }