catalyst-core 0.2.0-beta.1 → 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 (67) hide show
  1. package/dist/native/androidProject/app/build.gradle.kts +26 -8
  2. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +381 -78
  3. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +29 -8
  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 +7 -1
  6. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/OfflineCacheService.kt +374 -0
  7. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/WebCacheManager.kt +162 -26
  8. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/BridgeUtils.kt +56 -1
  9. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/KeyboardUtil.kt +48 -8
  10. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/PerfDeliveryState.kt +36 -0
  11. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/PerfEventBuffer.kt +289 -0
  12. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/ProfilerNavigationState.kt +13 -0
  13. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/CustomWebviewTest.kt +7 -2
  14. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/ProfilerLifecycleTest.kt +51 -0
  15. package/dist/native/bridge/WebBridge.js +2 -2
  16. package/dist/native/bridge/constants/NativeInterfaces.js +2 -1
  17. package/dist/native/bridge/perf/collectors/ApiCollector.js +22 -0
  18. package/dist/native/bridge/perf/collectors/CacheCollector.js +40 -0
  19. package/dist/native/bridge/perf/collectors/HookCollector.js +43 -0
  20. package/dist/native/bridge/perf/collectors/InsightsCollector.js +47 -0
  21. package/dist/native/bridge/perf/collectors/InteractionCollector.js +38 -0
  22. package/dist/native/bridge/perf/collectors/KeyboardCollector.js +25 -0
  23. package/dist/native/bridge/perf/collectors/NavigationCollector.js +53 -0
  24. package/dist/native/bridge/perf/collectors/NetworkTimingCollector.js +7 -0
  25. package/dist/native/bridge/perf/collectors/RenderCollector.js +40 -0
  26. package/dist/native/bridge/perf/collectors/ScrollCollector.js +25 -0
  27. package/dist/native/bridge/perf/collectors/ThreadCollector.js +27 -0
  28. package/dist/native/bridge/perf/core/constants.js +43 -0
  29. package/dist/native/bridge/perf/core/measure.js +40 -0
  30. package/dist/native/bridge/perf/core/session.js +34 -0
  31. package/dist/native/bridge/perf/core/store.js +1 -0
  32. package/dist/native/bridge/perf/core/summary.js +1 -0
  33. package/dist/native/bridge/perf/core/traceExport.js +1 -0
  34. package/dist/native/bridge/perf/core/waterfalls.js +1 -0
  35. package/dist/native/bridge/perf/index.js +81 -0
  36. package/dist/native/buildAndroid/assets.js +2 -2
  37. package/dist/native/buildIos/config.js +1 -1
  38. package/dist/native/iosnativeWebView/Sources/Core/Constants/CatalystConstants.swift +19 -0
  39. package/dist/native/iosnativeWebView/Sources/Core/Constants/ConfigConstants.swift +4 -0
  40. package/dist/native/iosnativeWebView/Sources/Core/Utils/BootTimingUtility.swift +5 -1
  41. package/dist/native/iosnativeWebView/Sources/Core/Utils/CacheManager.swift +49 -4
  42. package/dist/native/iosnativeWebView/Sources/Core/Utils/DisplayLinkPerfMonitor.swift +89 -0
  43. package/dist/native/iosnativeWebView/Sources/Core/Utils/OfflineCacheService.swift +683 -0
  44. package/dist/native/iosnativeWebView/Sources/Core/Utils/OfflineURLSchemeHandler.swift +94 -0
  45. package/dist/native/iosnativeWebView/Sources/Core/Utils/PerfEventBuffer.swift +373 -0
  46. package/dist/native/iosnativeWebView/Sources/Core/Utils/ResourceURLProtocol.swift +19 -6
  47. package/dist/native/iosnativeWebView/Sources/Core/Utils/SafeAreaUtils.swift +1 -0
  48. package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeCommandHandler.swift +1 -0
  49. package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeFileHandler.swift +35 -1
  50. package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeMessageValidator.swift +24 -11
  51. package/dist/native/iosnativeWebView/Sources/Core/WebView/NativeBridge.swift +9 -0
  52. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebView.swift +141 -1
  53. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +157 -3
  54. package/dist/native/iosnativeWebView/iosnativeWebViewTests/BridgeMessageValidatorTests.swift +13 -0
  55. package/dist/native/renameAndroidProject.js +2 -2
  56. package/dist/offline/catalyst-sw.js +1 -0
  57. package/dist/offline/registerServiceWorker.js +1 -0
  58. package/dist/router/ClientRouter.js +1 -1
  59. package/dist/scripts/build.js +1 -1
  60. package/dist/scripts/devBuild.js +1 -1
  61. package/dist/scripts/generateOfflineManifest.js +1 -0
  62. package/dist/server/expressServer.js +2 -1
  63. package/dist/server/renderer/document/Body.js +1 -1
  64. package/dist/server/renderer/handler.js +3 -3
  65. package/dist/webpack/base.babel.js +1 -1
  66. package/mcp_v2/knowledge-base.json +13 -0
  67. package/package.json +1 -1
