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.
- package/README.md +1 -1
- package/bin/catalyst.js +8 -1
- package/changelog.md +6 -0
- package/dist/native/androidProject/app/build.gradle.kts +34 -8
- package/dist/native/androidProject/app/src/main/AndroidManifest.xml +2 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/BridgeMessageValidator.kt +1 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +270 -78
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +34 -5
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeBridge.kt +149 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/NativeCameraManager.kt +337 -0
- 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/TransitionManager.kt +202 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/WebCacheManager.kt +156 -39
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/BarcodeDetector.kt +123 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/CameraSessionManager.kt +244 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/HoldController.kt +70 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/TorchController.kt +57 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/VideoStreamState.kt +41 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/VideoStreamStateMachine.kt +40 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/ViewfinderMapper.kt +75 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/camera/ZoomController.kt +156 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/CatalystPlugin.kt +7 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/GeneratedPluginIndex.kt +6 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/PluginBridge.kt +253 -0
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/security/FridaDetector.kt +16 -5
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/BridgeUtils.kt +42 -5
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/utils/FrameworkServerUtils.kt +7 -3
- package/dist/native/androidProject/app/src/main/res/drawable/debug_viewfinder_border.xml +8 -0
- package/dist/native/androidProject/app/src/main/res/layout/activity_main.xml +37 -0
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/CustomWebviewTest.kt +7 -2
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/plugins/PluginBridgeTest.kt +139 -0
- package/dist/native/androidSetup.js +1 -0
- package/dist/native/bridge/WebBridge.js +5 -5
- package/dist/native/bridge/constants/NativeInterfaces.js +9 -5
- package/dist/native/bridge/hooks/index.js +1 -0
- package/dist/native/bridge/hooks/useCamera.js +9 -0
- package/dist/native/bridge/hooks/useDataProtection.js +2 -0
- package/dist/native/bridge/hooks/useDeviceInfo.js +2 -0
- package/dist/native/bridge/hooks/useFilePicker.js +2 -0
- package/dist/native/bridge/hooks/useGoogleSignIn.js +1 -0
- package/dist/native/bridge/hooks/useHapticFeedback.js +1 -0
- package/dist/native/bridge/hooks/useIntent.js +1 -0
- package/dist/native/bridge/hooks/useNativeTransition.js +43 -0
- package/dist/native/bridge/hooks/useNetworkStatus.js +3 -0
- package/dist/native/bridge/hooks/useNotification.js +2 -0
- package/dist/native/bridge/hooks/useSafeArea.js +2 -0
- package/dist/native/bridge/hooks/useVideoStream.js +6 -0
- package/dist/native/bridge/hooks/utils.js +1 -0
- package/dist/native/bridge/hooks.js +1 -137
- package/dist/native/bridge/useBaseHook.js +12 -7
- package/dist/native/bridge/utils/NativeBridge.js +41 -6
- package/dist/native/buildAndroid/assets.js +52 -0
- package/dist/native/buildAndroid/build.js +19 -0
- package/dist/native/buildAndroid/config.js +1 -0
- package/dist/native/buildAndroid/index.js +1 -0
- package/dist/native/buildAppAndroid.js +2 -150
- package/dist/native/buildAppIos.js +2 -312
- package/dist/native/buildIos/assets.js +8 -0
- package/dist/native/buildIos/build.js +40 -0
- package/dist/native/buildIos/config.js +71 -0
- package/dist/native/buildIos/index.js +6 -0
- package/dist/native/buildIos/plugins.js +45 -0
- package/dist/native/internalPluginUtils.js +7 -0
- package/dist/native/iosnativeWebView/Package.resolved +36 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/BarcodeDetector.swift +116 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/CameraPreviewView.swift +58 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/CameraSessionManager.swift +308 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/HoldController.swift +63 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/NativeCameraManager.swift +194 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/TorchController.swift +60 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/VideoStreamState.swift +44 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/VideoStreamStateMachine.swift +60 -0
- package/dist/native/iosnativeWebView/Sources/Core/Camera/ZoomController.swift +130 -0
- package/dist/native/iosnativeWebView/Sources/Core/Constants/CatalystConstants.swift +11 -1
- package/dist/native/iosnativeWebView/Sources/Core/Constants/ConfigConstants.swift +2 -1
- package/dist/native/iosnativeWebView/Sources/Core/Plugins/CatalystPlugin.swift +5 -0
- package/dist/native/iosnativeWebView/Sources/Core/Plugins/GeneratedPluginIndex.swift +6 -0
- package/dist/native/iosnativeWebView/Sources/Core/Plugins/PluginBridge.swift +364 -0
- 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/BridgeMessageValidator.swift +4 -1
- package/dist/native/iosnativeWebView/Sources/Core/WebView/NativeBridge.swift +227 -4
- package/dist/native/iosnativeWebView/Sources/Core/WebView/TransitionManager.swift +169 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WeakScriptMessageHandler.swift +14 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebView.swift +53 -8
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebViewNavigationDelegate.swift +88 -4
- package/dist/native/iosnativeWebView/iosnativeWebView/ContentView.swift +30 -11
- package/dist/native/iosnativeWebView/iosnativeWebView/Info.plist +11 -17
- package/dist/native/iosnativeWebView/iosnativeWebView.xcodeproj/project.pbxproj +8 -2
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/BootTimingUtilityTests.swift +1 -1
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/ConfigMappingTests.swift +4 -2
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/PluginBridgeTests.swift +160 -0
- package/dist/native/plugin-bridge/PluginBridge.js +1 -0
- package/dist/native/pluginComposerAndroid.js +28 -0
- package/dist/native/pluginComposerIos.js +16 -0
- package/dist/native/renameAndroidProject.js +5 -5
- package/dist/native/setupEmulatorIos.js +6 -4
- package/dist/native/setupServer.js +2 -3
- package/dist/native/utils.js +4 -1
- package/dist/offline/catalyst-sw.js +1 -0
- package/dist/offline/registerServiceWorker.js +1 -0
- package/dist/otel.js +70 -2
- package/dist/router/ClientRouter.js +1 -1
- package/dist/scripts/build.js +1 -1
- package/dist/scripts/cleanCache.js +1 -0
- package/dist/scripts/devBuild.js +1 -1
- package/dist/scripts/devServe.js +1 -1
- package/dist/scripts/generateOfflineManifest.js +1 -0
- package/dist/scripts/loadEnvironmentVariables.js +1 -2
- package/dist/scripts/plugins.js +1 -0
- package/dist/scripts/registerAliases.js +6 -2
- package/dist/scripts/scriptUtils.js +2 -1
- package/dist/scripts/serve.js +2 -1
- package/dist/scripts/start.js +7 -2
- package/dist/server/expressServer.js +4 -8
- package/dist/server/renderer/document/Body.js +4 -4
- package/dist/server/renderer/document/Head.js +2 -2
- package/dist/server/renderer/extract.js +31 -27
- package/dist/server/renderer/handler.js +47 -24
- package/dist/server/renderer/index.js +1 -1
- package/dist/server/startServer.js +57 -7
- package/dist/server/utils/userAgentUtil.js +9 -3
- package/dist/server/utils/validator.js +1 -1
- package/dist/webpack/babel-plugins/remove-client.plugin.js +2 -2
- package/dist/webpack/babel-plugins/remove-ssr.plugin.js +2 -2
- package/dist/webpack/babel.config.client.js +1 -1
- package/dist/webpack/babel.config.ssr.js +1 -1
- package/dist/webpack/base.babel.js +7 -8
- package/dist/webpack/development.client.babel.js +7 -16
- package/dist/webpack/production.client.babel.js +1 -1
- package/dist/webpack/production.ssr.babel.js +5 -1
- package/mcp_v2/knowledge-base.json +206 -100
- package/mcp_v2/lib/helpers.js +42 -8
- package/mcp_v2/lib/seed.js +88 -0
- package/mcp_v2/mcp.js +150 -4
- package/mcp_v2/setup.js +30 -193
- package/mcp_v2/tools/build.js +1 -1
- package/mcp_v2/tools/config.js +16 -4
- package/mcp_v2/tools/conversion.js +11 -11
- package/mcp_v2/tools/github.js +1117 -0
- package/mcp_v2/tools/knowledge.js +16 -3
- package/mcp_v2/tools/sync.js +179 -10
- package/mcp_v2/tools/tasks.js +13 -4
- 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
|
+
}
|
package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/TransitionManager.kt
ADDED
|
@@ -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
|
+
}
|