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
@@ -0,0 +1,374 @@
1
+ package io.yourname.androidproject
2
+
3
+ import android.content.Context
4
+ import android.net.Uri
5
+ import android.util.Log
6
+ import android.webkit.CookieManager
7
+ import android.webkit.WebResourceResponse
8
+ import kotlinx.coroutines.CoroutineScope
9
+ import kotlinx.coroutines.Dispatchers
10
+ import kotlinx.coroutines.SupervisorJob
11
+ import kotlinx.coroutines.launch
12
+ import org.json.JSONObject
13
+ import java.io.BufferedInputStream
14
+ import java.io.File
15
+ import java.io.FileInputStream
16
+ import java.io.FileOutputStream
17
+ import java.net.HttpURLConnection
18
+ import java.net.URL
19
+ import java.security.MessageDigest
20
+
21
+ class OfflineCacheService(
22
+ context: Context
23
+ ) {
24
+ companion object {
25
+ private const val SNAPSHOT_MAX_AGE_MS = 24 * 60 * 60 * 1000L
26
+ private val ASSET_EXTENSIONS = setOf(
27
+ ".js",
28
+ ".mjs",
29
+ ".css",
30
+ ".png",
31
+ ".jpg",
32
+ ".jpeg",
33
+ ".gif",
34
+ ".svg",
35
+ ".webp",
36
+ ".ico",
37
+ ".woff",
38
+ ".woff2",
39
+ ".ttf",
40
+ ".eot"
41
+ )
42
+ }
43
+
44
+ private val TAG = "OfflineCacheService"
45
+ private val FLOW_TAG = "CatalystOfflineFlow"
46
+ private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
47
+ private val rootDir = File(context.cacheDir, "catalyst_offline")
48
+ private val routeRootDir = File(rootDir, "routes")
49
+ private val manifestFile = File(rootDir, "manifest.json")
50
+ private val ongoingSnapshots = mutableSetOf<String>()
51
+
52
+ @Volatile
53
+ private var manifest: OfflineManifest? = loadCachedManifest()
54
+
55
+ data class OfflineManifest(
56
+ val buildId: String,
57
+ val routes: List<OfflineRoute>
58
+ )
59
+
60
+ data class OfflineRoute(
61
+ val pattern: String,
62
+ val regex: String
63
+ )
64
+
65
+ init {
66
+ rootDir.mkdirs()
67
+ routeRootDir.mkdirs()
68
+ if (BuildConfig.DEBUG) {
69
+ Log.d(FLOW_TAG, "OFFLINE_SERVICE init cacheDir=${rootDir.absolutePath} manifestBuildId=${manifest?.buildId ?: "none"} routes=${manifest?.routes?.size ?: 0}")
70
+ }
71
+ }
72
+
73
+ fun refreshManifestAsync(url: String, headers: Map<String, String> = emptyMap()) {
74
+ if (!isHttpUrl(url)) return
75
+
76
+ if (BuildConfig.DEBUG) {
77
+ Log.d(FLOW_TAG, "MANIFEST refresh-async requested url=$url")
78
+ }
79
+ scope.launch {
80
+ refreshManifestBlocking(url, headers)
81
+ }
82
+ }
83
+
84
+ fun refreshManifestIfMissing(url: String, headers: Map<String, String> = emptyMap()) {
85
+ if (!isHttpUrl(url) || manifest != null) return
86
+ if (BuildConfig.DEBUG) {
87
+ Log.d(FLOW_TAG, "MANIFEST refresh-blocking reason=missing url=$url")
88
+ }
89
+ refreshManifestBlocking(url, headers)
90
+ }
91
+
92
+ fun storeRouteSnapshotAsync(url: String, headers: Map<String, String> = emptyMap()) {
93
+ if (!isHttpUrl(url)) return
94
+ val key = normalizeSnapshotUrl(url)
95
+
96
+ val shouldFetch = synchronized(ongoingSnapshots) {
97
+ ongoingSnapshots.add(key)
98
+ }
99
+
100
+ if (!shouldFetch) {
101
+ if (BuildConfig.DEBUG) {
102
+ Log.d(FLOW_TAG, "SNAPSHOT skip reason=already-in-flight url=$key")
103
+ }
104
+ return
105
+ }
106
+
107
+ if (BuildConfig.DEBUG) {
108
+ Log.d(FLOW_TAG, "SNAPSHOT store-requested url=$key")
109
+ }
110
+
111
+ scope.launch {
112
+ try {
113
+ refreshManifestBlocking(url, headers)
114
+ if (!isOfflineRouteUrl(url)) {
115
+ if (BuildConfig.DEBUG) {
116
+ Log.d(FLOW_TAG, "SNAPSHOT skip reason=route-not-eligible url=$url manifestBuildId=${manifest?.buildId ?: "none"} routes=${manifest?.routes?.size ?: 0}")
117
+ }
118
+ return@launch
119
+ }
120
+
121
+ val file = snapshotFileForUrl(key)
122
+ if (isFreshSnapshot(file)) {
123
+ if (BuildConfig.DEBUG) {
124
+ Log.d(FLOW_TAG, "SNAPSHOT skip reason=fresh-existing ageMs=${System.currentTimeMillis() - file.lastModified()} bytes=${file.length()} url=$key path=${file.absolutePath}")
125
+ }
126
+ return@launch
127
+ }
128
+
129
+ val connection = URL(key).openConnection() as HttpURLConnection
130
+ connection.requestMethod = "GET"
131
+ connection.connectTimeout = 15000
132
+ connection.readTimeout = 15000
133
+ connection.setRequestProperty("X-Catalyst-Offline-Snapshot-Fetch", "1")
134
+ headers.forEach { (header, value) ->
135
+ if (shouldForwardHeader(header)) connection.setRequestProperty(header, value)
136
+ }
137
+ CookieManager.getInstance().getCookie(key)?.takeIf { it.isNotBlank() }?.let {
138
+ connection.setRequestProperty("Cookie", it)
139
+ }
140
+
141
+ connection.connect()
142
+ val contentType = connection.contentType ?: ""
143
+ if (BuildConfig.DEBUG) {
144
+ Log.d(FLOW_TAG, "SNAPSHOT fetch-response status=${connection.responseCode} contentType=$contentType url=$key")
145
+ }
146
+
147
+ if (connection.responseCode == HttpURLConnection.HTTP_OK &&
148
+ contentType.contains("text/html", ignoreCase = true)
149
+ ) {
150
+ val bytes = connection.inputStream.use { it.readBytes() }
151
+ if (bytes.isNotEmpty()) {
152
+ file.parentFile?.mkdirs()
153
+ FileOutputStream(file).use { it.write(bytes) }
154
+ if (BuildConfig.DEBUG) {
155
+ Log.d(TAG, "Stored route snapshot: $key")
156
+ Log.d(FLOW_TAG, "SNAPSHOT stored bytes=${bytes.size} url=$key path=${file.absolutePath}")
157
+ }
158
+ }
159
+ } else if (BuildConfig.DEBUG) {
160
+ Log.w(FLOW_TAG, "SNAPSHOT not-stored reason=non-html-or-non-200 status=${connection.responseCode} contentType=$contentType url=$key")
161
+ }
162
+ connection.disconnect()
163
+ } catch (e: Exception) {
164
+ if (BuildConfig.DEBUG) {
165
+ Log.w(TAG, "Unable to store route snapshot for $url: ${e.message}")
166
+ Log.w(FLOW_TAG, "SNAPSHOT error phase=store url=$url error=${e.message}", e)
167
+ }
168
+ } finally {
169
+ synchronized(ongoingSnapshots) {
170
+ ongoingSnapshots.remove(key)
171
+ }
172
+ }
173
+ }
174
+ }
175
+
176
+ fun getRouteSnapshotResponse(url: String): WebResourceResponse? {
177
+ if (!isOfflineRouteUrl(url)) {
178
+ if (BuildConfig.DEBUG) {
179
+ Log.d(FLOW_TAG, "SNAPSHOT miss reason=route-not-eligible url=$url")
180
+ }
181
+ return null
182
+ }
183
+ val file = snapshotFileForUrl(url)
184
+ if (!file.exists()) {
185
+ if (BuildConfig.DEBUG) {
186
+ Log.d(FLOW_TAG, "SNAPSHOT miss reason=file-missing url=$url path=${file.absolutePath}")
187
+ }
188
+ return null
189
+ }
190
+
191
+ if (BuildConfig.DEBUG) {
192
+ Log.d(FLOW_TAG, "SNAPSHOT hit bytes=${file.length()} url=$url path=${file.absolutePath}")
193
+ }
194
+ return WebResourceResponse(
195
+ "text/html",
196
+ "utf-8",
197
+ 200,
198
+ "OK",
199
+ mapOf("X-Catalyst-Offline-Snapshot" to "hit"),
200
+ BufferedInputStream(FileInputStream(file))
201
+ )
202
+ }
203
+
204
+ fun hasRouteSnapshot(url: String): Boolean {
205
+ val eligible = isOfflineRouteUrl(url)
206
+ val file = snapshotFileForUrl(url)
207
+ val exists = eligible && file.exists()
208
+ if (BuildConfig.DEBUG) {
209
+ Log.d(FLOW_TAG, "SNAPSHOT exists-check eligible=$eligible exists=$exists bytes=${if (file.exists()) file.length() else 0} url=$url path=${file.absolutePath}")
210
+ }
211
+ return exists
212
+ }
213
+
214
+ fun isOfflineRouteUrl(url: String): Boolean {
215
+ if (!isHttpUrl(url)) return false
216
+ val currentManifest = manifest ?: return false
217
+ val path = Uri.parse(url).path ?: "/"
218
+
219
+ val matchedRoute = currentManifest.routes.firstOrNull { route ->
220
+ try {
221
+ Regex(route.regex, RegexOption.IGNORE_CASE).matches(path)
222
+ } catch (_: Exception) {
223
+ false
224
+ }
225
+ }
226
+ if (BuildConfig.DEBUG) {
227
+ Log.d(FLOW_TAG, "ROUTE match=${matchedRoute != null} path=$path pattern=${matchedRoute?.pattern ?: "none"} regex=${matchedRoute?.regex ?: "none"} buildId=${currentManifest.buildId} url=$url")
228
+ }
229
+ return matchedRoute != null
230
+ }
231
+
232
+ fun shouldCacheAssetUrl(url: String): Boolean {
233
+ if (!isHttpUrl(url)) return false
234
+ val path = Uri.parse(url).path?.lowercase() ?: return false
235
+ return ASSET_EXTENSIONS.any { path.endsWith(it) }
236
+ }
237
+
238
+ fun clearAll() {
239
+ try {
240
+ deleteRecursively(rootDir)
241
+ rootDir.mkdirs()
242
+ routeRootDir.mkdirs()
243
+ manifest = null
244
+ if (BuildConfig.DEBUG) {
245
+ Log.d(TAG, "Offline route snapshots cleared")
246
+ }
247
+ } catch (e: Exception) {
248
+ Log.e(TAG, "Unable to clear offline cache: ${e.message}")
249
+ }
250
+ }
251
+
252
+ private fun refreshManifestBlocking(url: String, headers: Map<String, String>): OfflineManifest? {
253
+ return try {
254
+ val manifestUrl = manifestUrlFor(url)
255
+ val connection = URL(manifestUrl).openConnection() as HttpURLConnection
256
+ connection.requestMethod = "GET"
257
+ connection.connectTimeout = 10000
258
+ connection.readTimeout = 10000
259
+ headers.forEach { (header, value) ->
260
+ if (shouldForwardHeader(header)) connection.setRequestProperty(header, value)
261
+ }
262
+ CookieManager.getInstance().getCookie(manifestUrl)?.takeIf { it.isNotBlank() }?.let {
263
+ connection.setRequestProperty("Cookie", it)
264
+ }
265
+ connection.connect()
266
+
267
+ if (connection.responseCode != HttpURLConnection.HTTP_OK) {
268
+ if (BuildConfig.DEBUG) {
269
+ Log.w(FLOW_TAG, "MANIFEST refresh-response status=${connection.responseCode} url=$manifestUrl keepingBuildId=${manifest?.buildId ?: "none"}")
270
+ }
271
+ connection.disconnect()
272
+ return manifest
273
+ }
274
+
275
+ val body = connection.inputStream.use { it.readBytes().toString(Charsets.UTF_8) }
276
+ connection.disconnect()
277
+ val parsed = parseManifest(body)
278
+ manifestFile.writeText(body)
279
+ manifest = parsed
280
+ if (BuildConfig.DEBUG) {
281
+ Log.d(FLOW_TAG, "MANIFEST refreshed buildId=${parsed.buildId} routes=${parsed.routes.size} url=$manifestUrl")
282
+ parsed.routes.forEach { route ->
283
+ Log.d(FLOW_TAG, "MANIFEST route pattern=${route.pattern} regex=${route.regex}")
284
+ }
285
+ }
286
+ parsed
287
+ } catch (e: Exception) {
288
+ if (BuildConfig.DEBUG) {
289
+ Log.w(TAG, "Unable to refresh offline manifest: ${e.message}")
290
+ Log.w(FLOW_TAG, "MANIFEST refresh-error url=$url error=${e.message}", e)
291
+ }
292
+ manifest
293
+ }
294
+ }
295
+
296
+ private fun loadCachedManifest(): OfflineManifest? {
297
+ return try {
298
+ if (!manifestFile.exists()) return null
299
+ parseManifest(manifestFile.readText())
300
+ } catch (_: Exception) {
301
+ null
302
+ }
303
+ }
304
+
305
+ private fun parseManifest(raw: String): OfflineManifest {
306
+ val json = JSONObject(raw)
307
+ val buildId = json.optString("buildId", "unknown")
308
+ val routesJson = json.optJSONArray("routes")
309
+ val routes = mutableListOf<OfflineRoute>()
310
+ if (routesJson != null) {
311
+ for (index in 0 until routesJson.length()) {
312
+ val route = routesJson.optJSONObject(index) ?: continue
313
+ val pattern = route.optString("pattern")
314
+ val regex = route.optString("regex")
315
+ if (pattern.isNotBlank() && regex.isNotBlank()) {
316
+ routes.add(OfflineRoute(pattern, regex))
317
+ }
318
+ }
319
+ }
320
+
321
+ return OfflineManifest(buildId, routes)
322
+ }
323
+
324
+ private fun snapshotFileForUrl(url: String): File {
325
+ val normalizedUrl = normalizeSnapshotUrl(url)
326
+ val namespace = manifest?.let { "${originFor(normalizedUrl)}:${it.buildId}" } ?: originFor(normalizedUrl)
327
+ val namespaceDir = File(routeRootDir, hash(namespace))
328
+ return File(namespaceDir, "${hash(normalizedUrl)}.html")
329
+ }
330
+
331
+ private fun manifestUrlFor(url: String): String {
332
+ val uri = Uri.parse(url)
333
+ return "${uri.scheme}://${uri.authority}/catalyst-offline-manifest.json"
334
+ }
335
+
336
+ private fun originFor(url: String): String {
337
+ val uri = Uri.parse(url)
338
+ return "${uri.scheme}://${uri.authority}"
339
+ }
340
+
341
+ private fun normalizeSnapshotUrl(url: String): String {
342
+ val uri = Uri.parse(url)
343
+ return uri.buildUpon().fragment(null).build().toString()
344
+ }
345
+
346
+ private fun isHttpUrl(url: String): Boolean {
347
+ val scheme = Uri.parse(url).scheme?.lowercase()
348
+ return scheme == "http" || scheme == "https"
349
+ }
350
+
351
+ private fun shouldForwardHeader(header: String): Boolean {
352
+ return !header.equals("Cookie", ignoreCase = true) &&
353
+ !header.equals("Host", ignoreCase = true) &&
354
+ !header.equals("Cache-Control", ignoreCase = true) &&
355
+ !header.equals("Pragma", ignoreCase = true)
356
+ }
357
+
358
+ private fun isFreshSnapshot(file: File): Boolean {
359
+ return file.exists() && System.currentTimeMillis() - file.lastModified() < SNAPSHOT_MAX_AGE_MS
360
+ }
361
+
362
+ private fun hash(value: String): String {
363
+ val digest = MessageDigest.getInstance("SHA-256").digest(value.toByteArray())
364
+ return digest.joinToString("") { "%02x".format(it) }
365
+ }
366
+
367
+ private fun deleteRecursively(file: File) {
368
+ if (!file.exists()) return
369
+ if (file.isDirectory) {
370
+ file.listFiles()?.forEach { deleteRecursively(it) }
371
+ }
372
+ file.delete()
373
+ }
374
+ }
@@ -14,6 +14,7 @@ import java.util.concurrent.TimeUnit
14
14
 
