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

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 (29) hide show
  1. package/dist/native/androidProject/app/build.gradle.kts +25 -8
  2. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +239 -75
  3. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +3 -2
  4. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeBridge.kt +1 -1
  5. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/OfflineCacheService.kt +374 -0
  6. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/WebCacheManager.kt +136 -26
  7. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/CustomWebviewTest.kt +7 -2
  8. package/dist/native/buildAndroid/assets.js +2 -2
  9. package/dist/native/iosnativeWebView/Sources/Core/Utils/CacheManager.swift +39 -4
  10. package/dist/native/iosnativeWebView/Sources/Core/Utils/OfflineCacheService.swift +683 -0
  11. package/dist/native/iosnativeWebView/Sources/Core/Utils/OfflineURLSchemeHandler.swift +94 -0
  12. package/dist/native/iosnativeWebView/Sources/Core/Utils/ResourceURLProtocol.swift +19 -6
  13. package/dist/native/iosnativeWebView/Sources/Core/Utils/SafeAreaUtils.swift +1 -0
  14. package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeCommandHandler.swift +1 -0
  15. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebView.swift +15 -1
  16. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +82 -2
  17. package/dist/native/renameAndroidProject.js +2 -2
  18. package/dist/offline/catalyst-sw.js +1 -0
  19. package/dist/offline/registerServiceWorker.js +1 -0
  20. package/dist/router/ClientRouter.js +1 -1
  21. package/dist/scripts/build.js +1 -1
  22. package/dist/scripts/devBuild.js +1 -1
  23. package/dist/scripts/generateOfflineManifest.js +1 -0
  24. package/dist/server/expressServer.js +2 -1
  25. package/dist/server/renderer/document/Body.js +1 -1
  26. package/dist/server/renderer/handler.js +3 -3
  27. package/dist/webpack/base.babel.js +1 -1
  28. package/mcp_v2/knowledge-base.json +13 -0
  29. package/package.json +1 -1
@@ -10,7 +10,7 @@ buildscript {
10
10
  }
11
11
  }
12
12
 
13
- val configPath: String? by project.properties
13
+ val configPath = findProperty("configPath") as? String
14
14
  val keystorePassword: String? by project.properties // Changed from keyStorePassword to keystorePassword
15
15
  val keyAlias: String? by project.properties
16
16
  val keyPassword: String? by project.properties
