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.
- package/dist/native/androidProject/app/build.gradle.kts +1 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +143 -4
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +26 -6
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MetricsMonitor.kt +143 -17
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeBridge.kt +6 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/WebCacheManager.kt +26 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/BridgeUtils.kt +56 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +48 -8
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/PerfDeliveryState.kt +36 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/PerfEventBuffer.kt +289 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/ProfilerNavigationState.kt +13 -0
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/ProfilerLifecycleTest.kt +51 -0
- package/dist/native/bridge/WebBridge.js +2 -2
- package/dist/native/bridge/constants/NativeInterfaces.js +2 -1
- package/dist/native/bridge/perf/collectors/ApiCollector.js +22 -0
- package/dist/native/bridge/perf/collectors/CacheCollector.js +40 -0
- package/dist/native/bridge/perf/collectors/HookCollector.js +43 -0
- package/dist/native/bridge/perf/collectors/InsightsCollector.js +47 -0
- package/dist/native/bridge/perf/collectors/InteractionCollector.js +38 -0
- package/dist/native/bridge/perf/collectors/KeyboardCollector.js +25 -0
- package/dist/native/bridge/perf/collectors/NavigationCollector.js +53 -0
- package/dist/native/bridge/perf/collectors/NetworkTimingCollector.js +7 -0
- package/dist/native/bridge/perf/collectors/RenderCollector.js +40 -0
- package/dist/native/bridge/perf/collectors/ScrollCollector.js +25 -0
- package/dist/native/bridge/perf/collectors/ThreadCollector.js +27 -0
- package/dist/native/bridge/perf/core/constants.js +43 -0
- package/dist/native/bridge/perf/core/measure.js +40 -0
- package/dist/native/bridge/perf/core/session.js +34 -0
- package/dist/native/bridge/perf/core/store.js +1 -0
- package/dist/native/bridge/perf/core/summary.js +1 -0
- package/dist/native/bridge/perf/core/traceExport.js +1 -0
- package/dist/native/bridge/perf/core/waterfalls.js +1 -0
- package/dist/native/bridge/perf/index.js +81 -0
- package/dist/native/buildIos/config.js +1 -1
- package/dist/native/iosnativeWebView/Sources/Core/Constants/CatalystConstants.swift +19 -0
- package/dist/native/iosnativeWebView/Sources/Core/Constants/ConfigConstants.swift +4 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/BootTimingUtility.swift +5 -1
- package/dist/native/iosnativeWebView/Sources/Core/Utils/CacheManager.swift +10 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/DisplayLinkPerfMonitor.swift +89 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/PerfEventBuffer.swift +373 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeFileHandler.swift +35 -1
- package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeMessageValidator.swift +24 -11
- package/dist/native/iosnativeWebView/Sources/Core/WebView/NativeBridge.swift +9 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebView.swift +126 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +75 -1
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/BridgeMessageValidatorTests.swift +13 -0
- package/package.json +1 -1
package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/WebCacheManager.kt
CHANGED
|
@@ -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) {
|
package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/BridgeUtils.kt
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|