15
15
  class WebCacheManager(private val context: Context, private val properties: java.util.Properties? = null) {
16
16
  private val TAG = "WebCacheManager"
17
+ private val FLOW_TAG = "CatalystOfflineFlow"
17
18
  private val cacheDir = File(context.cacheDir, "webview_cache")
18
19
  private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
19
20
  private val BUFFER_SIZE = 32 * 1024
@@ -51,18 +52,33 @@ class WebCacheManager(private val context: Context, private val properties: java
51
52
  }
52
53
  }
53
54
 
54
- data class CacheEntry(
55
- val response: WebResourceResponse,
55
+ private data class CacheEntry(
56
+ val data: ByteArray,
57
+ val mimeType: String,
58
+ val encoding: String,
59
+ val responseHeaders: Map<String, String> = emptyMap(),
56
60
  val timestamp: Long = System.currentTimeMillis(),
57
61
  val eTag: String? = null,
58
62
  val lastModified: String? = null
59
- )
63
+ ) {
64
+ fun toResponse(): WebResourceResponse {
65
+ return WebResourceResponse(
66
+ mimeType,
67
+ encoding,
68
+ 200,
69
+ "OK",
70
+ responseHeaders.ifEmpty { null },
71
+ ByteArrayInputStream(data)
72
+ )
73
+ }
74
+ }
60
75
 
