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.
- package/dist/native/androidProject/app/build.gradle.kts +25 -8
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +239 -75
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +3 -2
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeBridge.kt +1 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/OfflineCacheService.kt +374 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/WebCacheManager.kt +136 -26
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/CustomWebviewTest.kt +7 -2
- package/dist/native/buildAndroid/assets.js +2 -2
- package/dist/native/iosnativeWebView/Sources/Core/Utils/CacheManager.swift +39 -4
- package/dist/native/iosnativeWebView/Sources/Core/Utils/OfflineCacheService.swift +683 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/OfflineURLSchemeHandler.swift +94 -0
- package/dist/native/iosnativeWebView/Sources/Core/Utils/ResourceURLProtocol.swift +19 -6
- package/dist/native/iosnativeWebView/Sources/Core/Utils/SafeAreaUtils.swift +1 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/BridgeCommandHandler.swift +1 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebView.swift +15 -1
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +82 -2
- package/dist/native/renameAndroidProject.js +2 -2
- package/dist/offline/catalyst-sw.js +1 -0
- package/dist/offline/registerServiceWorker.js +1 -0
- package/dist/router/ClientRouter.js +1 -1
- package/dist/scripts/build.js +1 -1
- package/dist/scripts/devBuild.js +1 -1
- package/dist/scripts/generateOfflineManifest.js +1 -0
- package/dist/server/expressServer.js +2 -1
- package/dist/server/renderer/document/Body.js +1 -1
- package/dist/server/renderer/handler.js +3 -3
- package/dist/webpack/base.babel.js +1 -1
- package/mcp_v2/knowledge-base.json +13 -0
- 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
|
+
}
|
package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/WebCacheManager.kt
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
354
|
+
private fun fetchAndCacheResourceBlocking(
|
|
312
355
|
url: String,
|
|
313
356
|
headers: Map<String, String>,
|
|
314
357
|
cacheKey: String
|
|
315
|
-
): WebResourceResponse?
|
|
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
|
-
|
|
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
|
|
329
|
-
val
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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")
|