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
@@ -110,6 +110,7 @@ android {
110
110
  applicationIdSuffix = ".debug"
111
111
  versionNameSuffix = "-debug"
112
112
  isMinifyEnabled = false
113
+ enableUnitTestCoverage = true
113
114
  buildConfigField("Boolean", "ALLOW_MIXED_CONTENT", "true")
114
115
  buildConfigField("String", "LOCAL_IP", "\"${getLocalIpAddress()}\"")
115
116
  }
@@ -7,18 +7,26 @@ import android.graphics.Bitmap
7
7
  import android.net.Uri
8
8
  import android.os.Bundle
9
9
  import android.util.Log
10
+ import android.view.GestureDetector
11
+ import android.view.MotionEvent
10
12
  import android.view.View
11
13
  import android.webkit.*
12
14
  import androidx.webkit.ServiceWorkerClientCompat
13
15
  import androidx.webkit.ServiceWorkerControllerCompat
16
+ import androidx.webkit.WebViewCompat
17
+ import androidx.webkit.WebViewFeature
14
18
  import androidx.webkit.WebResourceRequestCompat
15
19
  import android.widget.ProgressBar
16
20
  import androidx.webkit.WebViewAssetLoader
17
21
  import io.yourname.androidproject.WebCacheManager
18
22
  import io.yourname.androidproject.URLWhitelistManager
19
23
  import io.yourname.androidproject.matchesCachePattern
24
+ import io.yourname.androidproject.utils.BridgeUtils
20
25
  import io.yourname.androidproject.utils.CameraUtils
21
26
  import io.yourname.androidproject.utils.NetworkUtils
27
+ import io.yourname.androidproject.utils.PerfEventBuffer
28
+ import io.yourname.androidproject.utils.ProfilerNavigationState
29
+ import org.json.JSONObject
22
30
  import kotlinx.coroutines.*
23
31
  import java.io.FileNotFoundException
24
32
  import java.util.Properties