61
76
  /**
62
77
  * Synchronous cache check - only returns cached content if immediately available
63
78
  * Does not perform network requests or background revalidation
64
79
  * Used by ServiceWorker to avoid blocking
65
80
  */
81
+ @Suppress("UNUSED_PARAMETER")
66
82
  fun getCachedResponseSync(url: String, headers: Map<String, String>): WebResourceResponse? {
67
83
  return try {
68
84
  val cacheKey = generateCacheKey(url)
@@ -76,8 +92,12 @@ class WebCacheManager(private val context: Context, private val properties: java
76
92
  if (age <= maxAge + staleWhileRevalidate) {
77
93
  if (BuildConfig.DEBUG) {
78
94
  Log.d(TAG, "✅ Serving from memory cache (sync): $url")
95
+ Log.d(FLOW_TAG, "ASSET cache-hit source=memory ageMs=$age url=$url")
79
96
  }
80
- return memoryCacheEntry.response
97
+ return memoryCacheEntry.toResponse()
98
+ }
99
+ if (BuildConfig.DEBUG) {
100
+ Log.d(FLOW_TAG, "ASSET cache-expired source=memory ageMs=$age url=$url")
81
101
  }
82
102
  }
83
103
 
@@ -88,20 +108,48 @@ class WebCacheManager(private val context: Context, private val properties: java
88
108
  if (fileAge <= maxAge + staleWhileRevalidate) {
89
109
  val metadata = loadMetadata(cacheKey)
90
110
  val response = createResponseFromCache(cacheFile, metadata)
111
+ memoryCache.put(cacheKey, createEntryFromCache(cacheFile, metadata, cacheFile.lastModified()))
91
112
  if (BuildConfig.DEBUG) {
92
113
  Log.d(TAG, "✅ Serving from disk cache (sync): $url")
114
+ Log.d(FLOW_TAG, "ASSET cache-hit source=disk ageMs=$fileAge bytes=${cacheFile.length()} mime=${metadata.mimeType} encoding=${metadata.encoding} url=$url")
93
115
  }
94
116
  return response
95
117
  }
118
+ if (BuildConfig.DEBUG) {
119
+ Log.d(FLOW_TAG, "ASSET cache-expired source=disk ageMs=$fileAge bytes=${cacheFile.length()} url=$url")
120
+ }
96
121
  }
97
122
 
98
123
  // No valid cache available
99
124
  if (BuildConfig.DEBUG) {
100
125
  Log.d(TAG, "❌ No valid cache available (sync): $url")
126
+ Log.d(FLOW_TAG, "ASSET cache-miss url=$url")
101
127
  }
102
128
  null
103
129
  } catch (e: Exception) {
104
130
  Log.e(TAG, "Error in getCachedResponseSync for URL: $url: ${e.message}")
131
+ if (BuildConfig.DEBUG) {
132
+ Log.e(FLOW_TAG, "ASSET cache-error phase=read url=$url error=${e.message}", e)
133
+ }
134
+ null
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Synchronous cache-or-network path for WebView interception.
140
+ *
141
+ * shouldInterceptRequest runs off the UI thread, so it is safe to block here.
142
+ * Returning the fetched response from this method guarantees that the same
143
+ * request which populated the cache also succeeds in the WebView.
144
+ */
145
+ fun getCachedResponseOrFetchSync(url: String, headers: Map<String, String>): WebResourceResponse? {
146
+ return try {
147
+ getCachedResponseSync(url, headers) ?: fetchAndCacheResourceBlocking(url, headers, generateCacheKey(url))
148
+ } catch (e: Exception) {
149
+ Log.e(TAG, "Error in getCachedResponseOrFetchSync for URL: $url: ${e.message}")
150
+ if (BuildConfig.DEBUG) {
151
+ Log.e(FLOW_TAG, "ASSET cache-error phase=cache-or-fetch url=$url error=${e.message}", e)
152
+ }
105
153
  null
106
154
  }
107
155
  }
@@ -123,7 +171,7 @@ class WebCacheManager(private val context: Context, private val properties: java
123
171
  if (BuildConfig.DEBUG) {
124
172
  Log.d(TAG, "Serving fresh content from memory cache: $url")
125
173
  }
126
- return@withContext memoryCacheEntry.response
174
+ return@withContext memoryCacheEntry.toResponse()
127
175
  }
128
176
  age <= maxAge + staleWhileRevalidate -> {
129
177
  // Stale content, but within revalidate window
@@ -131,7 +179,7 @@ class WebCacheManager(private val context: Context, private val properties: java
131
179
  Log.d(TAG, "Serving stale content while revalidating: $url")
132
180
  }
133
181
  revalidateInBackground(url, headers, cacheKey, memoryCacheEntry)
134
- return@withContext memoryCacheEntry.response
182
+ return@withContext memoryCacheEntry.toResponse()
135
183
  }
136
184
  }
137
185
  }