@@ -29,15 +29,32 @@ fun isAllowBackupEnabled(): Boolean {
29
29
 
30
30
  fun isNotificationsEnabled(): Boolean {
31
31
  return try {
32
- if (configPath == null) return false
33
- val configFile = File(configPath!!)
34
- if (!configFile.exists()) return false
32
+ if (configPath != null) {
33
+ val configFile = File(configPath)
34
+ if (configFile.exists()) {
35
+ val json = JSONObject(configFile.readText())
36
+ if (json.has("WEBVIEW_CONFIG")) {
37
+ val webviewConfig = json.getJSONObject("WEBVIEW_CONFIG")
38
+ return webviewConfig.optJSONObject("notifications")?.optBoolean("enabled", false) ?: false
39
+ }
40
+ }
41
+ }
35
42
 
36
- val json = JSONObject(configFile.readText())
37
- if (!json.has("WEBVIEW_CONFIG")) return false
43
+ val generatedBuildProps = File("${project.projectDir}/catalyst-build.properties")
44
+ if (generatedBuildProps.exists()) {
45
+ val props = Properties()
46
+ props.load(generatedBuildProps.inputStream())
47
+ return props.getProperty("notifications.enabled", "false").trim().lowercase() == "true"
48
+ }
38
49
 
39
- val webviewConfig = json.getJSONObject("WEBVIEW_CONFIG")
40
- webviewConfig.optJSONObject("notifications")?.optBoolean("enabled", false) ?: false
50
+ val webviewProps = File("${project.projectDir}/src/main/assets/webview_config.properties")
51
+ if (webviewProps.exists()) {
52
+ val props = Properties()
53
+ props.load(webviewProps.inputStream())
54
+ return props.getProperty("notifications.enabled", "false").trim().lowercase() == "true"
55
+ }
56
+
57
+ false
41
58
  } catch (e: Exception) {
42
59
  false
43
60
  }
@@ -31,10 +31,12 @@ class CustomWebView(
31
31
  ) : CoroutineScope {
32
32
 
33
33
  private val TAG = "WebViewDebug"
34
+ private val FLOW_TAG = "CatalystOfflineFlow"
34
35
  private val job = SupervisorJob()
35
36
  override val coroutineContext = Dispatchers.Main + job
36
37
 
37
38
  private var cacheManager: WebCacheManager
39
+ private var offlineCacheService: OfflineCacheService
38
40
  private val metricsMonitor = MetricsMonitor.getInstance(context)
39
41
  private var buildType: String = "debug" // Default to debug
40
42
  private var cachePatterns: List<String> = emptyList()
@@ -50,6 +52,8 @@ class CustomWebView(
50
52
  private val offlineAssetUrl = "file:///android_asset/$offlineAssetPath"
51
53
  private var offlinePageVisible = false
52
54
  private var lastTargetUrl: String? = null
55
+ private var activeOfflineRouteOrigin: String? = null
56
+ private var visibleOfflineSnapshotUrl: String? = null
53
57
  private var defaultRequestHeaders: Map<String, String> = emptyMap()
54
58
  var onPageStarted: (() -> Unit)? = null
55
59
 
@@ -57,12 +61,10 @@ class CustomWebView(
57
61
  private var assetLoadAttempts = 0
58
62
  private var assetLoadFailures = 0
59
63
 
60
- // Track ongoing cache fetches to prevent duplicates
61
- private val ongoingCacheFetches = mutableSetOf<String>()
62
-
63
64
  init {
64
65
  setupFromProperties()
65
66
  cacheManager = WebCacheManager(context, properties)
67
+ offlineCacheService = OfflineCacheService(context)
66
68
  setupWebView()
67
69
  }
68
70
 
@@ -97,7 +99,7 @@ class CustomWebView(
97
99
 
98
100
  // Access control toggle
99
101
  accessControlEnabled = properties
100
- .getProperty("accessControl.enabled", "true")
102
+ .getProperty("accessControl.enabled", "false")
101
103
  .equals("true", ignoreCase = true)
102
104
 
103
105
  // Initialize URLWhitelistManager with access control configuration
@@ -137,6 +139,15 @@ class CustomWebView(
137
139
 
138
140
  private fun loadUrlInternal(url: String) {
139
141
  val isNetworkUrl = url.startsWith("http://") || url.startsWith("https://")
142
+ val isOnline = NetworkUtils.getCurrentStatus(context).isOnline
143
+
144
+ if (BuildConfig.DEBUG) {
145
+ Log.d(FLOW_TAG, "WEBVIEW load-url start online=$isOnline url=$url headers=${defaultRequestHeaders.keys.sorted()}")
146
+ }
147
+
148
+ if (isNetworkUrl && isOnline) {
149
+ offlineCacheService.refreshManifestAsync(url, defaultRequestHeaders)
150
+ }
140
151
 
141
152
  if (isNetworkUrl && defaultRequestHeaders.isNotEmpty()) {
142
153
  if (BuildConfig.DEBUG) {
@@ -146,11 +157,16 @@ class CustomWebView(
146
157
  } else {
147
158
  webView.loadUrl(url)
148
159
  }
160
+ if (BuildConfig.DEBUG) {
161
+ Log.d(FLOW_TAG, "WEBVIEW load-url dispatched url=$url")
162
+ }
149
163
  }
150
164
 
151
165
  fun loadUrl(url: String) {
152
166
  lastTargetUrl = url
153
167
  offlinePageVisible = false
168
+ visibleOfflineSnapshotUrl = null
169
+ activeOfflineRouteOrigin = null
154
170
  loadUrlInternal(url)
155
171
  }
156
172
 
@@ -167,6 +183,8 @@ class CustomWebView(
167
183
  try {
168
184
  context.assets.open(offlineAssetPath).close()
169
185
  offlinePageVisible = true
186
+ visibleOfflineSnapshotUrl = null
187
+ activeOfflineRouteOrigin = null
170
188
  loadUrlInternal(offlineAssetUrl)
171
189
  if (BuildConfig.DEBUG) {
172
190
  Log.d(TAG, "📴 Showing offline page from assets: $offlineAssetPath")
@@ -192,6 +210,32 @@ class CustomWebView(
192
210
 
193
211
  fun clearAllCache() {
194
212
  cacheManager.clearAll()
213
+ offlineCacheService.clearAll()
214
+ activeOfflineRouteOrigin = null
215
+ visibleOfflineSnapshotUrl = null
216
+ }
217
+
218
+ fun showOfflineRouteOrOfflinePage(url: String) {
219
+ val snapshotUrl = normalizeUrlWithoutFragment(url)
220
+ if (visibleOfflineSnapshotUrl == snapshotUrl) {
221
+ if (BuildConfig.DEBUG) {
222
+ Log.d(TAG, "📴 Cached offline route snapshot already visible: $url")
223
+ }
224
+ return
225
+ }
226
+
227
+ if (offlineCacheService.hasRouteSnapshot(url)) {
228
+ offlinePageVisible = false
229
+ visibleOfflineSnapshotUrl = snapshotUrl
230
+ updateActiveOfflineRoute(url, true)
231
+ loadUrlInternal(url)
232
+ if (BuildConfig.DEBUG) {
233
+ Log.d(TAG, "📴 Loading cached offline route through request interceptor: $url")
234
+ }
235
+ return
236
+ }
237
+
238
+ showOfflinePage()
195
239
  }
196
240
 
197
241
  fun canGoBack(): Boolean = webView.canGoBack()
@@ -304,25 +348,112 @@ class CustomWebView(
304
348
  }
305
349
  }
306
350
 
307
- private fun shouldCacheUrl(url: String): Boolean {
351
+ private fun shouldCacheRequest(request: WebResourceRequest, url: String): Boolean {
308
352
  if (BuildConfig.DEBUG) {
309
353
  Log.d(TAG, "🔍 CACHE_CHECK: Checking if URL should be cached: $url")
310
354
  Log.d(TAG, "🔍 CACHE_CHECK: Cache patterns: $cachePatterns")
311
355
  }
312
356
 
313
- val shouldCache = matchesCachePattern(url, cachePatterns)
357
+ val matchesPattern = matchesCachePattern(url, cachePatterns)
358
+ val offlineRouteSubresource = shouldCacheOfflineRouteSubresource(request, url)
359
+ val shouldCache = matchesPattern || offlineRouteSubresource
314
360
 
315
361
  if (BuildConfig.DEBUG) {
316
362
  if (shouldCache) {
317
- Log.d(TAG, "✅ CACHE_CHECK: URL WILL be cached: $url")
363
+ Log.d(TAG, "✅ CACHE_CHECK: URL WILL be cached: $url (pattern=$matchesPattern, offlineRouteSubresource=$offlineRouteSubresource)")
364
+ 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
365
  } else {
319
366
  Log.d(TAG, "❌ CACHE_CHECK: URL will NOT be cached (no pattern match): $url")
367
+ 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
368
  }
321
369
  }
322
370
 
323
371
  return shouldCache
324
372
  }
325
373
 
374
+ private fun shouldCacheOfflineRouteSubresource(request: WebResourceRequest, url: String): Boolean {
375
+ if (request.isForMainFrame || request.method != "GET") return false
376
+ if (!isHttpUrl(url) || isApiCall(url) || isInternalOfflineRuntimeUrl(url)) return false
377
+
378
+ val activeOrigin = activeOfflineRouteOrigin ?: return false
379
+ if (originForUrl(url) != activeOrigin) return false
380
+
381
+ val destination = requestHeader(request, "Sec-Fetch-Dest")?.lowercase()
382
+ if (destination == "document" || destination == "empty") return false
383
+ if (destination in setOf("script", "style", "image", "font", "audio", "video", "track", "manifest")) {
384
+ return true
385
+ }
386
+
387
+ val accept = requestHeader(request, "Accept")?.lowercase().orEmpty()
388
+ if (accept.contains("text/html") ||
389
+ accept.contains("application/json") ||
390
+ accept.contains("text/event-stream")
391
+ ) {
392
+ return false
393
+ }
394
+ if (accept.contains("text/css") ||
395
+ accept.contains("javascript") ||
396
+ accept.contains("image/") ||
397
+ accept.contains("font/") ||
398
+ accept.contains("application/font") ||
399
+ accept.contains("application/wasm")
400
+ ) {
401
+ return true
402
+ }
403
+
404
+ return offlineCacheService.shouldCacheAssetUrl(url)
405
+ }
406
+
407
+ private fun requestHeader(request: WebResourceRequest, name: String): String? {
408
+ return request.requestHeaders.entries.firstOrNull {
409
+ it.key.equals(name, ignoreCase = true)
410
+ }?.value
411
+ }
412
+
413
+ private fun updateActiveOfflineRoute(url: String, active: Boolean) {
414
+ activeOfflineRouteOrigin = if (active) originForUrl(url) else null
415
+ if (BuildConfig.DEBUG) {
416
+ Log.d(FLOW_TAG, "ROUTE active=$active activeOrigin=${activeOfflineRouteOrigin ?: "none"} url=$url")
417
+ }
418
+ }
419
+
420
+ private fun isVisibleOfflineSnapshot(url: String?): Boolean {
421
+ if (url == null) return false
422
+ return visibleOfflineSnapshotUrl == normalizeUrlWithoutFragment(url)
423
+ }
424
+
425
+ private fun normalizeUrlWithoutFragment(url: String): String {
426
+ return try {
427
+ Uri.parse(url).buildUpon().fragment(null).build().toString()
428
+ } catch (_: Exception) {
429
+ url
430
+ }
431
+ }
432
+
433
+ private fun originForUrl(url: String): String? {
434
+ return try {
435
+ val uri = Uri.parse(url)
436
+ val scheme = uri.scheme?.lowercase() ?: return null
437
+ val authority = uri.authority ?: return null
438
+ if (scheme != "http" && scheme != "https") return null
439
+ "$scheme://$authority"
440
+ } catch (_: Exception) {
441
+ null
442
+ }
443
+ }
444
+
445
+ private fun isHttpUrl(url: String): Boolean {
446
+ val scheme = Uri.parse(url).scheme?.lowercase()
447
+ return scheme == "http" || scheme == "https"
448
+ }
449
+
450
+ private fun isInternalOfflineRuntimeUrl(url: String): Boolean {
451
+ val path = Uri.parse(url).path ?: return false
452
+ return path == "/catalyst-offline-manifest.json" ||
453
+ path == "/catalyst-sw.js" ||
454
+ path == "/offline.html"
455
+ }
456
+
326
457
  /**
327
458
  * Handle special URL schemes (tel:, mailto:, sms:)
328
459
  */
@@ -502,11 +633,13 @@ class CustomWebView(
502
633
  serviceWorkerController.setServiceWorkerClient(object : ServiceWorkerClientCompat() {
503
634
  override fun shouldInterceptRequest(request: WebResourceRequest): WebResourceResponse? {
504
635
  val url = request.url.toString()
636
+ val isOnline = NetworkUtils.getCurrentStatus(context).isOnline
505
637
 
506
638
  if (BuildConfig.DEBUG) {
507
639
  Log.d(TAG, "🔧 SERVICE_WORKER: Intercepting request: $url")
508
640
  Log.d(TAG, "🔧 SERVICE_WORKER: Method: ${request.method}")
509
641
  Log.d(TAG, "🔧 SERVICE_WORKER: Headers: ${request.requestHeaders}")
642
+ Log.d(FLOW_TAG, "SW intercept method=${request.method} online=$isOnline mainFrame=${request.isForMainFrame} url=$url")
510
643
  }
511
644
 
512
645
  // Check if URL is allowed
@@ -526,7 +659,7 @@ class CustomWebView(
526
659
  }
527
660
 
528
661
  // Check if this should be cached
529
- if (request.method == "GET" && shouldCacheUrl(url)) {
662
+ if (request.method == "GET" && shouldCacheRequest(request, url)) {
530
663
  if (BuildConfig.DEBUG) {
531
664
  Log.d(TAG, "🎯 SERVICE_WORKER: Using cache system for: $url")
532
665
  }
@@ -540,49 +673,22 @@ class CustomWebView(
540
673
  }
541
674
 
542
675
  // Get cached response synchronously (non-blocking for ServiceWorker)
543
- val response = cacheManager.getCachedResponseSync(url, headers)
676
+ val response = if (NetworkUtils.getCurrentStatus(context).isOnline) {
677
+ cacheManager.getCachedResponseOrFetchSync(url, headers)
678
+ } else {
679
+ cacheManager.getCachedResponseSync(url, headers)
680
+ }
544
681
  if (response != null) {
545
682
  if (BuildConfig.DEBUG) {
546
683
  Log.d(TAG, "✅ SERVICE_WORKER: Served from cache: $url")
684
+ Log.d(FLOW_TAG, "SW response source=cache-or-fetch mime=${response.mimeType ?: "unknown"} encoding=${response.encoding ?: "unknown"} url=$url")
547
685
  }
548
686
  withCorsHeaders(response)
549
687
  } else {
550
688
  if (BuildConfig.DEBUG) {
551
689
  Log.d(TAG, "❌ SERVICE_WORKER: Cache miss: $url")
690
+ Log.d(FLOW_TAG, "SW response source=network-fallback reason=cache-null url=$url")
552
691
  }
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
692
  null
587
693
  }
588
694
  } catch (e: Exception) {
@@ -594,7 +700,7 @@ class CustomWebView(
594
700
  } else {
595
701
  if (BuildConfig.DEBUG) {
596
702
  Log.d(TAG, "⏭️ SERVICE_WORKER: Skipping cache for: $url")
597
- Log.d(TAG, "⏭️ SERVICE_WORKER: Method: ${request.method}, shouldCache: ${shouldCacheUrl(url)}")
703
+ Log.d(TAG, "⏭️ SERVICE_WORKER: Method: ${request.method}, shouldCache: ${shouldCacheRequest(request, url)}")
598
704
  }
599
705
  }
600
706
 
@@ -687,6 +793,7 @@ class CustomWebView(
687
793
  request: WebResourceRequest
688
794
  ): WebResourceResponse? {
689
795
  val url = request.url.toString()
796
+ val isOnline = NetworkUtils.getCurrentStatus(context).isOnline
690
797
 
691
798
  // Track all network requests
692
799
  metricsMonitor.recordNetworkRequest(url)
@@ -697,6 +804,7 @@ class CustomWebView(
697
804
  Log.d(TAG, "🔄 INTERCEPT: Thread: ${Thread.currentThread().name}")
698
805
  Log.d(TAG, "🔄 INTERCEPT: File extension: ${url.substringAfterLast('.', "none")}")
699
806
  Log.d(TAG, "🔄 INTERCEPT: isInitialPageLoaded: $isInitialPageLoaded")
807
+ 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
808
  }
701
809
 
702
810
  if (!URLWhitelistManager.isUrlAllowed(url)) {
@@ -713,6 +821,51 @@ class CustomWebView(
713
821
  }
714
822
  }
715
823
 
824
+ val isMainFrameDocument = request.isForMainFrame &&
825
+ request.method == "GET" &&
826
+ (request.url.scheme == "http" || request.url.scheme == "https") &&
827
+ !isApiCall(url)
828
+
829
+ if (isMainFrameDocument) {
830
+ val headers = defaultRequestHeaders.toMutableMap().apply {
831
+ putAll(request.requestHeaders)
832
+ }
833
+ if (BuildConfig.DEBUG) {
834
+ Log.d(FLOW_TAG, "DOCUMENT intercept online=$isOnline url=$url headers=${headers.keys.sorted()}")
835
+ }
836
+
837
+ if (isOnline) {
838
+ offlineCacheService.refreshManifestIfMissing(url, headers)
839
+ val isOfflineRoute = offlineCacheService.isOfflineRouteUrl(url)
840
+ updateActiveOfflineRoute(url, isOfflineRoute)
841
+ visibleOfflineSnapshotUrl = null
842
+ if (BuildConfig.DEBUG) {
843
+ Log.d(FLOW_TAG, "DOCUMENT online routeEligible=$isOfflineRoute action=allow-network-and-background-snapshot url=$url")
844
+ }
845
+ offlineCacheService.storeRouteSnapshotAsync(url, headers)
846
+ } else {
847
+ val snapshotResponse = offlineCacheService.getRouteSnapshotResponse(url)
848
+ if (snapshotResponse != null) {
849
+ updateActiveOfflineRoute(url, true)
850
+ visibleOfflineSnapshotUrl = normalizeUrlWithoutFragment(url)
851
+ if (BuildConfig.DEBUG) {
852
+ Log.d(TAG, "📴 INTERCEPT: Serving offline route snapshot: $url")
853
+ Log.d(FLOW_TAG, "DOCUMENT offline response=route-snapshot mime=${snapshotResponse.mimeType ?: "unknown"} url=$url")
854
+ }
855
+ return withCorsHeaders(snapshotResponse)
856
+ }
857
+ updateActiveOfflineRoute(url, false)
858
+ if (BuildConfig.DEBUG) {
859
+ Log.d(FLOW_TAG, "DOCUMENT offline response=network-fallback reason=snapshot-miss url=$url")
860
+ }
861
+ }
862
+ } else if (request.isForMainFrame &&
863
+ request.method == "GET" &&
864
+ (request.url.scheme == "http" || request.url.scheme == "https")
865
+ ) {
866
+ updateActiveOfflineRoute(url, false)
867
+ }
868
+
716
869
  // TODO: Add HTML file workflow - index.html not available yet
717
870
  // Handle the initial route request - intercept first request regardless of host
718
871
  // if (!isInitialApiCalled && request.method == "GET") {
@@ -747,12 +900,12 @@ class CustomWebView(
747
900
  if (BuildConfig.DEBUG) {
748
901
  Log.d(TAG, "🔍 STEP_4_CHECK: Initial page not loaded yet, checking for static resources")
749
902
  Log.d(TAG, "🔍 STEP_4_CHECK: isStaticResourceRequest($url) = ${isStaticResourceRequest(url)}")
750
- Log.d(TAG, "🔍 STEP_4_CHECK: shouldCacheUrl($url) = ${shouldCacheUrl(url)}")
903
+ Log.d(TAG, "🔍 STEP_4_CHECK: shouldCacheRequest($url) = ${shouldCacheRequest(request, url)}")
751
904
  }
752
905
 
753
906
  // IMPORTANT: Skip asset loading if the URL matches a cache pattern
754
907
  // This allows cached resources to be served from the cache system instead
755
- if (request.method == "GET" && isStaticResourceRequest(url) && !shouldCacheUrl(url)) {
908
+ if (request.method == "GET" && isStaticResourceRequest(url) && !shouldCacheRequest(request, url)) {
756
909
  val assetPath = extractAssetPath(url)
757
910
  assetLoadAttempts++
758
911
 
@@ -790,7 +943,7 @@ class CustomWebView(
790
943
  }
791
944
  // Unexpected error - fall through to cache check below
792
945
  }
793
- } else if (shouldCacheUrl(url)) {
946
+ } else if (shouldCacheRequest(request, url)) {
794
947
  if (BuildConfig.DEBUG) {
795
948
  Log.d(TAG, "⏭️ STEP_4_SKIP: Skipping asset loading for cached URL: $url")
796
949
  }
@@ -805,10 +958,10 @@ class CustomWebView(
805
958
  if (BuildConfig.DEBUG) {
806
959
  Log.d(TAG, "🔍 STEP_5_CACHE_CHECK: Evaluating cache conditions")
807
960
  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)}")
961
+ Log.d(TAG, "🔍 STEP_5_CACHE_CHECK: shouldCacheRequest($url) = ${shouldCacheRequest(request, url)}")
809
962
  }
810
963
 
811
- if (request.method == "GET" && shouldCacheUrl(url)) {
964
+ if (request.method == "GET" && shouldCacheRequest(request, url)) {
812
965
  // Track that this request was evaluated for caching
813
966
  metricsMonitor.recordCacheEvaluation(url)
814
967
  if (BuildConfig.DEBUG) {
@@ -831,45 +984,27 @@ class CustomWebView(
831
984
  }
832
985
 
833
986
  // Use synchronous cache check to avoid blocking
834
- val response = cacheManager.getCachedResponseSync(url, headers)
987
+ val response = if (NetworkUtils.getCurrentStatus(context).isOnline) {
988
+ cacheManager.getCachedResponseOrFetchSync(url, headers)
989
+ } else {
990
+ cacheManager.getCachedResponseSync(url, headers)
991
+ }
835
992
 
836
993
  if (response != null) {
837
994
  if (BuildConfig.DEBUG) {
838
995
  val duration = System.currentTimeMillis() - startTime
839
996
  Log.d(TAG, "✅ WEBVIEW_CLIENT: Served from cache in ${duration}ms: $url")
997
+ 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
998
  }
841
999
  metricsMonitor.recordCacheHit(url)
842
1000
  return withCorsHeaders(response)
843
1001
  } else {
844
1002
  if (BuildConfig.DEBUG) {
845
- Log.d(TAG, "❌ WEBVIEW_CLIENT: Cache miss, triggering async fetch: $url")
1003
+ Log.d(TAG, "❌ WEBVIEW_CLIENT: Cache miss: $url")
1004
+ Log.d(FLOW_TAG, "ASSET response source=network-fallback reason=cache-null online=${NetworkUtils.getCurrentStatus(context).isOnline} url=$url")
846
1005
  }
847
1006
  metricsMonitor.recordCacheMiss(url)
848
1007
 
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
1008
  // Return null to let network handle this request
874
1009
  return null
875
1010
  }
@@ -897,7 +1032,7 @@ class CustomWebView(
897
1032
  Log.d(TAG, "❌ STEP_5_FAIL: URL doesn't match cache criteria, skipping cache")
898
1033
  Log.d(TAG, "❌ STEP_5_FAIL: URL: $url")
899
1034
  Log.d(TAG, "❌ STEP_5_FAIL: Request method: ${request.method} (need GET)")
900
- Log.d(TAG, "❌ STEP_5_FAIL: shouldCacheUrl result: ${shouldCacheUrl(url)}")
1035
+ Log.d(TAG, "❌ STEP_5_FAIL: shouldCacheRequest result: ${shouldCacheRequest(request, url)}")
901
1036
  Log.d(TAG, "❌ STEP_5_FAIL: This request will go through normal network loading")
902
1037
  }
903
1038
  }
@@ -909,6 +1044,9 @@ class CustomWebView(
909
1044
  super.onPageStarted(view, url, favicon)
910
1045
  if (url != null && url != offlineAssetUrl) {
911
1046
  offlinePageVisible = false
1047
+ if (!isVisibleOfflineSnapshot(url)) {
1048
+ visibleOfflineSnapshotUrl = null
1049
+ }
912
1050
  onPageStarted?.invoke()
913
1051
  }
914
1052
  progressBar.visibility = View.VISIBLE
@@ -917,6 +1055,7 @@ class CustomWebView(
917
1055
  url?.let { metricsMonitor.trackPageLoadStart(it) }
918
1056
  if (BuildConfig.DEBUG) {
919
1057
  Log.d(TAG, "⏳ Page load started for: $url - Hardware Acceleration: $isHardwareAccelerationEnabled")
1058
+ Log.d(FLOW_TAG, "PAGE started url=$url online=${NetworkUtils.getCurrentStatus(context).isOnline} offlinePageVisible=$offlinePageVisible visibleSnapshot=${visibleOfflineSnapshotUrl ?: "none"} activeOrigin=${activeOfflineRouteOrigin ?: "none"}")
920
1059
  }
921
1060
  }
922
1061
 
@@ -930,6 +1069,7 @@ class CustomWebView(
930
1069
  val startTime = view?.tag as? Long ?: return
931
1070
  val loadTime = System.currentTimeMillis() - startTime
932
1071
  Log.d(TAG, "✅ Page load finished for: $url - Load time: ${loadTime}ms - Hardware Acceleration: $isHardwareAccelerationEnabled")
1072
+ Log.d(FLOW_TAG, "PAGE finished url=$url loadTimeMs=$loadTime online=${NetworkUtils.getCurrentStatus(context).isOnline} offlinePageVisible=$offlinePageVisible visibleSnapshot=${visibleOfflineSnapshotUrl ?: "none"} activeOrigin=${activeOfflineRouteOrigin ?: "none"}")
933
1073
  Log.d(TAG, "📊 Asset loading stats: Attempted: $assetLoadAttempts, Failed: $assetLoadFailures (${String.format("%.1f", assetLoadFailures * 100.0 / assetLoadAttempts.coerceAtLeast(1))}%)")
934
1074
  Log.d(TAG, "📊 ${metricsMonitor.getCacheStats()}")
935
1075
 
@@ -952,7 +1092,30 @@ class CustomWebView(
952
1092
  override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
953
1093
  if (error != null) {
954
1094
  Log.e(TAG, "❌ Error loading ${request?.url}: ${error.errorCode} ${error.description}")
1095
+ if (BuildConfig.DEBUG) {
1096
+ Log.e(FLOW_TAG, "PAGE resource-error mainFrame=${request?.isForMainFrame} method=${request?.method} code=${error.errorCode} description=${error.description} url=${request?.url}")
1097
+ }
955
1098
  }
1099
+
1100
+ val failedUrl = request?.url?.toString()
1101
+ if (request?.isForMainFrame == true && isVisibleOfflineSnapshot(failedUrl)) {
1102
+ if (BuildConfig.DEBUG) {
1103
+ Log.d(TAG, "📴 Ignoring repeated error for visible offline route snapshot: $failedUrl")
1104
+ }
1105
+ return
1106
+ }
1107
+
1108
+ if (request?.isForMainFrame == true &&
1109
+ failedUrl != null &&
1110
+ failedUrl != offlineAssetUrl &&
1111
+ !NetworkUtils.getCurrentStatus(context).isOnline
1112
+ ) {
1113
+ view?.post {
1114
+ showOfflineRouteOrOfflinePage(failedUrl)
1115
+ }
1116
+ return
1117
+ }
1118
+
956
1119
  super.onReceivedError(view, request, error)
957
1120
  }
958
1121
 
@@ -1004,6 +1167,7 @@ class CustomWebView(
1004
1167
  override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
1005
1168
  if (BuildConfig.DEBUG) {
1006
1169
  Log.d(TAG, "Console: ${consoleMessage?.message()} -- From line ${consoleMessage?.lineNumber()} of ${consoleMessage?.sourceId()}")
1170
+ Log.d(FLOW_TAG, "CONSOLE level=${consoleMessage?.messageLevel()} line=${consoleMessage?.lineNumber()} source=${consoleMessage?.sourceId()} message=${consoleMessage?.message()}")
1007
1171
  }
1008
1172
  return true
1009
1173
  }
@@ -136,6 +136,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
136
136
  "X-Safe-Area-Right" to latestSafeAreaInsets.right.toString(),
137
137
  "X-Safe-Area-Bottom" to latestSafeAreaInsets.bottom.toString(),
138
138
  "X-Safe-Area-Left" to latestSafeAreaInsets.left.toString(),
139
+ "X-Catalyst-Native-WebView" to "1",
139
140
  // Prevent caching of SSR response so updated headers are always used
140
141
  "Cache-Control" to "no-cache, no-store, must-revalidate",
141
142
  "Pragma" to "no-cache"
@@ -385,7 +386,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
385
386
  if (BuildConfig.DEBUG) {
386
387
  Log.w(TAG, "📴 Device offline on launch, showing offline page")
387
388
  }
388
- customWebView.showOfflinePage()
389
+ customWebView.showOfflineRouteOrOfflinePage(currentUrl)
389
390
  }
390
391
  }
391
392
 
@@ -533,7 +534,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
533
534
  loadUrlWithSafeArea(url)
534
535
  } else {
535
536
  Log.w(TAG, "📴 Offline during notification click, showing offline page")
536
- customWebView.showOfflinePage()
537
+ customWebView.showOfflineRouteOrOfflinePage(url)
537
538
  }
538
539
 
539
540
  } catch (e: Exception) {
@@ -213,7 +213,7 @@ class NativeBridge(
213
213
  .filter { it.isNotEmpty() }
214
214
 
215
215
  accessControlEnabled = properties
216
- .getProperty("accessControl.enabled", "true")
216
+ .getProperty("accessControl.enabled", "false")
217
217
  .equals("true", ignoreCase = true)
218
218
 
219
219
  if (accessControlEnabled && allowedUrls.isNotEmpty()) {