@@ -45,11 +53,15 @@ class CustomWebView(
45
53
  private var isInitialApiCalled: Boolean = false // Flag to track initial page load
46
54
  private var isInitialPageLoaded: Boolean = false
47
55
  private var buildOptimisation: Boolean = false // Added property for build optimization
56
+ private var profilerEnabled: Boolean = false
57
+ private val profilerNavigationState = ProfilerNavigationState()
48
58
  private lateinit var assetLoader: WebViewAssetLoader
49
59
  private var allowedUrls: List<String> = emptyList()
50
60
  private var accessControlEnabled: Boolean = false
51
61
  private val offlineAssetPath = "offline/offline.html"
52
62
  private val offlineAssetUrl = "file:///android_asset/$offlineAssetPath"
63
+ private var scrollSessionOpen = false
64
+ private lateinit var scrollGestureDetector: GestureDetector
53
65
  private var offlinePageVisible = false
54
66
  private var lastTargetUrl: String? = null
55
67
  private var activeOfflineRouteOrigin: String? = null
@@ -62,8 +74,16 @@ class CustomWebView(
62
74
  private var assetLoadFailures = 0
63
75
 
64
76
  init {
77
+ if (BuildConfig.DEBUG) {
78
+ PerfEventBuffer.add(JSONObject().apply {
79
+ put("type", "boot-webview-constructed")
80
+ put("nativeTime", android.os.SystemClock.elapsedRealtime())
81
+ put("thread", Thread.currentThread().name)
82
+ })
83
+ }
65
84
  setupFromProperties()
66
85
  cacheManager = WebCacheManager(context, properties)
86
+ if (profilerEnabled) metricsMonitor.attachWebView(webView)
67
87
  offlineCacheService = OfflineCacheService(context)
68
88
  setupWebView()
69
89
  }
@@ -90,6 +110,7 @@ class CustomWebView(
90
110
 
91
111
  // Parse buildOptimisation property
92
112
  buildOptimisation = properties.getProperty("buildOptimisation", "false").toBoolean()
113
+ profilerEnabled = PerfEventBuffer.isEnabled()
93
114
 
94
115
  // Load allowed URLs from properties
95
116
  allowedUrls = properties.getProperty("accessControl.allowedUrls", "")
@@ -162,12 +183,26 @@ class CustomWebView(
162
183
  }
163
184
  }
164
185
 
186
+ private fun loadTopLevelUrl(url: String) {
187
+ if (BuildConfig.DEBUG) {
188
+ val navigation = profilerNavigationState.begin(url)
189
+ if (navigation.shouldReset) PerfEventBuffer.reset()
190
+ PerfEventBuffer.add(JSONObject().apply {
191
+ put("type", "boot-load-url")
192
+ put("nativeTime", android.os.SystemClock.elapsedRealtime())
193
+ put("url", navigation.url)
194
+ put("thread", Thread.currentThread().name)
195
+ })
196
+ }
197
+ loadUrlInternal(url)
198
+ }
199
+
165
200
  fun loadUrl(url: String) {
166
201
  lastTargetUrl = url
167
202
  offlinePageVisible = false
168
203
  visibleOfflineSnapshotUrl = null
169
204
  activeOfflineRouteOrigin = null
170
- loadUrlInternal(url)
205
+ loadTopLevelUrl(url)
171
206
  }
172
207
 
173
208
  fun updateLastTargetUrl(url: String) {
@@ -185,7 +220,7 @@ class CustomWebView(
185
220
  offlinePageVisible = true
186
221
  visibleOfflineSnapshotUrl = null
187
222
  activeOfflineRouteOrigin = null
188
- loadUrlInternal(offlineAssetUrl)
223
+ loadTopLevelUrl(offlineAssetUrl)
189
224
  if (BuildConfig.DEBUG) {
190
225
  Log.d(TAG, "📴 Showing offline page from assets: $offlineAssetPath")
191
226
  }
@@ -228,7 +263,7 @@ class CustomWebView(
228
263
  offlinePageVisible = false
229
264
  visibleOfflineSnapshotUrl = snapshotUrl
230
265
  updateActiveOfflineRoute(url, true)
231
- loadUrlInternal(url)
266
+ loadTopLevelUrl(url)
232
267
  if (BuildConfig.DEBUG) {
233
268
  Log.d(TAG, "📴 Loading cached offline route through request interceptor: $url")
234
269
  }
@@ -304,6 +339,7 @@ class CustomWebView(
304
339
 
305
340
  fun destroy() {
306
341
  job.cancel()
342
+ metricsMonitor.detachWebView()
307
343
  webView.destroy()
308
344
  }
309
345
 
@@ -335,6 +371,13 @@ class CustomWebView(
335
371
  if (BuildConfig.DEBUG) {
336
372
  Log.d(TAG, "🚀 Hardware acceleration enabled - Thread: ${Thread.currentThread().name}")
337
373
  }
374
+ BridgeUtils.emitPerfEvent(webView, JSONObject().apply {
375
+ put("type", "hw-accel-change")
376
+ put("state", "on")
377
+ put("trigger", "cleanup")
378
+ put("thread", Thread.currentThread().name)
379
+ put("nativeTime", android.os.SystemClock.elapsedRealtime())
380
+ })
338
381
  }
339
382
  }
340
383
 
@@ -345,6 +388,13 @@ class CustomWebView(
345
388
  if (BuildConfig.DEBUG) {
346
389
  Log.d(TAG, "⚫ Hardware acceleration disabled - Thread: ${Thread.currentThread().name}")
347
390
  }
391
+ BridgeUtils.emitPerfEvent(webView, JSONObject().apply {
392
+ put("type", "hw-accel-change")
393
+ put("state", "off")
394
+ put("trigger", "cache-serve")
395
+ put("thread", Thread.currentThread().name)
396
+ put("nativeTime", android.os.SystemClock.elapsedRealtime())
397
+ })
348
398
  }
349
399
  }
350
400
 
@@ -472,7 +522,7 @@ class CustomWebView(
472
522
  }
473
523
  offlinePageVisible = false
474
524
  lastTargetUrl = target
475
- loadUrlInternal(target)
525
+ loadTopLevelUrl(target)
476
526
  } else if (BuildConfig.DEBUG) {
477
527
  Log.w(TAG, "🔄 Retry requested but no target URL is known")
478
528
  }
@@ -718,6 +768,13 @@ class CustomWebView(
718
768
 
719
769
  @SuppressLint("SetJavaScriptEnabled")
720
770
  private fun setupWebView() {
771
+ if (profilerEnabled && WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
772
+ WebViewCompat.addDocumentStartJavaScript(
773
+ webView,
774
+ "window.__CATALYST_PROFILER_ENABLED = true;",
775
+ setOf("*")
776
+ )
777
+ }
721
778
  if (BuildConfig.DEBUG) {
722
779
  Log.d(TAG, "🌐 Setting up WebView on thread: ${Thread.currentThread().name}")
723
780
  }
@@ -1057,14 +1114,53 @@ class CustomWebView(
1057
1114
  Log.d(TAG, "⏳ Page load started for: $url - Hardware Acceleration: $isHardwareAccelerationEnabled")
1058
1115
  Log.d(FLOW_TAG, "PAGE started url=$url online=${NetworkUtils.getCurrentStatus(context).isOnline} offlinePageVisible=$offlinePageVisible visibleSnapshot=${visibleOfflineSnapshotUrl ?: "none"} activeOrigin=${activeOfflineRouteOrigin ?: "none"}")
1059
1116
  }
1117
+
1118
+ if (profilerEnabled) {
1119
+ val nativeNow = android.os.SystemClock.elapsedRealtime()
1120
+ view?.evaluateJavascript(
1121
+ "window.__CATALYST_PROFILER_ENABLED = true; window.__NATIVE_TIME_OFFSET = $nativeNow - performance.now();",
1122
+ null
1123
+ )
1124
+ }
1125
+ if (profilerEnabled && url != null) {
1126
+ val nativeNow = android.os.SystemClock.elapsedRealtime()
1127
+ PerfEventBuffer.add(JSONObject().apply {
1128
+ put("type", "boot-page-started")
1129
+ put("nativeTime", nativeNow)
1130
+ put("url", url)
1131
+ })
1132
+ PerfEventBuffer.add(JSONObject().apply {
1133
+ put("type", "page-load-start")
1134
+ put("nativeTime", nativeNow)
1135
+ put("url", url)
1136
+ })
1137
+ }
1060
1138
  }
1061
1139
 
1062
1140
  override fun onPageFinished(view: WebView?, url: String?) {
1063
1141
  progressBar.visibility = View.GONE
1064
1142
  if(!isInitialPageLoaded){
1065
1143
  isInitialPageLoaded = true
1144
+ metricsMonitor.markAppStartComplete()
1066
1145
  }
1067
1146
  url?.let { metricsMonitor.trackPageLoadEnd(it) }
1147
+
1148
+ val pageFinishedNative = android.os.SystemClock.elapsedRealtime()
1149
+ if (profilerEnabled && url != null) {
1150
+ PerfEventBuffer.add(JSONObject().apply {
1151
+ put("type", "boot-page-finished")
1152
+ put("nativeTime", pageFinishedNative)
1153
+ put("url", url)
1154
+ })
1155
+ val pageStart = view?.tag as? Long
1156
+ PerfEventBuffer.add(JSONObject().apply {
1157
+ put("type", "page-load-end")
1158
+ put("nativeTime", pageFinishedNative)
1159
+ put("url", url)
1160
+ put("durationMs", if (pageStart != null) System.currentTimeMillis() - pageStart else 0L)
1161
+ })
1162
+ if (view != null) PerfEventBuffer.scheduleFlush(view)
1163
+ }
1068
1164
  if (BuildConfig.DEBUG) {
1069
1165
  val startTime = view?.tag as? Long ?: return
1070
1166
  val loadTime = System.currentTimeMillis() - startTime
@@ -1095,6 +1191,15 @@ class CustomWebView(
1095
1191
  if (BuildConfig.DEBUG) {
1096
1192
  Log.e(FLOW_TAG, "PAGE resource-error mainFrame=${request?.isForMainFrame} method=${request?.method} code=${error.errorCode} description=${error.description} url=${request?.url}")
1097
1193
  }
1194
+ if (view != null && request?.isForMainFrame == true) {
1195
+ BridgeUtils.emitPerfEvent(view, JSONObject().apply {
1196
+ put("type", "page-load-error")
1197
+ put("nativeTime", android.os.SystemClock.elapsedRealtime())
1198
+ put("url", request.url?.toString() ?: "")
1199
+ put("errorCode", error.errorCode)
1200
+ put("description", error.description?.toString() ?: "")
1201
+ })
1202
+ }
1098
1203
  }
1099
1204
 
1100
1205
  val failedUrl = request?.url?.toString()
@@ -1204,5 +1309,39 @@ class CustomWebView(
1204
1309
 
1205
1310
  // Only enable WebView debugging in debug builds
1206
1311
  WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
1312
+
1313
+ if (profilerEnabled) {
1314
+ scrollGestureDetector = GestureDetector(context,
1315
+ object : GestureDetector.SimpleOnGestureListener() {
1316
+ override fun onScroll(
1317
+ e1: MotionEvent?,
1318
+ e2: MotionEvent,
1319
+ distanceX: Float,
1320
+ distanceY: Float
1321
+ ): Boolean {
1322
+ if (!scrollSessionOpen) {
1323
+ scrollSessionOpen = true
1324
+ BridgeUtils.emitPerfEvent(webView, JSONObject().apply {
1325
+ put("type", "scroll-start")
1326
+ put("nativeTime", android.os.SystemClock.elapsedRealtime())
1327
+ })
1328
+ }
1329
+ return false
1330
+ }
1331
+ }
1332
+ )
1333
+ webView.setOnTouchListener { _, event ->
1334
+ scrollGestureDetector.onTouchEvent(event)
1335
+ if (scrollSessionOpen &&
1336
+ (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL)) {
1337
+ scrollSessionOpen = false
1338
+ BridgeUtils.emitPerfEvent(webView, JSONObject().apply {
1339
+ put("type", "scroll-end")
1340
+ put("nativeTime", android.os.SystemClock.elapsedRealtime())
1341
+ })
1342
+ }
1343
+ false
1344
+ }
1345
+ }
1207
1346
  }
1208
1347
  }
@@ -274,6 +274,9 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
274
274
  properties.setProperty("buildType", if (BuildConfig.DEBUG) "debug" else "release")
275
275
  properties.setProperty("buildOptimisation", (!BuildConfig.DEBUG).toString())
276
276
  }
277
+ io.yourname.androidproject.utils.PerfEventBuffer.configure(
278
+ properties.getProperty("profiler.enabled", "false").toBoolean()
279
+ )
277
280
 
278
281
  configureEdgeToEdge()
279
282
 
@@ -286,16 +289,29 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
286
289
  })
287
290
  }
288
291
 
289
- // Initialize MetricsMonitor
292
+ // Initialize MetricsMonitor — recordAppStart() resets the cold-start clock for this
293
+ // Activity lifecycle. The singleton may have been constructed in a previous session
294
+ // (hot restart / Activity recreation), so appStartTime would be stale without this.
290
295
  metricsMonitor = MetricsMonitor.getInstance(this)
296
+ metricsMonitor.recordAppStart()
297
+
298
+ // Boot timing: record activity onCreate as the earliest boot event
299
+ if (BuildConfig.DEBUG) {
300
+ io.yourname.androidproject.utils.PerfEventBuffer.reset()
301
+ io.yourname.androidproject.utils.PerfEventBuffer.add(org.json.JSONObject().apply {
302
+ put("type", "boot-activity-created")
303
+ put("nativeTime", android.os.SystemClock.elapsedRealtime())
304
+ put("thread", Thread.currentThread().name)
305
+ })
306
+ }
291
307
 
292
308
  // Setup UI
293
309
  binding = ActivityMainBinding.inflate(layoutInflater)
294
310
  supportActionBar?.hide()
295
311
  setContentView(binding.root)
296
312
 
297
- // Initialize keyboard utility
298
- keyboardUtil = KeyboardUtil(this, binding.webviewContainer)
313
+ // Initialize keyboard utility (pass webView so keyboard events reach WebPerfCollector)
314
+ keyboardUtil = KeyboardUtil(this, binding.webviewContainer, binding.webview)
299
315
  keyboardUtil.initialize()
300
316
 
301
317
  // Enable hardware acceleration for the window
@@ -351,6 +367,12 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
351
367
  onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
352
368
  override fun handleOnBackPressed() {
353
369
  if (customWebView.canGoBack()) {
370
+ if (BuildConfig.DEBUG) {
371
+ BridgeUtils.emitPerfEvent(customWebView.getWebView(), org.json.JSONObject().apply {
372
+ put("type", "navigation-back")
373
+ put("nativeTime", android.os.SystemClock.elapsedRealtime())
374
+ })
375
+ }
354
376
  customWebView.goBack()
355
377
  } else {
356
378
  // Disable this callback and let the system handle back press
@@ -389,9 +411,6 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
389
411
  customWebView.showOfflineRouteOrOfflinePage(currentUrl)
390
412
  }
391
413
  }
392
-
393
- metricsMonitor.markAppStartComplete()
394
-
395
414
  } catch (e: Exception) {
396
415
  Log.e(TAG, "Failed to load initial URL: ${e.message}")
397
416
  // TODO: Add HTML file workflow - error.html not available yet
@@ -453,6 +472,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
453
472
  }