@@ -146,18 +194,13 @@ class WebCacheManager(private val context: Context, private val properties: java
146
194
  fileAge <= maxAge -> {
147
195
  // Fresh content from disk
148
196
  val response = createResponseFromCache(cacheFile, metadata)
149
- memoryCache.put(cacheKey, CacheEntry(response))
197
+ memoryCache.put(cacheKey, createEntryFromCache(cacheFile, metadata, cacheFile.lastModified()))
150
198
  return@withContext response
151
199
  }
152
200
  fileAge <= maxAge + staleWhileRevalidate -> {
153
201
  // Stale content from disk, revalidate in background
154
202
  val response = createResponseFromCache(cacheFile, metadata)
155
- val cacheEntry = CacheEntry(
156
- response = response,
157
- timestamp = cacheFile.lastModified(),
158
- eTag = metadata.eTag,
159
- lastModified = metadata.lastModified
160
- )
203
+ val cacheEntry = createEntryFromCache(cacheFile, metadata, cacheFile.lastModified())
161
204
  revalidateInBackground(url, headers, cacheKey, cacheEntry)
162
205
  return@withContext response
163
206
  }
@@ -168,7 +211,7 @@ class WebCacheManager(private val context: Context, private val properties: java
168
211
  if (BuildConfig.DEBUG) {
169
212
  Log.d(TAG, "❌ Cache miss, fetching fresh content: $url")
170
213
  }
171
- fetchAndCacheResource(url, headers, cacheKey)
214
+ fetchAndCacheResourceBlocking(url, headers, cacheKey)
172
215
  } catch (e: Exception) {
173
216
  Log.e(TAG, "Error in getCachedResponse for URL: $url: ${e.message}")
174
217
  if (BuildConfig.DEBUG) {
@@ -282,7 +325,7 @@ class WebCacheManager(private val context: Context, private val properties: java
282
325
  if (BuildConfig.DEBUG) {
283
326
  Log.d(TAG, "⚡ Content changed, updating cache: $url")
284
327
  }
285
- fetchAndCacheResource(url, headers, cacheKey)
328
+ fetchAndCacheResourceBlocking(url, headers, cacheKey)
286
329
  }
287
330
  }
288
331
  } catch (e: Exception) {
@@ -308,25 +351,32 @@ class WebCacheManager(private val context: Context, private val properties: java
308
351
  }
309
352
  }
310
353
 
311
- private suspend fun fetchAndCacheResource(
354
+ private fun fetchAndCacheResourceBlocking(
312
355
  url: String,
313
356
  headers: Map<String, String>,
314
357
  cacheKey: String
315
- ): WebResourceResponse? = withContext(Dispatchers.IO) {
358
+ ): WebResourceResponse? {
316
359
  var connection: HttpURLConnection? = null
317
360
  try {
318
361
  connection = URL(url).openConnection() as HttpURLConnection
319
362
  headers.forEach { (key, value) ->
320
- connection.setRequestProperty(key, value)
363
+ if (!key.equals("Accept-Encoding", ignoreCase = true)) {
364
+ connection.setRequestProperty(key, value)
365
+ }
321
366
  }
367
+ connection.setRequestProperty("Accept-Encoding", "identity")
322
368
 
323
369
  connection.connectTimeout = 15000
324
370
  connection.readTimeout = 15000
371
+ if (BuildConfig.DEBUG) {
372
+ Log.d(FLOW_TAG, "ASSET network-fetch start url=$url headers=${headers.keys.sorted()}")
373
+ }
325
374
  connection.connect()
326
375
 
327
376
  if (connection.responseCode == HttpURLConnection.HTTP_OK) {
328
- val mimeType = connection.contentType ?: "application/octet-stream"
329
- val encoding = connection.contentEncoding ?: "utf-8"
377
+ val contentType = connection.contentType ?: "application/octet-stream"
378
+ val mimeType = mimeTypeFromContentType(contentType)
379
+ val encoding = charsetFromContentType(contentType)
330
380
  val eTag = connection.getHeaderField("ETag")
331
381
  val lastModified = connection.getHeaderField("Last-Modified")
332
382
 
@@ -336,13 +386,17 @@ class WebCacheManager(private val context: Context, private val properties: java
336
386
  while (true) {
337
387
  val key = connection.getHeaderFieldKey(i) ?: break
338
388
  val value = connection.getHeaderField(i)
339
- if (key.isNotEmpty()) savedHeaders[key] = value
389
+ if (key.isNotEmpty() && shouldReplayHeader(key)) savedHeaders[key] = value
340
390
  i++
341
391
  }
392
+ savedHeaders["Content-Type"] = contentType
342
393
 
343
394
  val responseBytes = connection.inputStream.use { it.readBytes() }
344
395
  if (!isValidResponse(mimeType, responseBytes)) {
345
- return@withContext null
396
+ if (BuildConfig.DEBUG) {
397
+ Log.w(FLOW_TAG, "ASSET network-fetch invalid-response status=${connection.responseCode} contentType=$contentType bytes=${responseBytes.size} url=$url")
398
+ }
399
+ return null
346
400
  }
347
401
 
348
402
  // Create response for immediate use
@@ -350,7 +404,10 @@ class WebCacheManager(private val context: Context, private val properties: java
350
404
 
351
405
  // Cache the response
352
406
  val cacheEntry = CacheEntry(
353
- response = WebResourceResponse(mimeType, encoding, 200, "OK", savedHeaders, ByteArrayInputStream(responseBytes.copyOf())),
407
+ data = responseBytes.copyOf(),
408
+ mimeType = mimeType,
409
+ encoding = encoding,
410
+ responseHeaders = savedHeaders,
354
411
  eTag = eTag,
355
412
  lastModified = lastModified
356
413
  )
@@ -370,21 +427,34 @@ class WebCacheManager(private val context: Context, private val properties: java
370
427
  ))
371
428
  if (BuildConfig.DEBUG) {
372
429
  Log.d(TAG, "✅ Successfully cached response for: $url")
430
+ Log.d(FLOW_TAG, "ASSET stored bytes=${responseBytes.size} mime=$mimeType encoding=$encoding url=$url")
373
431
  }
374
432
  } catch (e: Exception) {
375
433
  Log.e(TAG, "Error saving to disk cache for URL: $url: ${e.message}")
434
+ if (BuildConfig.DEBUG) {
435
+ Log.e(FLOW_TAG, "ASSET store-error url=$url error=${e.message}", e)
436
+ }
376
437
  }
377
438
  }
378
439
 
379
- return@withContext response
440
+ if (BuildConfig.DEBUG) {
441
+ Log.d(FLOW_TAG, "ASSET network-fetch success status=${connection.responseCode} bytes=${responseBytes.size} contentType=$contentType replayMime=$mimeType replayEncoding=$encoding url=$url")
442
+ }
443
+ return response
444
+ }
445
+ if (BuildConfig.DEBUG) {
446
+ Log.w(FLOW_TAG, "ASSET network-fetch non-200 status=${connection.responseCode} contentType=${connection.contentType} url=$url")
380
447
  }
381
- return@withContext null
448
+ return null
382
449
  } catch (e: Exception) {
383
450
  Log.e(TAG, "Error fetching resource: ${e.message}")
451
+ if (BuildConfig.DEBUG) {
452
+ Log.e(FLOW_TAG, "ASSET network-fetch error url=$url error=${e.message}", e)
453
+ }
384
454
  if (BuildConfig.DEBUG) {
385
455
  e.printStackTrace()
386
456
  }
387
- return@withContext null
457
+ return null
388
458
  } finally {
389
459
  connection?.disconnect()
390
460
  }
@@ -409,6 +479,46 @@ class WebCacheManager(private val context: Context, private val properties: java
409
479
  )
410
480
  }
411
481
 
482
+ private fun mimeTypeFromContentType(contentType: String): String {
483
+ return contentType.substringBefore(";").trim().ifEmpty { "application/octet-stream" }
484
+ }
485
+
486
+ private fun charsetFromContentType(contentType: String): String {
487
+ val charsetPart = contentType
488
+ .split(";")
489
+ .map { it.trim() }
490
+ .firstOrNull { it.startsWith("charset=", ignoreCase = true) }
491
+ return charsetPart
492
+ ?.substringAfter("=")
493
+ ?.trim()
494
+ ?.trim('"')
495
+ ?.ifEmpty { null }
496
+ ?: "utf-8"
497
+ }
498
+
499
+ private fun shouldReplayHeader(header: String): Boolean {
500
+ return !header.equals("Content-Encoding", ignoreCase = true) &&
501
+ !header.equals("Transfer-Encoding", ignoreCase = true) &&
502
+ !header.equals("Content-Length", ignoreCase = true) &&
503
+ !header.equals("Connection", ignoreCase = true)
504
+ }
505
+
506
+ private fun createEntryFromCache(
507
+ cacheFile: File,
508
+ metadata: CacheMetadata,
509
+ timestamp: Long
510
+ ): CacheEntry {
511
+ return CacheEntry(
512
+ data = cacheFile.readBytes(),
513
+ mimeType = metadata.mimeType,
514
+ encoding = metadata.encoding,
515
+ responseHeaders = metadata.responseHeaders,
516
+ timestamp = timestamp,
517
+ eTag = metadata.eTag,
518
+ lastModified = metadata.lastModified
519
+ )
520
+ }
521
+
412
522
  private fun generateCacheKey(url: String): String {
413
523
  try {
414
524
  val md = MessageDigest.getInstance("SHA-256")