catalyst-core 0.2.0-beta.2 → 0.3.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/babel.native.config.cjs +8 -0
  2. package/bin/catalyst.js +49 -105
  3. package/changelog.md +8 -0
  4. package/dist/caching.js +1 -1
  5. package/dist/index.jsx +8 -0
  6. package/dist/logger.js +74 -3
  7. package/dist/native/androidProject/app/build.gradle.kts +1 -0
  8. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +143 -4
  9. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +26 -6
  10. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MetricsMonitor.kt +143 -17
  11. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeBridge.kt +6 -0
  12. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/WebCacheManager.kt +26 -0
  13. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/BridgeUtils.kt +56 -1
  14. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +48 -8
  15. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/PerfDeliveryState.kt +36 -0
  16. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/PerfEventBuffer.kt +289 -0
  17. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/ProfilerNavigationState.kt +13 -0
  18. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/ProfilerLifecycleTest.kt +51 -0
  19. package/dist/native/androidSetup.js +300 -8
  20. package/dist/native/bridge/WebBridge.js +481 -59
  21. package/dist/native/bridge/constants/NativeInterfaces.js +290 -44
  22. package/dist/native/bridge/errors.js +250 -26
  23. package/dist/native/bridge/hooks/index.js +125 -1
  24. package/dist/native/bridge/hooks/useCamera.js +347 -9
  25. package/dist/native/bridge/hooks/useDataProtection.js +200 -2
  26. package/dist/native/bridge/hooks/useDeviceInfo.js +71 -2
  27. package/dist/native/bridge/hooks/useFilePicker.js +429 -2
  28. package/dist/native/bridge/hooks/useGoogleSignIn.js +81 -1
  29. package/dist/native/bridge/hooks/useHapticFeedback.js +196 -1
  30. package/dist/native/bridge/hooks/useIntent.js +95 -1
  31. package/dist/native/bridge/hooks/useNativeTransition.js +202 -27
  32. package/dist/native/bridge/hooks/useNetworkStatus.js +84 -3
  33. package/dist/native/bridge/hooks/useNotification.js +318 -2
  34. package/dist/native/bridge/hooks/useSafeArea.js +84 -2
  35. package/dist/native/bridge/hooks/useVideoStream.js +434 -6
  36. package/dist/native/bridge/hooks/utils.js +27 -1
  37. package/dist/native/bridge/hooks.js +16 -1
  38. package/dist/native/bridge/perf/collectors/ApiCollector.js +85 -0
  39. package/dist/native/bridge/perf/collectors/CacheCollector.js +215 -0
  40. package/dist/native/bridge/perf/collectors/HookCollector.js +165 -0
  41. package/dist/native/bridge/perf/collectors/InsightsCollector.js +505 -0
  42. package/dist/native/bridge/perf/collectors/InteractionCollector.js +273 -0
  43. package/dist/native/bridge/perf/collectors/KeyboardCollector.js +124 -0
  44. package/dist/native/bridge/perf/collectors/NavigationCollector.js +294 -0
  45. package/dist/native/bridge/perf/collectors/NetworkTimingCollector.js +183 -0
  46. package/dist/native/bridge/perf/collectors/RenderCollector.js +284 -0
  47. package/dist/native/bridge/perf/collectors/ScrollCollector.js +111 -0
  48. package/dist/native/bridge/perf/collectors/ThreadCollector.js +111 -0
  49. package/dist/native/bridge/perf/core/constants.js +126 -0
  50. package/dist/native/bridge/perf/core/measure.js +189 -0
  51. package/dist/native/bridge/perf/core/session.js +100 -0
  52. package/dist/native/bridge/perf/core/store.js +554 -0
  53. package/dist/native/bridge/perf/core/summary.js +174 -0
  54. package/dist/native/bridge/perf/core/traceExport.js +240 -0
  55. package/dist/native/bridge/perf/core/waterfalls.js +146 -0
  56. package/dist/native/bridge/perf/index.js +402 -0
  57. package/dist/native/bridge/safeArea.js +53 -5
  58. package/dist/native/bridge/standardizedHooks.js +595 -82
  59. package/dist/native/bridge/useBaseHook.js +274 -38
  60. package/dist/native/bridge/utils/CameraUtils.js +201 -56
  61. package/dist/native/bridge/utils/FileObjectConverter.js +129 -18
  62. package/dist/native/bridge/utils/NativeBridge.js +425 -141
  63. package/dist/native/buildAndroid/assets.js +510 -13
  64. package/dist/native/buildAndroid/build.js +296 -19
  65. package/dist/native/buildAndroid/config.js +87 -1
  66. package/dist/native/buildAndroid/index.js +374 -1
  67. package/dist/native/buildAppAndroid.js +34 -2
  68. package/dist/native/buildAppIos.js +62 -2
  69. package/dist/native/buildIos/assets.js +609 -8
  70. package/dist/native/buildIos/build.js +639 -20
  71. package/dist/native/buildIos/config.js +294 -12
  72. package/dist/native/buildIos/index.js +343 -6
  73. package/dist/native/buildIos/plugins.js +343 -16
  74. package/dist/native/internal-plugins/device-info-plugin/android/DeviceInfoPlugin.kt +43 -0
  75. package/dist/native/internal-plugins/device-info-plugin/ios/DeviceInfoPlugin.swift +28 -0
  76. package/dist/native/internal-plugins/device-info-plugin/manifest.json +19 -0
  77. package/dist/native/internalPluginUtils.js +232 -7
  78. package/dist/native/iosnativeWebView/Sources/Core/Constants/CatalystConstants.swift +19 -0
  79. package/dist/native/iosnativeWebView/Sources/Core/Constants/ConfigConstants.swift +4 -0
  80. package/dist/native/iosnativeWebView/Sources/Core/Utils/BootTimingUtility.swift +5 -1
  81. package/dist/native/iosnativeWebView/Sources/Core/Utils/CacheManager.swift +10 -0
  82. package/dist/native/iosnativeWebView/Sources/Core/Utils/DisplayLinkPerfMonitor.swift +89 -0
  83. package/dist/native/iosnativeWebView/Sources/Core/Utils/PerfEventBuffer.swift +373 -0
  84. package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeFileHandler.swift +35 -1
  85. package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeMessageValidator.swift +24 -11
  86. package/dist/native/iosnativeWebView/Sources/Core/WebView/NativeBridge.swift +9 -0
  87. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebView.swift +126 -0
  88. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +75 -1
  89. package/dist/native/iosnativeWebView/iosnativeWebViewTests/BridgeMessageValidatorTests.swift +13 -0
  90. package/dist/native/package.json +3 -0
  91. package/dist/native/plugin-bridge/PluginBridge.js +227 -1
  92. package/dist/native/pluginComposerAndroid.js +407 -23
  93. package/dist/native/pluginComposerIos.js +435 -11
  94. package/dist/native/renameAndroidProject.js +1094 -88
  95. package/dist/native/setupEmulatorIos.js +289 -22
  96. package/dist/native/setupServer.js +160 -19
  97. package/dist/native/terminalProgress.js +199 -11
  98. package/dist/native/utils.js +193 -15
  99. package/dist/offline/catalyst-sw.js +145 -1
  100. package/dist/offline/registerServiceWorker.js +33 -1
  101. package/dist/otel.js +375 -46
  102. package/dist/router/ClientRouter.js +14 -2
  103. package/dist/router/ServerRouter.js +9 -1
  104. package/dist/router.js +1 -1
  105. package/dist/scripts/build.js +112 -3
  106. package/dist/scripts/checkVersion.js +3 -1
  107. package/dist/scripts/cleanCache.js +30 -1
  108. package/dist/scripts/generateOfflineManifest.js +116 -1
  109. package/dist/scripts/loadEnvironmentVariables.js +32 -6
  110. package/dist/scripts/plugins.js +309 -1
  111. package/dist/scripts/preServerInit.js +13 -0
  112. package/dist/scripts/scriptUtils.js +54 -3
  113. package/dist/scripts/serve.js +51 -4
  114. package/dist/scripts/start.js +47 -10
  115. package/dist/scripts/validator.js +149 -1
  116. package/dist/sentry.cjs +127 -0
  117. package/dist/sentry.js +125 -5
  118. package/dist/server/expressServer.js +250 -5
  119. package/dist/server/manifestCache.js +36 -0
  120. package/dist/server/renderer/ChunkExtractor.js +144 -0
  121. package/dist/server/renderer/document/Body.jsx +59 -0
  122. package/dist/server/renderer/document/FastRefresh.jsx +20 -0
  123. package/dist/server/renderer/document/Head.jsx +60 -0
  124. package/dist/server/renderer/document/index.jsx +2 -0
  125. package/dist/server/renderer/extract.js +149 -31
  126. package/dist/server/renderer/handler.jsx +433 -0
  127. package/dist/server/renderer/index.js +8 -1
  128. package/dist/server/renderer/render.js +47 -0
  129. package/dist/server/utils/botDetectionMiddleware.js +29 -0
  130. package/dist/server/utils/cjsRequire.js +10 -0
  131. package/dist/server/utils/userAgentUtil.js +87 -9
  132. package/dist/server/utils/validator.js +164 -1
  133. package/dist/vite/FastRefresh.jsx +20 -0
  134. package/dist/vite/inject-cache-key-plugin.js +276 -0
  135. package/dist/vite/loadCustomViteConfig.js +22 -0
  136. package/dist/vite/manifest-categorization-plugin.js +455 -0
  137. package/dist/vite/node-loader.mjs +458 -0
  138. package/dist/vite/scssParams.js +21 -0
  139. package/dist/vite/vite.config.client.js +130 -0
  140. package/dist/vite/vite.config.js +422 -0
  141. package/dist/vite/vite.config.server.js +84 -0
  142. package/dist/web-router/components/MetaTag.jsx +34 -0
  143. package/dist/web-router/components/RouterDataProvider.jsx +332 -0
  144. package/dist/web-router/components/Split.jsx +200 -0
  145. package/dist/web-router/components/SplitInview.jsx +106 -0
  146. package/dist/web-router/components/SsrRequestContext.jsx +14 -0
  147. package/dist/web-router/context.jsx +12 -0
  148. package/dist/web-router/hooks.jsx +72 -0
  149. package/dist/web-router/types/components/MetaTag.d.ts +2 -0
  150. package/dist/web-router/types/components/RouterDataProvider.d.ts +95 -0
  151. package/dist/web-router/types/context.d.ts +14 -0
  152. package/dist/web-router/types/hooks.d.ts +1 -0
  153. package/dist/web-router/types/index.d.ts +3 -0
  154. package/dist/web-router/types/utils/metaDataUtils.d.ts +24 -0
  155. package/dist/web-router/utils/metaDataUtils.jsx +119 -0
  156. package/mcp_v2/conversion-tasks.json +35 -17
  157. package/mcp_v2/knowledge-base.json +124 -13
  158. package/mcp_v2/lib/helpers.js +10 -0
  159. package/mcp_v2/mcp.js +4 -4
  160. package/mcp_v2/package.json +1 -0
  161. package/mcp_v2/tools/build.js +116 -20
  162. package/mcp_v2/tools/conversion.js +106 -5
  163. package/mcp_v2/tools/tasks.js +18 -14
  164. package/package.json +35 -68
  165. package/babel.config.js +0 -32
  166. package/dist/index.js +0 -1
  167. package/dist/scripts/devBuild.js +0 -3
  168. package/dist/scripts/devServe.js +0 -3
  169. package/dist/scripts/loadScriptsBeforeServerStarts.js +0 -4
  170. package/dist/scripts/registerAliases.js +0 -6
  171. package/dist/server/renderer/document/Body.js +0 -11
  172. package/dist/server/renderer/document/Head.js +0 -9
  173. package/dist/server/renderer/document/index.js +0 -1
  174. package/dist/server/renderer/handler.js +0 -47
  175. package/dist/server/startServer.js +0 -57
  176. package/dist/webpack/babel-plugins/remove-client.plugin.js +0 -5
  177. package/dist/webpack/babel-plugins/remove-ssr.plugin.js +0 -5
  178. package/dist/webpack/babel.config.client.js +0 -1
  179. package/dist/webpack/babel.config.ssr.js +0 -1
  180. package/dist/webpack/base.babel.js +0 -7
  181. package/dist/webpack/development.client.babel.js +0 -10
  182. package/dist/webpack/production.client.babel.js +0 -4
  183. package/dist/webpack/production.ssr.babel.js +0 -8
  184. package/dist/webpack/scssParams.js +0 -9
@@ -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
  }
@@ -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
+ }