catalyst-core 0.1.0-canary.9 → 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 (148) hide show
  1. package/README.md +1 -1
  2. package/bin/catalyst.js +8 -1
  3. package/changelog.md +6 -0
  4. package/dist/native/androidProject/app/build.gradle.kts +34 -8
  5. package/dist/native/androidProject/app/src/main/AndroidManifest.xml +2 -1
  6. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/BridgeMessageValidator.kt +1 -1
  7. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +270 -78
  8. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +34 -5
  9. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeBridge.kt +149 -1
  10. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeCameraManager.kt +337 -0
  11. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/OfflineCacheService.kt +374 -0
  12. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/TransitionManager.kt +202 -0
  13. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/WebCacheManager.kt +156 -39
  14. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/BarcodeDetector.kt +123 -0
  15. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/CameraSessionManager.kt +244 -0
  16. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/HoldController.kt +70 -0
  17. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/TorchController.kt +57 -0
  18. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/VideoStreamState.kt +41 -0
  19. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/VideoStreamStateMachine.kt +40 -0
  20. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/ViewfinderMapper.kt +75 -0
  21. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/ZoomController.kt +156 -0
  22. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/CatalystPlugin.kt +7 -0
  23. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/GeneratedPluginIndex.kt +6 -0
  24. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/PluginBridge.kt +253 -0
  25. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/security/FridaDetector.kt +16 -5
  26. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/BridgeUtils.kt +42 -5
  27. package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/FrameworkServerUtils.kt +7 -3
  28. package/dist/native/androidProject/app/src/main/res/drawable/debug_viewfinder_border.xml +8 -0
  29. package/dist/native/androidProject/app/src/main/res/layout/activity_main.xml +37 -0
  30. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/CustomWebviewTest.kt +7 -2
  31. package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/plugins/PluginBridgeTest.kt +139 -0
  32. package/dist/native/androidSetup.js +1 -0
  33. package/dist/native/bridge/WebBridge.js +5 -5
  34. package/dist/native/bridge/constants/NativeInterfaces.js +9 -5
  35. package/dist/native/bridge/hooks/index.js +1 -0
  36. package/dist/native/bridge/hooks/useCamera.js +9 -0
  37. package/dist/native/bridge/hooks/useDataProtection.js +2 -0
  38. package/dist/native/bridge/hooks/useDeviceInfo.js +2 -0
  39. package/dist/native/bridge/hooks/useFilePicker.js +2 -0
  40. package/dist/native/bridge/hooks/useGoogleSignIn.js +1 -0
  41. package/dist/native/bridge/hooks/useHapticFeedback.js +1 -0
  42. package/dist/native/bridge/hooks/useIntent.js +1 -0
  43. package/dist/native/bridge/hooks/useNativeTransition.js +43 -0
  44. package/dist/native/bridge/hooks/useNetworkStatus.js +3 -0
  45. package/dist/native/bridge/hooks/useNotification.js +2 -0
  46. package/dist/native/bridge/hooks/useSafeArea.js +2 -0
  47. package/dist/native/bridge/hooks/useVideoStream.js +6 -0
  48. package/dist/native/bridge/hooks/utils.js +1 -0
  49. package/dist/native/bridge/hooks.js +1 -137
  50. package/dist/native/bridge/useBaseHook.js +12 -7
  51. package/dist/native/bridge/utils/NativeBridge.js +41 -6
  52. package/dist/native/buildAndroid/assets.js +52 -0
  53. package/dist/native/buildAndroid/build.js +19 -0
  54. package/dist/native/buildAndroid/config.js +1 -0
  55. package/dist/native/buildAndroid/index.js +1 -0
  56. package/dist/native/buildAppAndroid.js +2 -150
  57. package/dist/native/buildAppIos.js +2 -312
  58. package/dist/native/buildIos/assets.js +8 -0
  59. package/dist/native/buildIos/build.js +40 -0
  60. package/dist/native/buildIos/config.js +71 -0
  61. package/dist/native/buildIos/index.js +6 -0
  62. package/dist/native/buildIos/plugins.js +45 -0
  63. package/dist/native/internalPluginUtils.js +7 -0
  64. package/dist/native/iosnativeWebView/Package.resolved +36 -0
  65. package/dist/native/iosnativeWebView/Sources/Core/Camera/BarcodeDetector.swift +116 -0
  66. package/dist/native/iosnativeWebView/Sources/Core/Camera/CameraPreviewView.swift +58 -0
  67. package/dist/native/iosnativeWebView/Sources/Core/Camera/CameraSessionManager.swift +308 -0
  68. package/dist/native/iosnativeWebView/Sources/Core/Camera/HoldController.swift +63 -0
  69. package/dist/native/iosnativeWebView/Sources/Core/Camera/NativeCameraManager.swift +194 -0
  70. package/dist/native/iosnativeWebView/Sources/Core/Camera/TorchController.swift +60 -0
  71. package/dist/native/iosnativeWebView/Sources/Core/Camera/VideoStreamState.swift +44 -0
  72. package/dist/native/iosnativeWebView/Sources/Core/Camera/VideoStreamStateMachine.swift +60 -0
  73. package/dist/native/iosnativeWebView/Sources/Core/Camera/ZoomController.swift +130 -0
  74. package/dist/native/iosnativeWebView/Sources/Core/Constants/CatalystConstants.swift +11 -1
  75. package/dist/native/iosnativeWebView/Sources/Core/Constants/ConfigConstants.swift +2 -1
  76. package/dist/native/iosnativeWebView/Sources/Core/Plugins/CatalystPlugin.swift +5 -0
  77. package/dist/native/iosnativeWebView/Sources/Core/Plugins/GeneratedPluginIndex.swift +6 -0
  78. package/dist/native/iosnativeWebView/Sources/Core/Plugins/PluginBridge.swift +364 -0
  79. package/dist/native/iosnativeWebView/Sources/Core/Utils/CacheManager.swift +39 -4
  80. package/dist/native/iosnativeWebView/Sources/Core/Utils/OfflineCacheService.swift +683 -0
  81. package/dist/native/iosnativeWebView/Sources/Core/Utils/OfflineURLSchemeHandler.swift +94 -0
  82. package/dist/native/iosnativeWebView/Sources/Core/Utils/ResourceURLProtocol.swift +19 -6
  83. package/dist/native/iosnativeWebView/Sources/Core/Utils/SafeAreaUtils.swift +1 -0
  84. package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeCommandHandler.swift +1 -0
  85. package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeMessageValidator.swift +4 -1
  86. package/dist/native/iosnativeWebView/Sources/Core/WebView/NativeBridge.swift +227 -4
  87. package/dist/native/iosnativeWebView/Sources/Core/WebView/TransitionManager.swift +169 -0
  88. package/dist/native/iosnativeWebView/Sources/Core/WebView/WeakScriptMessageHandler.swift +14 -0
  89. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebView.swift +53 -8
  90. package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +88 -4
  91. package/dist/native/iosnativeWebView/iosnativeWebView/ContentView.swift +30 -11
  92. package/dist/native/iosnativeWebView/iosnativeWebView/Info.plist +11 -17
  93. package/dist/native/iosnativeWebView/iosnativeWebView.xcodeproj/project.pbxproj +8 -2
  94. package/dist/native/iosnativeWebView/iosnativeWebViewTests/BootTimingUtilityTests.swift +1 -1
  95. package/dist/native/iosnativeWebView/iosnativeWebViewTests/ConfigMappingTests.swift +4 -2
  96. package/dist/native/iosnativeWebView/iosnativeWebViewTests/PluginBridgeTests.swift +160 -0
  97. package/dist/native/plugin-bridge/PluginBridge.js +1 -0
  98. package/dist/native/pluginComposerAndroid.js +28 -0
  99. package/dist/native/pluginComposerIos.js +16 -0
  100. package/dist/native/renameAndroidProject.js +5 -5
  101. package/dist/native/setupEmulatorIos.js +6 -4
  102. package/dist/native/setupServer.js +2 -3
  103. package/dist/native/utils.js +4 -1
  104. package/dist/offline/catalyst-sw.js +1 -0
  105. package/dist/offline/registerServiceWorker.js +1 -0
  106. package/dist/otel.js +70 -2
  107. package/dist/router/ClientRouter.js +1 -1
  108. package/dist/scripts/build.js +1 -1
  109. package/dist/scripts/cleanCache.js +1 -0
  110. package/dist/scripts/devBuild.js +1 -1
  111. package/dist/scripts/devServe.js +1 -1
  112. package/dist/scripts/generateOfflineManifest.js +1 -0
  113. package/dist/scripts/loadEnvironmentVariables.js +1 -2
  114. package/dist/scripts/plugins.js +1 -0
  115. package/dist/scripts/registerAliases.js +6 -2
  116. package/dist/scripts/scriptUtils.js +2 -1
  117. package/dist/scripts/serve.js +2 -1
  118. package/dist/scripts/start.js +7 -2
  119. package/dist/server/expressServer.js +4 -8
  120. package/dist/server/renderer/document/Body.js +4 -4
  121. package/dist/server/renderer/document/Head.js +2 -2
  122. package/dist/server/renderer/extract.js +31 -27
  123. package/dist/server/renderer/handler.js +47 -24
  124. package/dist/server/renderer/index.js +1 -1
  125. package/dist/server/startServer.js +57 -7
  126. package/dist/server/utils/userAgentUtil.js +9 -3
  127. package/dist/server/utils/validator.js +1 -1
  128. package/dist/webpack/babel-plugins/remove-client.plugin.js +2 -2
  129. package/dist/webpack/babel-plugins/remove-ssr.plugin.js +2 -2
  130. package/dist/webpack/babel.config.client.js +1 -1
  131. package/dist/webpack/babel.config.ssr.js +1 -1
  132. package/dist/webpack/base.babel.js +7 -8
  133. package/dist/webpack/development.client.babel.js +7 -16
  134. package/dist/webpack/production.client.babel.js +1 -1
  135. package/dist/webpack/production.ssr.babel.js +5 -1
  136. package/mcp_v2/knowledge-base.json +206 -100
  137. package/mcp_v2/lib/helpers.js +42 -8
  138. package/mcp_v2/lib/seed.js +88 -0
  139. package/mcp_v2/mcp.js +150 -4
  140. package/mcp_v2/setup.js +30 -193
  141. package/mcp_v2/tools/build.js +1 -1
  142. package/mcp_v2/tools/config.js +16 -4
  143. package/mcp_v2/tools/conversion.js +11 -11
  144. package/mcp_v2/tools/github.js +1117 -0
  145. package/mcp_v2/tools/knowledge.js +16 -3
  146. package/mcp_v2/tools/sync.js +179 -10
  147. package/mcp_v2/tools/tasks.js +13 -4
  148. package/package.json +20 -8
