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
@@ -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
+ }
@@ -0,0 +1,202 @@
1
+ package io.yourname.androidproject
2
+
3
+ import android.animation.Animator
4
+ import android.animation.AnimatorListenerAdapter
5
+ import android.animation.ValueAnimator
6
+ import android.app.Activity
7
+ import android.graphics.Bitmap
8
+ import android.graphics.Canvas
9
+ import android.os.Handler
10
+ import android.os.Looper
11
+ import android.util.Log
12
+ import android.view.View
13
+ import android.view.ViewGroup
14
+ import android.webkit.WebView
15
+ import android.widget.FrameLayout
16
+ import android.widget.ImageView
17
+ import io.yourname.androidproject.utils.BridgeUtils
18
+
19
+ /**
20
+ * Manages native page transitions using a snapshot-overlay pattern.
21
+ *
22
+ * Flow:
23
+ * startTransition() — captures a bitmap of the current WebView, places it as an
24
+ * ImageView overlay on top of the content, covering the skeleton
25
+ * rendered by the router while the new page loads.
26
+ * commitTransition() — animates the overlay out (slide or fade) to reveal the new page.
27
+ * cancelTransition() — removes the overlay immediately without animating.
28
+ *
29
+ * Safety timer: a Handler timer fires on the main thread after `timeout` ms if
30
+ * commitTransition() was never called (JS error/hang). It force-fades the overlay
31
+ * and fires ON_TRANSITION_TIMEOUT to JS so the hook can reset its state.
32
+ *
33
+ * Thread safety: all public methods must be called on the main thread.
34
+ */
35
+ class TransitionManager(
36
+ private val activity: Activity,
37
+ private val webView: WebView
38
+ ) {
39
+ private val TAG = "TransitionManager"
40
+ private val mainHandler = Handler(Looper.getMainLooper())
41
+
42
+ private var overlayView: ImageView? = null
43
+ private var safetyRunnable: Runnable? = null
44
+ private var activeDuration: Int = 300
45
+ private var activeType: String = "slide"
46
+ private var activeDirection: String = "left"
47
+
48
+ // -------------------------------------------------------------------------
49
+ // Public API
50
+ // -------------------------------------------------------------------------
51
+
52
+ fun startTransition(type: String, direction: String, duration: Int, timeout: Int) {
53
+ // Cancel any orphaned transition first
54
+ cancelTransitionInternal(notify = false)
55
+
56
+ activeType = type
57
+ activeDirection = direction
58
+ activeDuration = duration
59
+
60
+ val snapshot = captureSnapshot() ?: run {
61
+ Log.w(TAG, "startTransition — snapshot failed, transition skipped")
62
+ return
63
+ }
64
+
65
+ val overlay = ImageView(activity).apply {
66
+ setImageBitmap(snapshot)
67
+ layoutParams = ViewGroup.LayoutParams(
68
+ ViewGroup.LayoutParams.MATCH_PARENT,
69
+ ViewGroup.LayoutParams.MATCH_PARENT
70
+ )
71
+ scaleType = ImageView.ScaleType.MATRIX
72
+ }
73
+
74
+ val root = activity.window.decorView as ViewGroup
75
+ root.addView(overlay)
76
+ overlayView = overlay
77
+
78
+ Log.d(TAG, "startTransition — overlay attached (type=$type direction=$direction duration=${duration}ms timeout=${timeout}ms)")
79
+
80
+ // Arm safety timer
81
+ val safety = Runnable {
82
+ Log.w(TAG, "startTransition — safety timeout fired, force-removing overlay")
83
+ cancelTransitionInternal(notify = false)
84
+ BridgeUtils.notifyWebSuccess(webView, BridgeUtils.WebEvents.ON_TRANSITION_TIMEOUT)
85
+ }
86
+ safetyRunnable = safety
87
+ mainHandler.postDelayed(safety, timeout.toLong())
88
+ }
89
+
90
+ fun commitTransition() {
91
+ val overlay = overlayView ?: run {
92
+ Log.w(TAG, "commitTransition — no active overlay, ignoring")
93
+ return
94
+ }
95
+
96
+ // Disarm safety timer — we're committing normally
97
+ disarmSafetyTimer()
98
+
99
+ when (activeType) {
100
+ "fade" -> animateFadeOut(overlay, activeDuration)
101
+ else -> animateSlideOut(overlay, activeDuration, activeDirection)
102
+ }
103
+ }
104
+
105
+ fun cancelTransition() {
106
+ cancelTransitionInternal(notify = true)
107
+ }
108
+
109
+ fun cleanup() {
110
+ cancelTransitionInternal(notify = false)
111
+ }
112
+
113
+ // -------------------------------------------------------------------------
114
+ // Private helpers
115
+ // -------------------------------------------------------------------------
116
+
117
+ private fun captureSnapshot(): Bitmap? {
118
+ return try {
119
+ val bmp = Bitmap.createBitmap(webView.width, webView.height, Bitmap.Config.ARGB_8888)
120
+ val canvas = Canvas(bmp)
121
+ webView.draw(canvas)
122
+ bmp
123
+ } catch (e: Exception) {
124
+ Log.e(TAG, "captureSnapshot failed", e)
125
+ null
126
+ }
127
+ }
128
+
129
+ private fun animateSlideOut(overlay: ImageView, duration: Int, direction: String) {
130
+ val root = activity.window.decorView as ViewGroup
131
+ val w = root.width.toFloat()
132
+ val h = root.height.toFloat()
133
+
134
+ val (endX, endY) = when (direction) {
135
+ "right" -> Pair(-w, 0f)
136
+ "up" -> Pair(0f, h)
137
+ "down" -> Pair(0f, -h)
138
+ else -> Pair(w, 0f) // "left" — overlay slides out to the right
139
+ }
140
+
141
+ ValueAnimator.ofFloat(0f, 1f).apply {
142
+ this.duration = duration.toLong()
143
+ addUpdateListener { anim ->
144
+ val fraction = anim.animatedFraction
145
+ overlay.translationX = endX * fraction
146
+ overlay.translationY = endY * fraction
147
+ }
148
+ addListener(object : AnimatorListenerAdapter() {
149
+ override fun onAnimationEnd(animation: Animator) {
150
+ removeOverlay(overlay)
151
+ BridgeUtils.notifyWebSuccess(webView, BridgeUtils.WebEvents.ON_TRANSITION_COMMITTED)
152
+ }
153
+ })
154
+ start()
155
+ }
156
+ }
157
+
158
+ private fun animateFadeOut(overlay: ImageView, duration: Int) {
159
+ ValueAnimator.ofFloat(1f, 0f).apply {
160
+ this.duration = duration.toLong()
161
+ addUpdateListener { anim ->
162
+ overlay.alpha = anim.animatedValue as Float
163
+ }
164
+ addListener(object : AnimatorListenerAdapter() {
165
+ override fun onAnimationEnd(animation: Animator) {
166
+ removeOverlay(overlay)
167
+ BridgeUtils.notifyWebSuccess(webView, BridgeUtils.WebEvents.ON_TRANSITION_COMMITTED)
168
+ }
169
+ })
170
+ start()
171
+ }
172
+ }
173
+
174
+ private fun cancelTransitionInternal(notify: Boolean) {
175
+ disarmSafetyTimer()
176
+
177
+ overlayView?.let { overlay ->
178
+ removeOverlay(overlay)
179
+ if (notify) {
180
+ BridgeUtils.notifyWebSuccess(webView, BridgeUtils.WebEvents.ON_TRANSITION_CANCELLED)
181
+ }
182
+ }
183
+ }
184
+
185
+ private fun removeOverlay(overlay: ImageView) {
186
+ try {
187
+ val root = activity.window.decorView as ViewGroup
188
+ root.removeView(overlay)
189
+ } catch (e: Exception) {
190
+ Log.w(TAG, "removeOverlay — view already detached: ${e.message}")
191
+ }
192
+ if (overlayView === overlay) {
193
+ overlayView = null
194
+ }
195
+ overlay.setImageBitmap(null)
196
+ }
197
+
198
+ private fun disarmSafetyTimer() {
199
+ safetyRunnable?.let { mainHandler.removeCallbacks(it) }
200
+ safetyRunnable = null
201
+ }
202
+ }