@@ -5,7 +5,9 @@ import android.util.Log
5
5
  import android.util.LruCache
6
6
  import android.webkit.WebResourceResponse
7
7
  import io.yourname.androidproject.BuildConfig
8
+ import io.yourname.androidproject.utils.PerfEventBuffer
8
9
  import kotlinx.coroutines.*
10
+ import org.json.JSONObject
9
11
  import java.io.*
10
12
  import java.net.HttpURLConnection
11
13
  import java.net.URL
@@ -14,6 +16,7 @@ import java.util.concurrent.TimeUnit
14
16
 
15
17
  class WebCacheManager(private val context: Context, private val properties: java.util.Properties? = null) {
16
18
  private val TAG = "WebCacheManager"
19
+ private val FLOW_TAG = "CatalystOfflineFlow"
17
20
  private val cacheDir = File(context.cacheDir, "webview_cache")
18
21
  private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
19
22
  private val BUFFER_SIZE = 32 * 1024
@@ -35,6 +38,24 @@ class WebCacheManager(private val context: Context, private val properties: java
35
38
  (Runtime.getRuntime().maxMemory() / 1024 / memoryFraction).toInt()
36
39
  )
37
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
+
38
59
  init {
39
60
  try {
40
61
  cacheDir.mkdirs()
@@ -51,20 +72,36 @@ class WebCacheManager(private val context: Context, private val properties: java
51
72
  }
52
73
  }
53
74
 
54
- data class CacheEntry(
55
- val response: WebResourceResponse,
75
+ private data class CacheEntry(
76
+ val data: ByteArray,
77
+ val mimeType: String,
78
+ val encoding: String,
79
+ val responseHeaders: Map<String, String> = emptyMap(),
56
80
  val timestamp: Long = System.currentTimeMillis(),
57
81
  val eTag: String? = null,
58
82
  val lastModified: String? = null
59
- )
83
+ ) {
84
+ fun toResponse(): WebResourceResponse {
85
+ return WebResourceResponse(
86
+ mimeType,
87
+ encoding,
88
+ 200,
89
+ "OK",
90
+ responseHeaders.ifEmpty { null },
91
+ ByteArrayInputStream(data)
92
+ )
93
+ }
94
+ }
60
95
 
61
96
  /**
62
97
  * Synchronous cache check - only returns cached content if immediately available
63
98
  * Does not perform network requests or background revalidation
64
99
  * Used by ServiceWorker to avoid blocking
65
100
  */
101
+ @Suppress("UNUSED_PARAMETER")
66
102
  fun getCachedResponseSync(url: String, headers: Map<String, String>): WebResourceResponse? {
67
103
  return try {
104
+ val startMs = android.os.SystemClock.elapsedRealtime()
68
105
  val cacheKey = generateCacheKey(url)
69
106
  val currentTime = System.currentTimeMillis()
70
107
 
@@ -76,8 +113,13 @@ class WebCacheManager(private val context: Context, private val properties: java
76
113
  if (age <= maxAge + staleWhileRevalidate) {
77
114
  if (BuildConfig.DEBUG) {
78
115
  Log.d(TAG, "✅ Serving from memory cache (sync): $url")
116
+ Log.d(FLOW_TAG, "ASSET cache-hit source=memory ageMs=$age url=$url")
79
117
  }
80
- return memoryCacheEntry.response
118
+ emitCachePerfEvent("cache-hit-memory", url, startMs)
119
+ return memoryCacheEntry.toResponse()
120
+ }
121
+ if (BuildConfig.DEBUG) {
122
+ Log.d(FLOW_TAG, "ASSET cache-expired source=memory ageMs=$age url=$url")
81
123
  }
82
124
  }
83
125
 
@@ -88,20 +130,50 @@ class WebCacheManager(private val context: Context, private val properties: java
88
130
  if (fileAge <= maxAge + staleWhileRevalidate) {
89
131
  val metadata = loadMetadata(cacheKey)
90
132
  val response = createResponseFromCache(cacheFile, metadata)
133
+ memoryCache.put(cacheKey, createEntryFromCache(cacheFile, metadata, cacheFile.lastModified()))
91
134
  if (BuildConfig.DEBUG) {
92
135
  Log.d(TAG, "✅ Serving from disk cache (sync): $url")
136
+ Log.d(FLOW_TAG, "ASSET cache-hit source=disk ageMs=$fileAge bytes=${cacheFile.length()} mime=${metadata.mimeType} encoding=${metadata.encoding} url=$url")
93
137
  }
138
+ emitCachePerfEvent("cache-hit-disk", url, startMs)
94
139
  return response
95
140
  }
141
+ if (BuildConfig.DEBUG) {
142
+ Log.d(FLOW_TAG, "ASSET cache-expired source=disk ageMs=$fileAge bytes=${cacheFile.length()} url=$url")
143
+ }
96
144
  }
97
145
 
98
146
  // No valid cache available
99
147
  if (BuildConfig.DEBUG) {
100
148
  Log.d(TAG, "❌ No valid cache available (sync): $url")
149
+ Log.d(FLOW_TAG, "ASSET cache-miss url=$url")
101
150
  }
151
+ emitCachePerfEvent("cache-miss-fetch", url, startMs)
102
152
  null
103
153
  } catch (e: Exception) {
104
154
  Log.e(TAG, "Error in getCachedResponseSync for URL: $url: ${e.message}")
155
+ if (BuildConfig.DEBUG) {
156
+ Log.e(FLOW_TAG, "ASSET cache-error phase=read url=$url error=${e.message}", e)
157
+ }
158
+ null
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Synchronous cache-or-network path for WebView interception.
164
+ *
165
+ * shouldInterceptRequest runs off the UI thread, so it is safe to block here.
166
+ * Returning the fetched response from this method guarantees that the same
167
+ * request which populated the cache also succeeds in the WebView.
168
+ */
169
+ fun getCachedResponseOrFetchSync(url: String, headers: Map<String, String>): WebResourceResponse? {
170
+ return try {
171
+ getCachedResponseSync(url, headers) ?: fetchAndCacheResourceBlocking(url, headers, generateCacheKey(url))
172
+ } catch (e: Exception) {
173
+ Log.e(TAG, "Error in getCachedResponseOrFetchSync for URL: $url: ${e.message}")
174
+ if (BuildConfig.DEBUG) {
175
+ Log.e(FLOW_TAG, "ASSET cache-error phase=cache-or-fetch url=$url error=${e.message}", e)
176
+ }
105
177
  null
106
178
  }
107
179
  }
@@ -123,7 +195,7 @@ class WebCacheManager(private val context: Context, private val properties: java
123
195
  if (BuildConfig.DEBUG) {
124
196
  Log.d(TAG, "Serving fresh content from memory cache: $url")
125
197
  }
126
- return@withContext memoryCacheEntry.response
198
+ return@withContext memoryCacheEntry.toResponse()
127
199
  }
128
200
  age <= maxAge + staleWhileRevalidate -> {
129
201
  // Stale content, but within revalidate window
@@ -131,7 +203,7 @@ class WebCacheManager(private val context: Context, private val properties: java
131
203
  Log.d(TAG, "Serving stale content while revalidating: $url")
132
204
  }
133
205
  revalidateInBackground(url, headers, cacheKey, memoryCacheEntry)
134
- return@withContext memoryCacheEntry.response
206
+ return@withContext memoryCacheEntry.toResponse()
135
207
  }
136
208
  }
137
209
  }
@@ -146,18 +218,13 @@ class WebCacheManager(private val context: Context, private val properties: java
146
218
  fileAge <= maxAge -> {
147
219
  // Fresh content from disk
148
220
  val response = createResponseFromCache(cacheFile, metadata)
149
- memoryCache.put(cacheKey, CacheEntry(response))
221
+ memoryCache.put(cacheKey, createEntryFromCache(cacheFile, metadata, cacheFile.lastModified()))
150
222
  return@withContext response
151
223
  }
152
224
  fileAge <= maxAge + staleWhileRevalidate -> {
153
225
  // Stale content from disk, revalidate in background
154
226
  val response = createResponseFromCache(cacheFile, metadata)
155
- val cacheEntry = CacheEntry(
156
- response = response,
157
- timestamp = cacheFile.lastModified(),
158
- eTag = metadata.eTag,
159
- lastModified = metadata.lastModified
160
- )
227
+ val cacheEntry = createEntryFromCache(cacheFile, metadata, cacheFile.lastModified())
161
228
  revalidateInBackground(url, headers, cacheKey, cacheEntry)
162
229
  return@withContext response
163
230
  }
@@ -168,7 +235,7 @@ class WebCacheManager(private val context: Context, private val properties: java
168
235
  if (BuildConfig.DEBUG) {
169
236
  Log.d(TAG, "❌ Cache miss, fetching fresh content: $url")
170
237
  }
171
- fetchAndCacheResource(url, headers, cacheKey)
238
+ fetchAndCacheResourceBlocking(url, headers, cacheKey)
172
239
  } catch (e: Exception) {
173
240
  Log.e(TAG, "Error in getCachedResponse for URL: $url: ${e.message}")
174
241
  if (BuildConfig.DEBUG) {
@@ -282,7 +349,7 @@ class WebCacheManager(private val context: Context, private val properties: java
282
349
  if (BuildConfig.DEBUG) {
283
350
  Log.d(TAG, "⚡ Content changed, updating cache: $url")
284
351
  }
285
- fetchAndCacheResource(url, headers, cacheKey)
352
+ fetchAndCacheResourceBlocking(url, headers, cacheKey)
286
353
  }
287
354
  }
288
355
  } catch (e: Exception) {
@@ -308,25 +375,33 @@ class WebCacheManager(private val context: Context, private val properties: java
308
375
  }
309
376
  }
310
377
 
311
- private suspend fun fetchAndCacheResource(
378
+ private fun fetchAndCacheResourceBlocking(
312
379
  url: String,
313
380
  headers: Map<String, String>,
314
381
  cacheKey: String
315
- ): WebResourceResponse? = withContext(Dispatchers.IO) {
382
+ ): WebResourceResponse? {
316
383
  var connection: HttpURLConnection? = null
384
+ val startMs = android.os.SystemClock.elapsedRealtime()
317
385
  try {
318
386
  connection = URL(url).openConnection() as HttpURLConnection
319
387
  headers.forEach { (key, value) ->
320
- connection.setRequestProperty(key, value)
388
+ if (!key.equals("Accept-Encoding", ignoreCase = true)) {
389
+ connection.setRequestProperty(key, value)
390
+ }
321
391
  }
392
+ connection.setRequestProperty("Accept-Encoding", "identity")
322
393
 
323
394
  connection.connectTimeout = 15000
324
395
  connection.readTimeout = 15000
396
+ if (BuildConfig.DEBUG) {
397
+ Log.d(FLOW_TAG, "ASSET network-fetch start url=$url headers=${headers.keys.sorted()}")
398
+ }
325
399
  connection.connect()
326
400
 
327
401
  if (connection.responseCode == HttpURLConnection.HTTP_OK) {
328
- val mimeType = connection.contentType ?: "application/octet-stream"
329
- val encoding = connection.contentEncoding ?: "utf-8"
402
+ val contentType = connection.contentType ?: "application/octet-stream"
403
+ val mimeType = mimeTypeFromContentType(contentType)
404
+ val encoding = charsetFromContentType(contentType)
330
405
  val eTag = connection.getHeaderField("ETag")
331
406
  val lastModified = connection.getHeaderField("Last-Modified")
332
407
 
@@ -336,13 +411,17 @@ class WebCacheManager(private val context: Context, private val properties: java
336
411
  while (true) {
337
412
  val key = connection.getHeaderFieldKey(i) ?: break
338
413
  val value = connection.getHeaderField(i)
339
- if (key.isNotEmpty()) savedHeaders[key] = value
414
+ if (key.isNotEmpty() && shouldReplayHeader(key)) savedHeaders[key] = value
340
415
  i++
341
416
  }
417
+ savedHeaders["Content-Type"] = contentType
342
418
 
343
419
  val responseBytes = connection.inputStream.use { it.readBytes() }
344
420
  if (!isValidResponse(mimeType, responseBytes)) {
345
- return@withContext null
421
+ if (BuildConfig.DEBUG) {
422
+ Log.w(FLOW_TAG, "ASSET network-fetch invalid-response status=${connection.responseCode} contentType=$contentType bytes=${responseBytes.size} url=$url")
423
+ }
424
+ return null
346
425
  }
347
426
 
348
427
  // Create response for immediate use
@@ -350,7 +429,10 @@ class WebCacheManager(private val context: Context, private val properties: java
350
429
 
351
430
  // Cache the response
352
431
  val cacheEntry = CacheEntry(
353
- response = WebResourceResponse(mimeType, encoding, 200, "OK", savedHeaders, ByteArrayInputStream(responseBytes.copyOf())),
432
+ data = responseBytes.copyOf(),
433
+ mimeType = mimeType,
434
+ encoding = encoding,
435
+ responseHeaders = savedHeaders,
354
436
  eTag = eTag,
355
437
  lastModified = lastModified
356
438
  )
@@ -370,21 +452,35 @@ class WebCacheManager(private val context: Context, private val properties: java
370
452
  ))
371
453
  if (BuildConfig.DEBUG) {
372
454
  Log.d(TAG, "✅ Successfully cached response for: $url")
455
+ Log.d(FLOW_TAG, "ASSET stored bytes=${responseBytes.size} mime=$mimeType encoding=$encoding url=$url")
373
456
  }
374
457
  } catch (e: Exception) {
375
458
  Log.e(TAG, "Error saving to disk cache for URL: $url: ${e.message}")
459
+ if (BuildConfig.DEBUG) {
460
+ Log.e(FLOW_TAG, "ASSET store-error url=$url error=${e.message}", e)
461
+ }
376
462
  }
377
463
  }
378
464
 
379
- return@withContext response
465
+ if (BuildConfig.DEBUG) {
466
+ Log.d(FLOW_TAG, "ASSET network-fetch success status=${connection.responseCode} bytes=${responseBytes.size} contentType=$contentType replayMime=$mimeType replayEncoding=$encoding url=$url")
467
+ }
468
+ emitCachePerfEvent("network-fetch-complete", url, startMs, connection.responseCode)
469
+ return response
470
+ }
471
+ if (BuildConfig.DEBUG) {
472
+ Log.w(FLOW_TAG, "ASSET network-fetch non-200 status=${connection.responseCode} contentType=${connection.contentType} url=$url")
380
473
  }
381
- return@withContext null
474
+ return null
382
475
  } catch (e: Exception) {
383
476
  Log.e(TAG, "Error fetching resource: ${e.message}")
477
+ if (BuildConfig.DEBUG) {
478
+ Log.e(FLOW_TAG, "ASSET network-fetch error url=$url error=${e.message}", e)
479
+ }
384
480
  if (BuildConfig.DEBUG) {
385
481
  e.printStackTrace()
386
482
  }
387
- return@withContext null
483
+ return null
388
484
  } finally {
389
485
  connection?.disconnect()
390
486
  }
@@ -409,6 +505,46 @@ class WebCacheManager(private val context: Context, private val properties: java
409
505
  )
410
506
  }
411
507
 
508
+ private fun mimeTypeFromContentType(contentType: String): String {
509
+ return contentType.substringBefore(";").trim().ifEmpty { "application/octet-stream" }
510
+ }
511
+
512
+ private fun charsetFromContentType(contentType: String): String {
513
+ val charsetPart = contentType
514
+ .split(";")
515
+ .map { it.trim() }
516
+ .firstOrNull { it.startsWith("charset=", ignoreCase = true) }
517
+ return charsetPart
518
+ ?.substringAfter("=")
519
+ ?.trim()
520
+ ?.trim('"')
521
+ ?.ifEmpty { null }
522
+ ?: "utf-8"
523
+ }
524
+
525
+ private fun shouldReplayHeader(header: String): Boolean {
526
+ return !header.equals("Content-Encoding", ignoreCase = true) &&
527
+ !header.equals("Transfer-Encoding", ignoreCase = true) &&
528
+ !header.equals("Content-Length", ignoreCase = true) &&
529
+ !header.equals("Connection", ignoreCase = true)
530
+ }
531
+
532
+ private fun createEntryFromCache(
533
+ cacheFile: File,
534
+ metadata: CacheMetadata,
535
+ timestamp: Long
536
+ ): CacheEntry {
537
+ return CacheEntry(
538
+ data = cacheFile.readBytes(),
539
+ mimeType = metadata.mimeType,
540
+ encoding = metadata.encoding,
541
+ responseHeaders = metadata.responseHeaders,
542
+ timestamp = timestamp,
543
+ eTag = metadata.eTag,
544
+ lastModified = metadata.lastModified
545
+ )
546
+ }
547
+
412
548
  private fun generateCacheKey(url: String): String {
413
549
  try {
414
550
  val md = MessageDigest.getInstance("SHA-256")
@@ -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
+ }