@@ -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,18 +52,19 @@ 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()
58
+ var onPageStarted: (() -> Unit)? = null
54
59
 
55
60
  // Counters for asset loading statistics
56
61
  private var assetLoadAttempts = 0
57
62
  private var assetLoadFailures = 0
58
63
 
59
- // Track ongoing cache fetches to prevent duplicates
60
- private val ongoingCacheFetches = mutableSetOf<String>()
61
-
62
64
  init {
63
65
  setupFromProperties()
64
66
  cacheManager = WebCacheManager(context, properties)
67
+ offlineCacheService = OfflineCacheService(context)
65
68
  setupWebView()
66
69
  }
67
70
 
@@ -96,7 +99,7 @@ class CustomWebView(
96
99
 
97
100
  // Access control toggle
98
101
  accessControlEnabled = properties
99
- .getProperty("accessControl.enabled", "true")
102
+ .getProperty("accessControl.enabled", "false")
100
103
  .equals("true", ignoreCase = true)
101
104
 
102
105
  // Initialize URLWhitelistManager with access control configuration
@@ -136,6 +139,15 @@ class CustomWebView(
136
139
 
137
140
  private fun loadUrlInternal(url: String) {
138
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
+ }
139
151
 
140
152
  if (isNetworkUrl && defaultRequestHeaders.isNotEmpty()) {
141
153
  if (BuildConfig.DEBUG) {
@@ -145,11 +157,16 @@ class CustomWebView(
145
157
  } else {
146
158
  webView.loadUrl(url)
147
159
  }
160
+ if (BuildConfig.DEBUG) {
161
+ Log.d(FLOW_TAG, "WEBVIEW load-url dispatched url=$url")
162
+ }
148
163
  }
149
164
 
150
165
  fun loadUrl(url: String) {
151
166
  lastTargetUrl = url
152
167
  offlinePageVisible = false
168
+ visibleOfflineSnapshotUrl = null
169
+ activeOfflineRouteOrigin = null
153
170
  loadUrlInternal(url)
154
171
  }
155
172
 
@@ -166,6 +183,8 @@ class CustomWebView(
166
183
  try {
167
184
  context.assets.open(offlineAssetPath).close()
168
185
  offlinePageVisible = true
186
+ visibleOfflineSnapshotUrl = null
187
+ activeOfflineRouteOrigin = null
169
188
  loadUrlInternal(offlineAssetUrl)
170
189
  if (BuildConfig.DEBUG) {
171
190
  Log.d(TAG, "📴 Showing offline page from assets: $offlineAssetPath")
@@ -191,6 +210,32 @@ class CustomWebView(
191
210
 
192
211
  fun clearAllCache() {
193
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()
194
239
  }
195
240
 
196
241
  fun canGoBack(): Boolean = webView.canGoBack()
@@ -233,7 +278,7 @@ class CustomWebView(
233
278
  }
234
279
 
235
280
  // Additional security check: only allow whitelisted interface names
236
- val allowedInterfaces = setOf("NativeBridge", "AndroidBridge")
281
+ val allowedInterfaces = setOf("NativeBridge", "AndroidBridge", "PluginBridge")
237
282
  if (name !in allowedInterfaces) {
238
283
  Log.e(TAG, "❌ Security: Interface name '$name' is not in whitelist. Refusing to add interface.")
239
284
  return
@@ -246,6 +291,17 @@ class CustomWebView(
246
291
  }
247
292
  }
248
293
 
294
+ fun removeJavascriptInterface(name: String) {
295
+ try {
296
+ webView.removeJavascriptInterface(name)
297
+ if (BuildConfig.DEBUG) {
298
+ Log.d(TAG, "🔌 Removed JavaScript interface: $name")
299
+ }
300
+ } catch (e: Exception) {
301
+ Log.w(TAG, "⚠️ Failed to remove JavaScript interface '$name': ${e.message}")
302
+ }
303
+ }
304
+
249
305
  fun destroy() {
250
306
  job.cancel()
251
307
  webView.destroy()
@@ -292,25 +348,112 @@ class CustomWebView(
292
348
  }
293
349
  }
294
350
 
295
- private fun shouldCacheUrl(url: String): Boolean {
351
+ private fun shouldCacheRequest(request: WebResourceRequest, url: String): Boolean {
296
352
  if (BuildConfig.DEBUG) {
297
353
  Log.d(TAG, "🔍 CACHE_CHECK: Checking if URL should be cached: $url")
298
354
  Log.d(TAG, "🔍 CACHE_CHECK: Cache patterns: $cachePatterns")
299
355
  }
300
356
 
301
- val shouldCache = matchesCachePattern(url, cachePatterns)
357
+ val matchesPattern = matchesCachePattern(url, cachePatterns)
358
+ val offlineRouteSubresource = shouldCacheOfflineRouteSubresource(request, url)
359
+ val shouldCache = matchesPattern || offlineRouteSubresource
302
360
 
303
361
  if (BuildConfig.DEBUG) {
304
362
  if (shouldCache) {
305
- 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")
306
365
  } else {
307
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")
308
368
  }
309
369
  }
310
370
 
311
371
  return shouldCache
312
372
  }
313
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
+
314
457
  /**
315
458
  * Handle special URL schemes (tel:, mailto:, sms:)
316
459
  */
@@ -453,6 +596,18 @@ class CustomWebView(
453
596
  return "build/public/$fileName"
454
597
  }
455
598
 
599
+ private fun withCorsHeaders(response: WebResourceResponse): WebResourceResponse {
600
+ val responseHeaders = response.responseHeaders
601
+ if (responseHeaders.isNullOrEmpty()) {
602
+ response.responseHeaders = mutableMapOf(
603
+ "Access-Control-Allow-Origin" to "*",
604
+ "Access-Control-Allow-Methods" to "GET, OPTIONS",
605
+ "Access-Control-Allow-Headers" to "*"
606
+ )
607
+ }
608
+ return response
609
+ }
610
+
456
611
  private fun getMimeType(path: String): String {
457
612
  return when {
458
613
  path.endsWith(".html") -> "text/html"
@@ -478,11 +633,13 @@ class CustomWebView(
478
633
  serviceWorkerController.setServiceWorkerClient(object : ServiceWorkerClientCompat() {
479
634
  override fun shouldInterceptRequest(request: WebResourceRequest): WebResourceResponse? {
480
635
  val url = request.url.toString()
636
+ val isOnline = NetworkUtils.getCurrentStatus(context).isOnline
481
637
 
482
638
  if (BuildConfig.DEBUG) {
483
639
  Log.d(TAG, "🔧 SERVICE_WORKER: Intercepting request: $url")
484
640
  Log.d(TAG, "🔧 SERVICE_WORKER: Method: ${request.method}")
485
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")
486
643
  }
487
644
 
488
645
  // Check if URL is allowed
@@ -502,7 +659,7 @@ class CustomWebView(
502
659
  }
503
660
 
504
661
  // Check if this should be cached
505
- if (request.method == "GET" && shouldCacheUrl(url)) {
662
+ if (request.method == "GET" && shouldCacheRequest(request, url)) {
506
663
  if (BuildConfig.DEBUG) {
507
664
  Log.d(TAG, "🎯 SERVICE_WORKER: Using cache system for: $url")
508
665
  }
@@ -516,49 +673,22 @@ class CustomWebView(
516
673
  }
517
674
 
518
675
  // Get cached response synchronously (non-blocking for ServiceWorker)
519
- 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
+ }
520
681
  if (response != null) {
521
682
  if (BuildConfig.DEBUG) {
522
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")
523
685
  }
524
- response
686
+ withCorsHeaders(response)
525
687
  } else {
526
688
  if (BuildConfig.DEBUG) {
527
689
  Log.d(TAG, "❌ SERVICE_WORKER: Cache miss: $url")
690
+ Log.d(FLOW_TAG, "SW response source=network-fallback reason=cache-null url=$url")
528
691
  }
529
-
530
- // Trigger async cache population for next request (with deduplication)
531
- val shouldFetch = synchronized(ongoingCacheFetches) {
532
- ongoingCacheFetches.add(url)
533
- }
534
-
535
- if (shouldFetch) {
536
- if (BuildConfig.DEBUG) {
537
- Log.d(TAG, "🔄 SERVICE_WORKER: Triggering async cache fetch for: $url")
538
- }
539
- launch(Dispatchers.IO) {
540
- try {
541
- cacheManager.getCachedResponse(url, headers)
542
- if (BuildConfig.DEBUG) {
543
- Log.d(TAG, "✅ SERVICE_WORKER: Async cache fetch completed: $url")
544
- }
545
- } catch (e: Exception) {
546
- if (BuildConfig.DEBUG) {
547
- Log.e(TAG, "❌ SERVICE_WORKER: Async cache fetch failed: $url - ${e.message}")
548
- }
549
- } finally {
550
- synchronized(ongoingCacheFetches) {
551
- ongoingCacheFetches.remove(url)
552
- }
553
- }
554
- }
555
- } else {
556
- if (BuildConfig.DEBUG) {
557
- Log.d(TAG, "⏭️ SERVICE_WORKER: Cache fetch already in progress for: $url")
558
- }
559
- }
560
-
561
- // Let this request fall back to network
562
692
  null
563
693
  }
564
694
  } catch (e: Exception) {
@@ -570,7 +700,7 @@ class CustomWebView(
570
700
  } else {
571
701
  if (BuildConfig.DEBUG) {
572
702
  Log.d(TAG, "⏭️ SERVICE_WORKER: Skipping cache for: $url")
573
- Log.d(TAG, "⏭️ SERVICE_WORKER: Method: ${request.method}, shouldCache: ${shouldCacheUrl(url)}")
703
+ Log.d(TAG, "⏭️ SERVICE_WORKER: Method: ${request.method}, shouldCache: ${shouldCacheRequest(request, url)}")
574
704
  }
575
705
  }
576
706
 
@@ -606,6 +736,9 @@ class CustomWebView(
606
736
  domStorageEnabled = true
607
737
  allowFileAccess = true
608
738
  allowContentAccess = true
739
+ setSupportZoom(false)
740
+ builtInZoomControls = false
741
+ displayZoomControls = false
609
742
 
610
743
  // TODO: Enable these when build optimization feature is implemented
611
744
  // These are deprecated but may be needed for local file access in development
@@ -660,6 +793,7 @@ class CustomWebView(
660
793
  request: WebResourceRequest
661
794
  ): WebResourceResponse? {
662
795
  val url = request.url.toString()
796
+ val isOnline = NetworkUtils.getCurrentStatus(context).isOnline
663
797
 
664
798
  // Track all network requests
665
799
  metricsMonitor.recordNetworkRequest(url)
@@ -670,6 +804,7 @@ class CustomWebView(
670
804
  Log.d(TAG, "🔄 INTERCEPT: Thread: ${Thread.currentThread().name}")
671
805
  Log.d(TAG, "🔄 INTERCEPT: File extension: ${url.substringAfterLast('.', "none")}")
672
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")
673
808
  }
674
809
 
675
810
  if (!URLWhitelistManager.isUrlAllowed(url)) {
@@ -686,6 +821,51 @@ class CustomWebView(
686
821
  }
687
822
  }
688
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
+
689
869
  // TODO: Add HTML file workflow - index.html not available yet
690
870
  // Handle the initial route request - intercept first request regardless of host
691
871
  // if (!isInitialApiCalled && request.method == "GET") {
@@ -720,12 +900,12 @@ class CustomWebView(
720
900
  if (BuildConfig.DEBUG) {
721
901
  Log.d(TAG, "🔍 STEP_4_CHECK: Initial page not loaded yet, checking for static resources")
722
902
  Log.d(TAG, "🔍 STEP_4_CHECK: isStaticResourceRequest($url) = ${isStaticResourceRequest(url)}")
723
- Log.d(TAG, "🔍 STEP_4_CHECK: shouldCacheUrl($url) = ${shouldCacheUrl(url)}")
903
+ Log.d(TAG, "🔍 STEP_4_CHECK: shouldCacheRequest($url) = ${shouldCacheRequest(request, url)}")
724
904
  }
725
905
 
726
906
  // IMPORTANT: Skip asset loading if the URL matches a cache pattern
727
907
  // This allows cached resources to be served from the cache system instead
728
- if (request.method == "GET" && isStaticResourceRequest(url) && !shouldCacheUrl(url)) {
908
+ if (request.method == "GET" && isStaticResourceRequest(url) && !shouldCacheRequest(request, url)) {
729
909
  val assetPath = extractAssetPath(url)
730
910
  assetLoadAttempts++
731
911
 
@@ -763,7 +943,7 @@ class CustomWebView(
763
943
  }
764
944
  // Unexpected error - fall through to cache check below
765
945
  }
766
- } else if (shouldCacheUrl(url)) {
946
+ } else if (shouldCacheRequest(request, url)) {
767
947
  if (BuildConfig.DEBUG) {
768
948
  Log.d(TAG, "⏭️ STEP_4_SKIP: Skipping asset loading for cached URL: $url")
769
949
  }
@@ -778,10 +958,10 @@ class CustomWebView(
778
958
  if (BuildConfig.DEBUG) {
779
959
  Log.d(TAG, "🔍 STEP_5_CACHE_CHECK: Evaluating cache conditions")
780
960
  Log.d(TAG, "🔍 STEP_5_CACHE_CHECK: Request method: ${request.method} (need GET)")
781
- Log.d(TAG, "🔍 STEP_5_CACHE_CHECK: shouldCacheUrl($url) = ${shouldCacheUrl(url)}")
961
+ Log.d(TAG, "🔍 STEP_5_CACHE_CHECK: shouldCacheRequest($url) = ${shouldCacheRequest(request, url)}")
782
962
  }
783
963
 
784
- if (request.method == "GET" && shouldCacheUrl(url)) {
964
+ if (request.method == "GET" && shouldCacheRequest(request, url)) {
785
965
  // Track that this request was evaluated for caching
786
966
  metricsMonitor.recordCacheEvaluation(url)
787
967
  if (BuildConfig.DEBUG) {
@@ -804,45 +984,27 @@ class CustomWebView(
804
984
  }
805
985
 
806
986
  // Use synchronous cache check to avoid blocking
807
- 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
+ }
808
992
 
809
993
  if (response != null) {
810
994
  if (BuildConfig.DEBUG) {
811
995
  val duration = System.currentTimeMillis() - startTime
812
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")
813
998
  }
814
999
  metricsMonitor.recordCacheHit(url)
815
- return response
1000
+ return withCorsHeaders(response)
816
1001
  } else {
817
1002
  if (BuildConfig.DEBUG) {
818
- 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")
819
1005
  }
820
1006
  metricsMonitor.recordCacheMiss(url)
821
1007
 
822
- // Trigger async cache population for next time
823
- val shouldFetch = synchronized(ongoingCacheFetches) {
824
- ongoingCacheFetches.add(url)
825
- }
826
-
827
- if (shouldFetch) {
828
- launch(Dispatchers.IO) {
829
- try {
830
- cacheManager.getCachedResponse(url, headers)
831
- if (BuildConfig.DEBUG) {
832
- Log.d(TAG, "✅ WEBVIEW_CLIENT: Async cache fetch completed: $url")
833
- }
834
- } catch (e: Exception) {
835
- if (BuildConfig.DEBUG) {
836
- Log.e(TAG, "❌ WEBVIEW_CLIENT: Async cache fetch failed: $url - ${e.message}")
837
- }
838
- } finally {
839
- synchronized(ongoingCacheFetches) {
840
- ongoingCacheFetches.remove(url)
841
- }
842
- }
843
- }
844
- }
845
-
846
1008
  // Return null to let network handle this request
847
1009
  return null
848
1010
  }
@@ -870,7 +1032,7 @@ class CustomWebView(
870
1032
  Log.d(TAG, "❌ STEP_5_FAIL: URL doesn't match cache criteria, skipping cache")
871
1033
  Log.d(TAG, "❌ STEP_5_FAIL: URL: $url")
872
1034
  Log.d(TAG, "❌ STEP_5_FAIL: Request method: ${request.method} (need GET)")
873
- Log.d(TAG, "❌ STEP_5_FAIL: shouldCacheUrl result: ${shouldCacheUrl(url)}")
1035
+ Log.d(TAG, "❌ STEP_5_FAIL: shouldCacheRequest result: ${shouldCacheRequest(request, url)}")
874
1036
  Log.d(TAG, "❌ STEP_5_FAIL: This request will go through normal network loading")
875
1037
  }
876
1038
  }
@@ -882,6 +1044,10 @@ class CustomWebView(
882
1044
  super.onPageStarted(view, url, favicon)
883
1045
  if (url != null && url != offlineAssetUrl) {
884
1046
  offlinePageVisible = false
1047
+ if (!isVisibleOfflineSnapshot(url)) {
1048
+ visibleOfflineSnapshotUrl = null
1049
+ }
1050
+ onPageStarted?.invoke()
885
1051
  }
886
1052
  progressBar.visibility = View.VISIBLE
887
1053
  val startTime = System.currentTimeMillis()
@@ -889,6 +1055,7 @@ class CustomWebView(
889
1055
  url?.let { metricsMonitor.trackPageLoadStart(it) }
890
1056
  if (BuildConfig.DEBUG) {
891
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"}")
892
1059
  }
893
1060
  }
894
1061
 
@@ -902,6 +1069,7 @@ class CustomWebView(
902
1069
  val startTime = view?.tag as? Long ?: return
903
1070
  val loadTime = System.currentTimeMillis() - startTime
904
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"}")
905
1073
  Log.d(TAG, "📊 Asset loading stats: Attempted: $assetLoadAttempts, Failed: $assetLoadFailures (${String.format("%.1f", assetLoadFailures * 100.0 / assetLoadAttempts.coerceAtLeast(1))}%)")
906
1074
  Log.d(TAG, "📊 ${metricsMonitor.getCacheStats()}")
907
1075
 
@@ -924,7 +1092,30 @@ class CustomWebView(
924
1092
  override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
925
1093
  if (error != null) {
926
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
+ }
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
927
1117
  }
1118
+
928
1119
  super.onReceivedError(view, request, error)
929
1120
  }
930
1121
 
@@ -976,6 +1167,7 @@ class CustomWebView(
976
1167
  override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
977
1168
  if (BuildConfig.DEBUG) {
978
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()}")
979
1171
  }
980
1172
  return true
981
1173
  }