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
@@ -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
@@ -31,10 +39,12 @@ class CustomWebView(
31
39
  ) : CoroutineScope {
32
40
 
33
41
  private val TAG = "WebViewDebug"
42
+ private val FLOW_TAG = "CatalystOfflineFlow"
34
43
  private val job = SupervisorJob()
35
44
  override val coroutineContext = Dispatchers.Main + job
36
45
 
37
46
  private var cacheManager: WebCacheManager
47
+ private var offlineCacheService: OfflineCacheService
38
48
  private val metricsMonitor = MetricsMonitor.getInstance(context)
39
49
  private var buildType: String = "debug" // Default to debug
40
50
  private var cachePatterns: List<String> = emptyList()
@@ -43,13 +53,19 @@ class CustomWebView(
43
53
  private var isInitialApiCalled: Boolean = false // Flag to track initial page load
44
54
  private var isInitialPageLoaded: Boolean = false
45
55
  private var buildOptimisation: Boolean = false // Added property for build optimization
56
+ private var profilerEnabled: Boolean = false
57
+ private val profilerNavigationState = ProfilerNavigationState()
46
58
  private lateinit var assetLoader: WebViewAssetLoader
47
59
  private var allowedUrls: List<String> = emptyList()
48
60
  private var accessControlEnabled: Boolean = false
49
61
  private val offlineAssetPath = "offline/offline.html"
50
62
  private val offlineAssetUrl = "file:///android_asset/$offlineAssetPath"
63
+ private var scrollSessionOpen = false
64
+ private lateinit var scrollGestureDetector: GestureDetector
51
65
  private var offlinePageVisible = false
52
66
  private var lastTargetUrl: String? = null
67
+ private var activeOfflineRouteOrigin: String? = null
68
+ private var visibleOfflineSnapshotUrl: String? = null
53
69
  private var defaultRequestHeaders: Map<String, String> = emptyMap()
54
70
  var onPageStarted: (() -> Unit)? = null
55
71
 
@@ -57,12 +73,18 @@ class CustomWebView(
57
73
  private var assetLoadAttempts = 0
58
74
  private var assetLoadFailures = 0
59
75
 
60
- // Track ongoing cache fetches to prevent duplicates
61
- private val ongoingCacheFetches = mutableSetOf<String>()
62
-
63
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
+ }
64
84
  setupFromProperties()
65
85
  cacheManager = WebCacheManager(context, properties)
86
+ if (profilerEnabled) metricsMonitor.attachWebView(webView)
87
+ offlineCacheService = OfflineCacheService(context)
66
88
  setupWebView()
67
89
  }
68
90
 
@@ -88,6 +110,7 @@ class CustomWebView(
88
110
 
89
111
  // Parse buildOptimisation property
90
112
  buildOptimisation = properties.getProperty("buildOptimisation", "false").toBoolean()
113
+ profilerEnabled = PerfEventBuffer.isEnabled()
91
114
 
92
115
  // Load allowed URLs from properties
93
116
  allowedUrls = properties.getProperty("accessControl.allowedUrls", "")
@@ -97,7 +120,7 @@ class CustomWebView(
97
120
 
98
121
  // Access control toggle
99
122
  accessControlEnabled = properties
100
- .getProperty("accessControl.enabled", "true")
123
+ .getProperty("accessControl.enabled", "false")
101
124
  .equals("true", ignoreCase = true)
102
125
 
103
126
  // Initialize URLWhitelistManager with access control configuration
@@ -137,6 +160,15 @@ class CustomWebView(
137
160
 
138
161
  private fun loadUrlInternal(url: String) {
139
162
  val isNetworkUrl = url.startsWith("http://") || url.startsWith("https://")
163
+ val isOnline = NetworkUtils.getCurrentStatus(context).isOnline
164
+
165
+ if (BuildConfig.DEBUG) {
166
+ Log.d(FLOW_TAG, "WEBVIEW load-url start online=$isOnline url=$url headers=${defaultRequestHeaders.keys.sorted()}")
167
+ }
168
+
169
+ if (isNetworkUrl && isOnline) {
170
+ offlineCacheService.refreshManifestAsync(url, defaultRequestHeaders)
171
+ }
140
172
 
141
173
  if (isNetworkUrl && defaultRequestHeaders.isNotEmpty()) {
142
174
  if (BuildConfig.DEBUG) {
@@ -146,12 +178,31 @@ class CustomWebView(
146
178
  } else {
147
179
  webView.loadUrl(url)
148
180
  }
181
+ if (BuildConfig.DEBUG) {
182
+ Log.d(FLOW_TAG, "WEBVIEW load-url dispatched url=$url")
183
+ }
184
+ }
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)
149
198
  }
150
199
 
151
200
  fun loadUrl(url: String) {
152
201
  lastTargetUrl = url
153
202
  offlinePageVisible = false
154
- loadUrlInternal(url)
203
+ visibleOfflineSnapshotUrl = null
204
+ activeOfflineRouteOrigin = null
205
+ loadTopLevelUrl(url)
155
206
  }
156
207
 
157
208
  fun updateLastTargetUrl(url: String) {
@@ -167,7 +218,9 @@ class CustomWebView(
167
218
  try {
168
219
  context.assets.open(offlineAssetPath).close()
169
220
  offlinePageVisible = true
170
- loadUrlInternal(offlineAssetUrl)
221
+ visibleOfflineSnapshotUrl = null
222
+ activeOfflineRouteOrigin = null
223
+ loadTopLevelUrl(offlineAssetUrl)
171
224
  if (BuildConfig.DEBUG) {
172
225
  Log.d(TAG, "📴 Showing offline page from assets: $offlineAssetPath")
173
226
  }
@@ -192,6 +245,32 @@ class CustomWebView(
192
245
 
193
246
  fun clearAllCache() {
194
247
  cacheManager.clearAll()
248
+ offlineCacheService.clearAll()
249
+ activeOfflineRouteOrigin = null
250
+ visibleOfflineSnapshotUrl = null
251
+ }
252
+
253
+ fun showOfflineRouteOrOfflinePage(url: String) {
254
+ val snapshotUrl = normalizeUrlWithoutFragment(url)
255
+ if (visibleOfflineSnapshotUrl == snapshotUrl) {
256
+ if (BuildConfig.DEBUG) {
257
+ Log.d(TAG, "📴 Cached offline route snapshot already visible: $url")
258
+ }
259
+ return
260
+ }
261
+
262
+ if (offlineCacheService.hasRouteSnapshot(url)) {
263
+ offlinePageVisible = false
264
+ visibleOfflineSnapshotUrl = snapshotUrl
265
+ updateActiveOfflineRoute(url, true)
266
+ loadTopLevelUrl(url)
267
+ if (BuildConfig.DEBUG) {
268
+ Log.d(TAG, "📴 Loading cached offline route through request interceptor: $url")
269
+ }
270
+ return
271
+ }
272
+
273
+ showOfflinePage()
195
274
  }
196
275
 
197
276
  fun canGoBack(): Boolean = webView.canGoBack()
@@ -260,6 +339,7 @@ class CustomWebView(
260
339
 
261
340
  fun destroy() {
262
341
  job.cancel()
342
+ metricsMonitor.detachWebView()
263
343
  webView.destroy()
264
344
  }
265
345
 
@@ -291,6 +371,13 @@ class CustomWebView(
291
371
  if (BuildConfig.DEBUG) {
292
372
  Log.d(TAG, "🚀 Hardware acceleration enabled - Thread: ${Thread.currentThread().name}")
293
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
+ })
294
381
  }
295
382
  }
296
383
 
@@ -301,28 +388,122 @@ class CustomWebView(
301
388
  if (BuildConfig.DEBUG) {
302
389
  Log.d(TAG, "⚫ Hardware acceleration disabled - Thread: ${Thread.currentThread().name}")
303
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
+ })
304
398
  }
305
399
  }
306
400
 
307
- private fun shouldCacheUrl(url: String): Boolean {
401
+ private fun shouldCacheRequest(request: WebResourceRequest, url: String): Boolean {
308
402
  if (BuildConfig.DEBUG) {
309
403
  Log.d(TAG, "🔍 CACHE_CHECK: Checking if URL should be cached: $url")
310
404
  Log.d(TAG, "🔍 CACHE_CHECK: Cache patterns: $cachePatterns")
311
405
  }
312
406
 
313
- val shouldCache = matchesCachePattern(url, cachePatterns)
407
+ val matchesPattern = matchesCachePattern(url, cachePatterns)
408
+ val offlineRouteSubresource = shouldCacheOfflineRouteSubresource(request, url)
409
+ val shouldCache = matchesPattern || offlineRouteSubresource
314
410
 
315
411
  if (BuildConfig.DEBUG) {
316
412
  if (shouldCache) {
317
- Log.d(TAG, "✅ CACHE_CHECK: URL WILL be cached: $url")
413
+ Log.d(TAG, "✅ CACHE_CHECK: URL WILL be cached: $url (pattern=$matchesPattern, offlineRouteSubresource=$offlineRouteSubresource)")
414
+ Log.d(FLOW_TAG, "CACHE eligible=true pattern=$matchesPattern offlineRouteSubresource=$offlineRouteSubresource mainFrame=${request.isForMainFrame} method=${request.method} dest=${requestHeader(request, "Sec-Fetch-Dest") ?: "none"} accept=${requestHeader(request, "Accept") ?: "none"} activeOrigin=${activeOfflineRouteOrigin ?: "none"} url=$url")
318
415
  } else {
319
416
  Log.d(TAG, "❌ CACHE_CHECK: URL will NOT be cached (no pattern match): $url")
417
+ Log.d(FLOW_TAG, "CACHE eligible=false pattern=$matchesPattern offlineRouteSubresource=$offlineRouteSubresource mainFrame=${request.isForMainFrame} method=${request.method} dest=${requestHeader(request, "Sec-Fetch-Dest") ?: "none"} accept=${requestHeader(request, "Accept") ?: "none"} activeOrigin=${activeOfflineRouteOrigin ?: "none"} url=$url")
320
418
  }
321
419
  }
322
420
 
323
421
  return shouldCache
324
422
  }
325
423
 
424
+ private fun shouldCacheOfflineRouteSubresource(request: WebResourceRequest, url: String): Boolean {
425
+ if (request.isForMainFrame || request.method != "GET") return false
426
+ if (!isHttpUrl(url) || isApiCall(url) || isInternalOfflineRuntimeUrl(url)) return false
427
+
428
+ val activeOrigin = activeOfflineRouteOrigin ?: return false
429
+ if (originForUrl(url) != activeOrigin) return false
430
+
431
+ val destination = requestHeader(request, "Sec-Fetch-Dest")?.lowercase()
432
+ if (destination == "document" || destination == "empty") return false
433
+ if (destination in setOf("script", "style", "image", "font", "audio", "video", "track", "manifest")) {
434
+ return true
435
+ }
436
+
437
+ val accept = requestHeader(request, "Accept")?.lowercase().orEmpty()
438
+ if (accept.contains("text/html") ||
439
+ accept.contains("application/json") ||
440
+ accept.contains("text/event-stream")
441
+ ) {
442
+ return false
443
+ }
444
+ if (accept.contains("text/css") ||
445
+ accept.contains("javascript") ||
446
+ accept.contains("image/") ||
447
+ accept.contains("font/") ||
448
+ accept.contains("application/font") ||
449
+ accept.contains("application/wasm")
450
+ ) {
451
+ return true
452
+ }
453
+
454
+ return offlineCacheService.shouldCacheAssetUrl(url)
455
+ }
456
+
457
+ private fun requestHeader(request: WebResourceRequest, name: String): String? {
458
+ return request.requestHeaders.entries.firstOrNull {
459
+ it.key.equals(name, ignoreCase = true)
460
+ }?.value
461
+ }
462
+
463
+ private fun updateActiveOfflineRoute(url: String, active: Boolean) {
464
+ activeOfflineRouteOrigin = if (active) originForUrl(url) else null
465
+ if (BuildConfig.DEBUG) {
466
+ Log.d(FLOW_TAG, "ROUTE active=$active activeOrigin=${activeOfflineRouteOrigin ?: "none"} url=$url")
467
+ }
468
+ }
469
+
470
+ private fun isVisibleOfflineSnapshot(url: String?): Boolean {
471
+ if (url == null) return false
472
+ return visibleOfflineSnapshotUrl == normalizeUrlWithoutFragment(url)
473
+ }
474
+
475
+ private fun normalizeUrlWithoutFragment(url: String): String {
476
+ return try {
477
+ Uri.parse(url).buildUpon().fragment(null).build().toString()
478
+ } catch (_: Exception) {
479
+ url
480
+ }
481
+ }
482
+
483
+ private fun originForUrl(url: String): String? {
484
+ return try {
485
+ val uri = Uri.parse(url)
486
+ val scheme = uri.scheme?.lowercase() ?: return null
487
+ val authority = uri.authority ?: return null
488
+ if (scheme != "http" && scheme != "https") return null
489
+ "$scheme://$authority"
490
+ } catch (_: Exception) {
491
+ null
492
+ }
493
+ }
494
+
495
+ private fun isHttpUrl(url: String): Boolean {
496
+ val scheme = Uri.parse(url).scheme?.lowercase()
497
+ return scheme == "http" || scheme == "https"
498
+ }
499
+
500
+ private fun isInternalOfflineRuntimeUrl(url: String): Boolean {
501
+ val path = Uri.parse(url).path ?: return false
502
+ return path == "/catalyst-offline-manifest.json" ||
503
+ path == "/catalyst-sw.js" ||
504
+ path == "/offline.html"
505
+ }
506
+
326
507
  /**
327
508
  * Handle special URL schemes (tel:, mailto:, sms:)
328
509
  */
@@ -341,7 +522,7 @@ class CustomWebView(
341
522
  }
342
523
  offlinePageVisible = false
343
524
  lastTargetUrl = target
344
- loadUrlInternal(target)
525
+ loadTopLevelUrl(target)
345
526
  } else if (BuildConfig.DEBUG) {
346
527
  Log.w(TAG, "🔄 Retry requested but no target URL is known")
347
528
  }
@@ -502,11 +683,13 @@ class CustomWebView(
502
683
  serviceWorkerController.setServiceWorkerClient(object : ServiceWorkerClientCompat() {
503
684
  override fun shouldInterceptRequest(request: WebResourceRequest): WebResourceResponse? {
504
685
  val url = request.url.toString()
686
+ val isOnline = NetworkUtils.getCurrentStatus(context).isOnline
505
687
 
506
688
  if (BuildConfig.DEBUG) {
507
689
  Log.d(TAG, "🔧 SERVICE_WORKER: Intercepting request: $url")
508
690
  Log.d(TAG, "🔧 SERVICE_WORKER: Method: ${request.method}")
509
691
  Log.d(TAG, "🔧 SERVICE_WORKER: Headers: ${request.requestHeaders}")
692
+ Log.d(FLOW_TAG, "SW intercept method=${request.method} online=$isOnline mainFrame=${request.isForMainFrame} url=$url")
510
693
  }
511
694
 
512
695
  // Check if URL is allowed
@@ -526,7 +709,7 @@ class CustomWebView(
526
709
  }
527
710
 
528
711
  // Check if this should be cached
529
- if (request.method == "GET" && shouldCacheUrl(url)) {
712
+ if (request.method == "GET" && shouldCacheRequest(request, url)) {
530
713
  if (BuildConfig.DEBUG) {
531
714
  Log.d(TAG, "🎯 SERVICE_WORKER: Using cache system for: $url")
532
715
  }
@@ -540,49 +723,22 @@ class CustomWebView(
540
723
  }
541
724
 
542
725
  // Get cached response synchronously (non-blocking for ServiceWorker)
543
- val response = cacheManager.getCachedResponseSync(url, headers)
726
+ val response = if (NetworkUtils.getCurrentStatus(context).isOnline) {
727
+ cacheManager.getCachedResponseOrFetchSync(url, headers)
728
+ } else {
729
+ cacheManager.getCachedResponseSync(url, headers)
730
+ }
544
731
  if (response != null) {
545
732
  if (BuildConfig.DEBUG) {
546
733
  Log.d(TAG, "✅ SERVICE_WORKER: Served from cache: $url")
734
+ Log.d(FLOW_TAG, "SW response source=cache-or-fetch mime=${response.mimeType ?: "unknown"} encoding=${response.encoding ?: "unknown"} url=$url")
547
735
  }
548
736
  withCorsHeaders(response)
549
737
  } else {
550
738
  if (BuildConfig.DEBUG) {
551
739
  Log.d(TAG, "❌ SERVICE_WORKER: Cache miss: $url")
740
+ Log.d(FLOW_TAG, "SW response source=network-fallback reason=cache-null url=$url")
552
741
  }
553
-
554
- // Trigger async cache population for next request (with deduplication)
555
- val shouldFetch = synchronized(ongoingCacheFetches) {
556
- ongoingCacheFetches.add(url)
557
- }
558
-
559
- if (shouldFetch) {
560
- if (BuildConfig.DEBUG) {
561
- Log.d(TAG, "🔄 SERVICE_WORKER: Triggering async cache fetch for: $url")
562
- }
563
- launch(Dispatchers.IO) {
564
- try {
565
- cacheManager.getCachedResponse(url, headers)
566
- if (BuildConfig.DEBUG) {
567
- Log.d(TAG, "✅ SERVICE_WORKER: Async cache fetch completed: $url")
568
- }
569
- } catch (e: Exception) {
570
- if (BuildConfig.DEBUG) {
571
- Log.e(TAG, "❌ SERVICE_WORKER: Async cache fetch failed: $url - ${e.message}")
572
- }
573
- } finally {
574
- synchronized(ongoingCacheFetches) {
575
- ongoingCacheFetches.remove(url)
576
- }
577
- }
578
- }
579
- } else {
580
- if (BuildConfig.DEBUG) {
581
- Log.d(TAG, "⏭️ SERVICE_WORKER: Cache fetch already in progress for: $url")
582
- }
583
- }
584
-
585
- // Let this request fall back to network
586
742
  null
587
743
  }
588
744
  } catch (e: Exception) {
@@ -594,7 +750,7 @@ class CustomWebView(
594
750
  } else {
595
751
  if (BuildConfig.DEBUG) {
596
752
  Log.d(TAG, "⏭️ SERVICE_WORKER: Skipping cache for: $url")
597
- Log.d(TAG, "⏭️ SERVICE_WORKER: Method: ${request.method}, shouldCache: ${shouldCacheUrl(url)}")
753
+ Log.d(TAG, "⏭️ SERVICE_WORKER: Method: ${request.method}, shouldCache: ${shouldCacheRequest(request, url)}")
598
754
  }
599
755
  }
600
756
 
@@ -612,6 +768,13 @@ class CustomWebView(
612
768
 
613
769
  @SuppressLint("SetJavaScriptEnabled")
614
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
+ }
615
778
  if (BuildConfig.DEBUG) {
616
779
  Log.d(TAG, "🌐 Setting up WebView on thread: ${Thread.currentThread().name}")
617
780
  }
@@ -687,6 +850,7 @@ class CustomWebView(
687
850
  request: WebResourceRequest
688
851
  ): WebResourceResponse? {
689
852
  val url = request.url.toString()
853
+ val isOnline = NetworkUtils.getCurrentStatus(context).isOnline
690
854
 
691
855
  // Track all network requests
692
856
  metricsMonitor.recordNetworkRequest(url)
@@ -697,6 +861,7 @@ class CustomWebView(
697
861
  Log.d(TAG, "🔄 INTERCEPT: Thread: ${Thread.currentThread().name}")
698
862
  Log.d(TAG, "🔄 INTERCEPT: File extension: ${url.substringAfterLast('.', "none")}")
699
863
  Log.d(TAG, "🔄 INTERCEPT: isInitialPageLoaded: $isInitialPageLoaded")
864
+ Log.d(FLOW_TAG, "WV intercept method=${request.method} online=$isOnline mainFrame=${request.isForMainFrame} initialLoaded=$isInitialPageLoaded dest=${requestHeader(request, "Sec-Fetch-Dest") ?: "none"} accept=${requestHeader(request, "Accept") ?: "none"} url=$url")
700
865
  }
701
866
 
702
867
  if (!URLWhitelistManager.isUrlAllowed(url)) {
@@ -713,6 +878,51 @@ class CustomWebView(
713
878
  }
714
879
  }
715
880
 
881
+ val isMainFrameDocument = request.isForMainFrame &&
882
+ request.method == "GET" &&
883
+ (request.url.scheme == "http" || request.url.scheme == "https") &&
884
+ !isApiCall(url)
885
+
886
+ if (isMainFrameDocument) {
887
+ val headers = defaultRequestHeaders.toMutableMap().apply {
888
+ putAll(request.requestHeaders)
889
+ }
890
+ if (BuildConfig.DEBUG) {
891
+ Log.d(FLOW_TAG, "DOCUMENT intercept online=$isOnline url=$url headers=${headers.keys.sorted()}")
892
+ }
893
+
894
+ if (isOnline) {
895
+ offlineCacheService.refreshManifestIfMissing(url, headers)
896
+ val isOfflineRoute = offlineCacheService.isOfflineRouteUrl(url)
897
+ updateActiveOfflineRoute(url, isOfflineRoute)
898
+ visibleOfflineSnapshotUrl = null
899
+ if (BuildConfig.DEBUG) {
900
+ Log.d(FLOW_TAG, "DOCUMENT online routeEligible=$isOfflineRoute action=allow-network-and-background-snapshot url=$url")
901
+ }
902
+ offlineCacheService.storeRouteSnapshotAsync(url, headers)
903
+ } else {
904
+ val snapshotResponse = offlineCacheService.getRouteSnapshotResponse(url)
905
+ if (snapshotResponse != null) {
906
+ updateActiveOfflineRoute(url, true)
907
+ visibleOfflineSnapshotUrl = normalizeUrlWithoutFragment(url)
908
+ if (BuildConfig.DEBUG) {
909
+ Log.d(TAG, "📴 INTERCEPT: Serving offline route snapshot: $url")
910
+ Log.d(FLOW_TAG, "DOCUMENT offline response=route-snapshot mime=${snapshotResponse.mimeType ?: "unknown"} url=$url")
911
+ }
912
+ return withCorsHeaders(snapshotResponse)
913
+ }
914
+ updateActiveOfflineRoute(url, false)
915
+ if (BuildConfig.DEBUG) {
916
+ Log.d(FLOW_TAG, "DOCUMENT offline response=network-fallback reason=snapshot-miss url=$url")
917
+ }
918
+ }
919
+ } else if (request.isForMainFrame &&
920
+ request.method == "GET" &&
921
+ (request.url.scheme == "http" || request.url.scheme == "https")
922
+ ) {
923
+ updateActiveOfflineRoute(url, false)
924
+ }
925
+
716
926
  // TODO: Add HTML file workflow - index.html not available yet
717
927
  // Handle the initial route request - intercept first request regardless of host
718
928
  // if (!isInitialApiCalled && request.method == "GET") {
@@ -747,12 +957,12 @@ class CustomWebView(
747
957
  if (BuildConfig.DEBUG) {
748
958
  Log.d(TAG, "🔍 STEP_4_CHECK: Initial page not loaded yet, checking for static resources")
749
959
  Log.d(TAG, "🔍 STEP_4_CHECK: isStaticResourceRequest($url) = ${isStaticResourceRequest(url)}")
750
- Log.d(TAG, "🔍 STEP_4_CHECK: shouldCacheUrl($url) = ${shouldCacheUrl(url)}")
960
+ Log.d(TAG, "🔍 STEP_4_CHECK: shouldCacheRequest($url) = ${shouldCacheRequest(request, url)}")
751
961
  }
752
962
 
753
963
  // IMPORTANT: Skip asset loading if the URL matches a cache pattern
754
964
  // This allows cached resources to be served from the cache system instead
755
- if (request.method == "GET" && isStaticResourceRequest(url) && !shouldCacheUrl(url)) {
965
+ if (request.method == "GET" && isStaticResourceRequest(url) && !shouldCacheRequest(request, url)) {
756
966
  val assetPath = extractAssetPath(url)
757
967
  assetLoadAttempts++
758
968
 
@@ -790,7 +1000,7 @@ class CustomWebView(
790
1000
  }
791
1001
  // Unexpected error - fall through to cache check below
792
1002
  }
793
- } else if (shouldCacheUrl(url)) {
1003
+ } else if (shouldCacheRequest(request, url)) {
794
1004
  if (BuildConfig.DEBUG) {
795
1005
  Log.d(TAG, "⏭️ STEP_4_SKIP: Skipping asset loading for cached URL: $url")
796
1006
  }
@@ -805,10 +1015,10 @@ class CustomWebView(
805
1015
  if (BuildConfig.DEBUG) {
806
1016
  Log.d(TAG, "🔍 STEP_5_CACHE_CHECK: Evaluating cache conditions")
807
1017
  Log.d(TAG, "🔍 STEP_5_CACHE_CHECK: Request method: ${request.method} (need GET)")
808
- Log.d(TAG, "🔍 STEP_5_CACHE_CHECK: shouldCacheUrl($url) = ${shouldCacheUrl(url)}")
1018
+ Log.d(TAG, "🔍 STEP_5_CACHE_CHECK: shouldCacheRequest($url) = ${shouldCacheRequest(request, url)}")
809
1019
  }
810
1020
 
811
- if (request.method == "GET" && shouldCacheUrl(url)) {
1021
+ if (request.method == "GET" && shouldCacheRequest(request, url)) {
812
1022
  // Track that this request was evaluated for caching
813
1023
  metricsMonitor.recordCacheEvaluation(url)
814
1024
  if (BuildConfig.DEBUG) {
@@ -831,45 +1041,27 @@ class CustomWebView(
831
1041
  }
832
1042
 
833
1043
  // Use synchronous cache check to avoid blocking
834
- val response = cacheManager.getCachedResponseSync(url, headers)
1044
+ val response = if (NetworkUtils.getCurrentStatus(context).isOnline) {
1045
+ cacheManager.getCachedResponseOrFetchSync(url, headers)
1046
+ } else {
1047
+ cacheManager.getCachedResponseSync(url, headers)
1048
+ }
835
1049
 
836
1050
  if (response != null) {
837
1051
  if (BuildConfig.DEBUG) {
838
1052
  val duration = System.currentTimeMillis() - startTime
839
1053
  Log.d(TAG, "✅ WEBVIEW_CLIENT: Served from cache in ${duration}ms: $url")
1054
+ Log.d(FLOW_TAG, "ASSET response source=cache-or-fetch online=${NetworkUtils.getCurrentStatus(context).isOnline} durationMs=$duration mime=${response.mimeType ?: "unknown"} encoding=${response.encoding ?: "unknown"} url=$url")
840
1055
  }
841
1056
  metricsMonitor.recordCacheHit(url)
842
1057
  return withCorsHeaders(response)
843
1058
  } else {
844
1059
  if (BuildConfig.DEBUG) {
845
- Log.d(TAG, "❌ WEBVIEW_CLIENT: Cache miss, triggering async fetch: $url")
1060
+ Log.d(TAG, "❌ WEBVIEW_CLIENT: Cache miss: $url")
1061
+ Log.d(FLOW_TAG, "ASSET response source=network-fallback reason=cache-null online=${NetworkUtils.getCurrentStatus(context).isOnline} url=$url")
846
1062
  }
847
1063
  metricsMonitor.recordCacheMiss(url)
848
1064
 
849
- // Trigger async cache population for next time
850
- val shouldFetch = synchronized(ongoingCacheFetches) {
851
- ongoingCacheFetches.add(url)
852
- }
853
-
854
- if (shouldFetch) {
855
- launch(Dispatchers.IO) {
856
- try {
857
- cacheManager.getCachedResponse(url, headers)
858
- if (BuildConfig.DEBUG) {
859
- Log.d(TAG, "✅ WEBVIEW_CLIENT: Async cache fetch completed: $url")
860
- }
861
- } catch (e: Exception) {
862
- if (BuildConfig.DEBUG) {
863
- Log.e(TAG, "❌ WEBVIEW_CLIENT: Async cache fetch failed: $url - ${e.message}")
864
- }
865
- } finally {
866
- synchronized(ongoingCacheFetches) {
867
- ongoingCacheFetches.remove(url)
868
- }
869
- }
870
- }
871
- }
872
-
873
1065
  // Return null to let network handle this request
874
1066
  return null
875
1067
  }
@@ -897,7 +1089,7 @@ class CustomWebView(
897
1089
  Log.d(TAG, "❌ STEP_5_FAIL: URL doesn't match cache criteria, skipping cache")
898
1090
  Log.d(TAG, "❌ STEP_5_FAIL: URL: $url")
899
1091
  Log.d(TAG, "❌ STEP_5_FAIL: Request method: ${request.method} (need GET)")
900
- Log.d(TAG, "❌ STEP_5_FAIL: shouldCacheUrl result: ${shouldCacheUrl(url)}")
1092
+ Log.d(TAG, "❌ STEP_5_FAIL: shouldCacheRequest result: ${shouldCacheRequest(request, url)}")
901
1093
  Log.d(TAG, "❌ STEP_5_FAIL: This request will go through normal network loading")
902
1094
  }
903
1095
  }
@@ -909,6 +1101,9 @@ class CustomWebView(
909
1101
  super.onPageStarted(view, url, favicon)
910
1102
  if (url != null && url != offlineAssetUrl) {
911
1103
  offlinePageVisible = false
1104
+ if (!isVisibleOfflineSnapshot(url)) {
1105
+ visibleOfflineSnapshotUrl = null
1106
+ }
912
1107
  onPageStarted?.invoke()
913
1108
  }
914
1109
  progressBar.visibility = View.VISIBLE
@@ -917,6 +1112,28 @@ class CustomWebView(
917
1112
  url?.let { metricsMonitor.trackPageLoadStart(it) }
918
1113
  if (BuildConfig.DEBUG) {
919
1114
  Log.d(TAG, "⏳ Page load started for: $url - Hardware Acceleration: $isHardwareAccelerationEnabled")
1115
+ Log.d(FLOW_TAG, "PAGE started url=$url online=${NetworkUtils.getCurrentStatus(context).isOnline} offlinePageVisible=$offlinePageVisible visibleSnapshot=${visibleOfflineSnapshotUrl ?: "none"} activeOrigin=${activeOfflineRouteOrigin ?: "none"}")
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
+ })
920
1137
  }
921
1138
  }
922
1139
 
@@ -924,12 +1141,31 @@ class CustomWebView(
924
1141
  progressBar.visibility = View.GONE
925
1142
  if(!isInitialPageLoaded){
926
1143
  isInitialPageLoaded = true
1144
+ metricsMonitor.markAppStartComplete()
927
1145
  }
928
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
+ }
929
1164
  if (BuildConfig.DEBUG) {
930
1165
  val startTime = view?.tag as? Long ?: return
931
1166
  val loadTime = System.currentTimeMillis() - startTime
932
1167
  Log.d(TAG, "✅ Page load finished for: $url - Load time: ${loadTime}ms - Hardware Acceleration: $isHardwareAccelerationEnabled")
1168
+ Log.d(FLOW_TAG, "PAGE finished url=$url loadTimeMs=$loadTime online=${NetworkUtils.getCurrentStatus(context).isOnline} offlinePageVisible=$offlinePageVisible visibleSnapshot=${visibleOfflineSnapshotUrl ?: "none"} activeOrigin=${activeOfflineRouteOrigin ?: "none"}")
933
1169
  Log.d(TAG, "📊 Asset loading stats: Attempted: $assetLoadAttempts, Failed: $assetLoadFailures (${String.format("%.1f", assetLoadFailures * 100.0 / assetLoadAttempts.coerceAtLeast(1))}%)")
934
1170
  Log.d(TAG, "📊 ${metricsMonitor.getCacheStats()}")
935
1171
 
@@ -952,7 +1188,39 @@ class CustomWebView(
952
1188
  override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
953
1189
  if (error != null) {
954
1190
  Log.e(TAG, "❌ Error loading ${request?.url}: ${error.errorCode} ${error.description}")
1191
+ if (BuildConfig.DEBUG) {
1192
+ Log.e(FLOW_TAG, "PAGE resource-error mainFrame=${request?.isForMainFrame} method=${request?.method} code=${error.errorCode} description=${error.description} url=${request?.url}")
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
+ }
1203
+ }
1204
+
1205
+ val failedUrl = request?.url?.toString()
1206
+ if (request?.isForMainFrame == true && isVisibleOfflineSnapshot(failedUrl)) {
1207
+ if (BuildConfig.DEBUG) {
1208
+ Log.d(TAG, "📴 Ignoring repeated error for visible offline route snapshot: $failedUrl")
1209
+ }
1210
+ return
1211
+ }
1212
+
1213
+ if (request?.isForMainFrame == true &&
1214
+ failedUrl != null &&
1215
+ failedUrl != offlineAssetUrl &&
1216
+ !NetworkUtils.getCurrentStatus(context).isOnline
1217
+ ) {
1218
+ view?.post {
1219
+ showOfflineRouteOrOfflinePage(failedUrl)
1220
+ }
1221
+ return
955
1222
  }
1223
+
956
1224
  super.onReceivedError(view, request, error)
957
1225
  }
958
1226
 
@@ -1004,6 +1272,7 @@ class CustomWebView(
1004
1272
  override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
1005
1273
  if (BuildConfig.DEBUG) {
1006
1274
  Log.d(TAG, "Console: ${consoleMessage?.message()} -- From line ${consoleMessage?.lineNumber()} of ${consoleMessage?.sourceId()}")
1275
+ Log.d(FLOW_TAG, "CONSOLE level=${consoleMessage?.messageLevel()} line=${consoleMessage?.lineNumber()} source=${consoleMessage?.sourceId()} message=${consoleMessage?.message()}")
1007
1276
  }
1008
1277
  return true
1009
1278
  }
@@ -1040,5 +1309,39 @@ class CustomWebView(
1040
1309
 
1041
1310
  // Only enable WebView debugging in debug builds
1042
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
+ }
1043
1346
  }
1044
1347
  }