454
473
 
455
474
  override fun onDestroy() {
475
+ io.yourname.androidproject.utils.PerfEventBuffer.reset()
456
476
  // Log all performance metrics before destroying
457
477
  if (BuildConfig.DEBUG) {
458
478
  Log.d(TAG, "🏁 App shutting down - logging final metrics...")
@@ -5,6 +5,10 @@ import android.os.Debug
5
5
  import android.os.SystemClock
6
6
  import android.util.Log
7
7
  import android.view.Choreographer
8
+ import android.webkit.WebView
9
+ import io.yourname.androidproject.utils.BridgeUtils
10
+ import io.yourname.androidproject.utils.PerfEventBuffer
11
+ import org.json.JSONObject
8
12
  import java.util.concurrent.TimeUnit
9
13
  import java.util.concurrent.atomic.AtomicInteger
10
14
 
@@ -13,11 +17,29 @@ import java.util.concurrent.atomic.AtomicInteger
13
17
  * Tracks key performance metrics and logs them on app destroy.
14
18
  */
15
19
  class MetricsMonitor(private val context: Context) {
20
+
21
+ // WebView reference for emitting perf events — set via attachWebView()
22
+ @Volatile private var webView: WebView? = null
23
+
24
+ fun attachWebView(wv: WebView) {
25
+ webView = wv
26
+ }
27
+
28
+ fun detachWebView() {
29
+ // Flush any open fps-drop episode before losing the WebView reference
30
+ if (inFpsDropEpisode) emitFpsDropEpisode(endTime = SystemClock.elapsedRealtime())
31
+ webView = null
32
+ }
33
+
34
+ private fun emitPerf(payload: JSONObject) {
35
+ webView?.let { BridgeUtils.emitPerfEvent(it, payload) }
36
+ }
16
37
  private val TAG = "MetricsMonitor"
17
38
 
18
39
  // App Startup metrics
19
40
  private var appStartTime: Long = SystemClock.elapsedRealtime()
20
41
  private var coldStartDuration: Long = 0
42
+ private var coldStartCompleted = false
21
43
 
22
44
  // Page Load metrics
23
45
  private val pageLoadTimes = mutableMapOf<String, Long>()
@@ -34,6 +56,15 @@ class MetricsMonitor(private val context: Context) {
34
56
  private var avgFps = 0.0
35
57
  private var fpsReadings = 0
36
58
 
59
+ // fps-drop episode tracking
60
+ private var inFpsDropEpisode = false
61
+ private var episodeStartNativeTime: Long = 0
62
+ private var episodeMinFps = Double.MAX_VALUE
63
+ private var episodeFpsSum = 0.0
64
+ private var episodeSampleCount = 0
65
+ private val FPS_EPISODE_MAX_MS = 10_000L // force-close after 10s
66
+ private var lastStableFps = 60.0 // fps reading just before episode started — used to compute deltaFps
67
+
37
68
  // Memory metrics
38
69
  private var initialMemory: Long = getMemoryUsage()
39
70
  private var peakMemory: Long = initialMemory
@@ -74,8 +105,18 @@ class MetricsMonitor(private val context: Context) {
74
105
  }
75
106
 
76
107
  fun markAppStartComplete() {
108
+ if (coldStartCompleted) return
109
+ coldStartCompleted = true
77
110
  coldStartDuration = SystemClock.elapsedRealtime() - appStartTime
78
111
  Log.d(TAG, "App cold start completed in: $coldStartDuration ms")
112
+ // Buffer so it is delivered with the post-load batch flush when JS is ready.
113
+ // emitPerf (live path) would be a no-op here — JS may not have loaded yet.
114
+ PerfEventBuffer.add(JSONObject().apply {
115
+ put("type", "cold-start")
116
+ put("nativeTime", SystemClock.elapsedRealtime())
117
+ put("durationMs", coldStartDuration)
118
+ put("thread", Thread.currentThread().name)
119
+ })
79
120
  }
80
121
 
81
122
  fun trackPageLoadStart(url: String) {
@@ -144,8 +185,14 @@ class MetricsMonitor(private val context: Context) {
144
185
  uiBlockingEvents++
145
186
  }
146
187
 
147
- fun recordLongTask() {
188
+ fun recordLongTask(durationMs: Long = longTaskThreshold) {
148
189
  longTasksDetected++
190
+ emitPerf(JSONObject().apply {
191
+ put("type", "long-task")
192
+ put("nativeTime", SystemClock.elapsedRealtime())
193
+ put("durationMs", durationMs)
194
+ put("thread", Thread.currentThread().name)
195
+ })
149
196
  }
150
197
 
151
198
  private fun startFrameMonitoring() {
@@ -173,27 +220,106 @@ class MetricsMonitor(private val context: Context) {
173
220
  val timeSpan = now - lastFpsCheckTime
174
221
  lastFpsCheckTime = now
175
222
 
176
- if (timeSpan > 0) {
177
- currentFps = frames * 1000.0 / timeSpan
178
-
179
- // Update min/max/avg
180
- if (minFps == Double.MAX_VALUE) minFps = currentFps
181
- minFps = minFps.coerceAtMost(currentFps)
182
- maxFps = maxFps.coerceAtLeast(currentFps)
183
-
184
- val totalFps = avgFps * fpsReadings + currentFps
185
- fpsReadings++
186
- avgFps = totalFps / fpsReadings
223
+ if (timeSpan <= 0) return
224
+
225
+ currentFps = frames * 1000.0 / timeSpan
226
+
227
+ // Update session-level min/max/avg
228
+ if (minFps == Double.MAX_VALUE) minFps = currentFps
229
+ minFps = minFps.coerceAtMost(currentFps)
230
+ maxFps = maxFps.coerceAtLeast(currentFps)
231
+ val totalFps = avgFps * fpsReadings + currentFps
232
+ fpsReadings++
233
+ avgFps = totalFps / fpsReadings
234
+
235
+ if (currentFps < 55.0) {
236
+ if (!inFpsDropEpisode) {
237
+ // Start new episode — capture the last stable fps as baseline for deltaFps
238
+ inFpsDropEpisode = true
239
+ episodeStartNativeTime = now - timeSpan // start of the measured window
240
+ episodeMinFps = currentFps
241
+ episodeFpsSum = currentFps
242
+ episodeSampleCount = 1
243
+ // lastStableFps already holds the previous reading (set in the else branch below)
244
+ } else {
245
+ // Accumulate into current episode
246
+ episodeMinFps = episodeMinFps.coerceAtMost(currentFps)
247
+ episodeFpsSum += currentFps
248
+ episodeSampleCount++
249
+
250
+ // Force-close if episode has run for >= 10s
251
+ val episodeDuration = now - episodeStartNativeTime
252
+ if (episodeDuration >= FPS_EPISODE_MAX_MS) {
253
+ emitFpsDropEpisode(endTime = now)
254
+ // Immediately start a new episode for the continuing drop
255
+ inFpsDropEpisode = true
256
+ episodeStartNativeTime = now
257
+ episodeMinFps = currentFps
258
+ episodeFpsSum = currentFps
259
+ episodeSampleCount = 1
260
+ }
261
+ }
262
+ } else {
263
+ // FPS is good — update lastStableFps and close episode if one was open
264
+ lastStableFps = currentFps
265
+ if (inFpsDropEpisode) {
266
+ emitFpsDropEpisode(endTime = now)
267
+ }
187
268
  }
188
269
  }
189
270
 
271
+ private fun emitFpsDropEpisode(endTime: Long) {
272
+ if (!inFpsDropEpisode) return
273
+ val durationMs = endTime - episodeStartNativeTime
274
+ val avgEpisodeFps = if (episodeSampleCount > 0) episodeFpsSum / episodeSampleCount else episodeMinFps
275
+ // deltaFps = how far FPS fell from the last stable reading before the episode
276
+ val deltaFps = lastStableFps - episodeMinFps
277
+ emitPerf(JSONObject().apply {
278
+ put("type", "fps-drop-episode")
279
+ put("startNativeTime", episodeStartNativeTime)
280
+ put("endNativeTime", endTime)
281
+ put("durationMs", durationMs)
282
+ put("minFps", episodeMinFps)
283
+ put("avgFps", avgEpisodeFps)
284
+ put("deltaFps", deltaFps) // drop magnitude from last stable fps
285
+ put("baselineFps", lastStableFps)
286
+ put("thread", Thread.currentThread().name)
287
+ })
288
+ inFpsDropEpisode = false
289
+ episodeMinFps = Double.MAX_VALUE
290
+ episodeFpsSum = 0.0
291
+ episodeSampleCount = 0
292
+ }
293
+
190
294
  private fun monitorMemoryPeriodically() {
191
295
  Thread {
192
296
  while (!Thread.interrupted()) {
193
297
  try {
194
- currentMemory = getMemoryUsage()
195
- peakMemory = peakMemory.coerceAtLeast(currentMemory)
196
- Thread.sleep(5000) // Check every 5 seconds
298
+ val memInfo = Debug.MemoryInfo()
299
+ Debug.getMemoryInfo(memInfo)
300
+
301
+ // PSS breakdown — all values in KB, convert to MB
302
+ val jvmMb = memInfo.dalvikPss / 1024.0 // Kotlin/Java heap
303
+ val webviewMb = memInfo.nativePss / 1024.0 // V8 + Blink + JNI (WebView proxy)
304
+ val otherMb = memInfo.otherPss / 1024.0 // graphics buffers, code, stack
305
+ val totalMb = memInfo.totalPss / 1024.0 // what Android OOM killer sees
306
+
307
+ currentMemory = memInfo.totalPss.toLong()
308
+ peakMemory = peakMemory.coerceAtLeast(currentMemory)
309
+
310
+ val now = SystemClock.elapsedRealtime()
311
+ emitPerf(JSONObject().apply {
312
+ put("type", "memory-snapshot")
313
+ put("nativeTime", now)
314
+ put("jvmMb", jvmMb)
315
+ put("webviewMb", webviewMb)
316
+ put("otherMb", otherMb)
317
+ put("totalMb", totalMb)
318
+ put("peakMb", peakMemory / 1024.0)
319
+ put("thread", Thread.currentThread().name)
320
+ if (coldStartDuration > 0) put("coldStartMs", coldStartDuration)
321
+ })
322
+ Thread.sleep(5000)
197
323
  } catch (e: InterruptedException) {
198
324
  break
199
325
  }
@@ -324,7 +450,7 @@ class MetricsMonitor(private val context: Context) {
324
450
  }
325
451
 
326
452
  fun cleanup() {
327
- // Remove frame callback
453
+ if (inFpsDropEpisode) emitFpsDropEpisode(endTime = SystemClock.elapsedRealtime())
328
454
  Choreographer.getInstance().removeFrameCallback(frameCallback)
329
455
  }
330
456
 
@@ -338,4 +464,4 @@ class MetricsMonitor(private val context: Context) {
338
464
  }
339
465
  }
340
466
  }
341
- }
467
+ }
@@ -332,8 +332,11 @@ class NativeBridge(
332
332
 
333
333
  @JavascriptInterface
334
334
  fun openCamera(options: String?) {
335
+ val callId = "openCamera:${android.os.SystemClock.elapsedRealtime()}"
336
+ BridgeUtils.bridgeCallReceived(callId, "openCamera")
335
337
  BridgeUtils.safeExecute(webView, BridgeUtils.WebEvents.ON_CAMERA_ERROR, "open camera") {
336
338
  mainActivity.runOnUiThread {
339
+ BridgeUtils.bridgeCallDispatched(callId)
337
340
  if (CameraUtils.hasCameraPermission(mainActivity)) {
338
341
  launchCamera()
339
342
  } else {
@@ -492,10 +495,13 @@ class NativeBridge(
492
495
 
493
496
  @JavascriptInterface
494
497
  fun getDeviceInfo(options: String?) {
498
+ val callId = "getDeviceInfo:${android.os.SystemClock.elapsedRealtime()}"
499
+ BridgeUtils.bridgeCallReceived(callId, "getDeviceInfo")
495
500
  BridgeUtils.safeExecute(webView, BridgeUtils.WebEvents.ON_DEVICE_INFO_ERROR, "get device info") {
496
501
  mainActivity.runOnUiThread {
497
502
  val deviceInfo = DeviceInfoUtils.getDeviceInfo(mainActivity, properties)
498
503
  BridgeUtils.logDebug(TAG, "Device info retrieved: $deviceInfo")
504
+ BridgeUtils.bridgeCallDispatched(callId)
499
505
  BridgeUtils.notifyWeb(webView, BridgeUtils.WebEvents.ON_DEVICE_INFO_SUCCESS, deviceInfo.toString())
500
506
  }
501
507
  }