catalyst-core-internal 0.1.2 → 0.1.3
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 +4 -4
- package/bin/catalyst.js +8 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/BridgeMessageValidator.kt +3 -11
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt +12 -1
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt +18 -3
- package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/plugins/CatalystPlugin.kt +5 -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 +240 -0
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/SecurityBridgeTest.kt +199 -0
- package/dist/native/androidProject/app/src/test/java/io/yourname/androidproject/plugins/PluginBridgeTest.kt +121 -0
- package/dist/native/bridge/hooks.js +4 -4
- package/dist/native/bridge/useBaseHook.js +5 -4
- package/dist/native/bridge/utils/NativeBridge.js +4 -4
- package/dist/native/buildAppAndroid.js +2 -2
- package/dist/native/buildAppIos.js +10 -17
- package/dist/native/internal-plugins/device-info-plugin/android/DeviceInfoPlugin.kt +43 -0
- package/dist/native/internal-plugins/device-info-plugin/ios/DeviceInfoPlugin.swift +28 -0
- package/dist/native/internal-plugins/device-info-plugin/manifest.json +19 -0
- package/dist/native/internalPluginUtils.js +1 -0
- 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 +13 -2
- package/dist/native/iosnativeWebView/Sources/Core/WebView/NativeBridge.swift +13 -2
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WeakScriptMessageHandler.swift +14 -0
- package/dist/native/iosnativeWebView/Sources/Core/WebView/WebView.swift +6 -0
- package/dist/native/iosnativeWebView/iosnativeWebView.xcodeproj/project.pbxproj +4 -0
- package/dist/native/iosnativeWebView/iosnativeWebView.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +36 -0
- package/dist/native/iosnativeWebView/iosnativeWebView.xctestplan +1 -0
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/BridgeCommandHandlerSecurityTests.swift +212 -0
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/FrameworkServerUtilsTests.swift +14 -4
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/PluginBridgeTests.swift +160 -0
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/ScreenSecureManagerTests.swift +121 -0
- package/dist/native/iosnativeWebView/iosnativeWebViewTests/WebViewTests.swift +9 -21
- package/dist/native/plugin-bridge/PluginBridge.js +1 -0
- package/dist/native/pluginComposerAndroid.js +9 -0
- package/dist/native/pluginComposerIos.js +7 -0
- package/dist/scripts/plugins.js +1 -0
- package/package.json +3 -2
- package/mcp_v2/conversion-tasks.json +0 -371
- package/mcp_v2/knowledge-base.json +0 -1450
- package/mcp_v2/lib/helpers.js +0 -145
- package/mcp_v2/mcp.js +0 -366
- package/mcp_v2/package.json +0 -13
- package/mcp_v2/schema.sql +0 -88
- package/mcp_v2/setup.js +0 -262
- package/mcp_v2/tools/build.js +0 -449
- package/mcp_v2/tools/config.js +0 -262
- package/mcp_v2/tools/conversion.js +0 -492
- package/mcp_v2/tools/debug.js +0 -62
- package/mcp_v2/tools/knowledge.js +0 -213
- package/mcp_v2/tools/sync.js +0 -21
- package/mcp_v2/tools/tasks.js +0 -844
package/README.md
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
## Table of Contents
|
|
9
9
|
|
|
10
|
-
-
|
|
11
|
-
-
|
|
10
|
+
- Overview
|
|
11
|
+
- Installation
|
|
12
12
|
|
|
13
13
|
## Overview
|
|
14
14
|
|
|
@@ -20,8 +20,8 @@ This version adds support for Universal app for both Android and iOS. Please che
|
|
|
20
20
|
|
|
21
21
|
**System Requirements**
|
|
22
22
|
|
|
23
|
-
-
|
|
24
|
-
-
|
|
23
|
+
- Node version 20.4.0 or later
|
|
24
|
+
- Compatible with **macOS** and **Linux**
|
|
25
25
|
|
|
26
26
|
**Automatic Installation**
|
|
27
27
|
|
package/bin/catalyst.js
CHANGED
|
@@ -13,6 +13,8 @@ const validCommands = [
|
|
|
13
13
|
"serve",
|
|
14
14
|
"devBuild",
|
|
15
15
|
"devServe",
|
|
16
|
+
"plugin",
|
|
17
|
+
"plugins",
|
|
16
18
|
"buildApp",
|
|
17
19
|
"buildApp:ios",
|
|
18
20
|
"buildApp:android",
|
|
@@ -67,11 +69,14 @@ const scriptIndex = args.findIndex(
|
|
|
67
69
|
x === "serve" ||
|
|
68
70
|
x === "devBuild" ||
|
|
69
71
|
x === "devServe" ||
|
|
72
|
+
x === "plugin" ||
|
|
73
|
+
x === "plugins" ||
|
|
70
74
|
isPlatformCommand(x, "buildApp") ||
|
|
71
75
|
isPlatformCommand(x, "setupEmulator")
|
|
72
76
|
)
|
|
73
77
|
const script = scriptIndex === -1 ? args[0] : args[scriptIndex]
|
|
74
78
|
const nodeArgs = scriptIndex > 0 ? args.slice(0, scriptIndex) : []
|
|
79
|
+
const resolvedScript = script === "plugin" ? "plugins" : script
|
|
75
80
|
|
|
76
81
|
if (validCommands.includes(script)) {
|
|
77
82
|
// Handle platform-specific or combined commands
|
|
@@ -90,7 +95,9 @@ if (validCommands.includes(script)) {
|
|
|
90
95
|
// Original commands
|
|
91
96
|
const result = spawnSync(
|
|
92
97
|
process.execPath,
|
|
93
|
-
nodeArgs
|
|
98
|
+
nodeArgs
|
|
99
|
+
.concat(require.resolve("../dist/scripts/" + resolvedScript))
|
|
100
|
+
.concat(args.slice(scriptIndex + 1)),
|
|
94
101
|
{ stdio: "inherit" }
|
|
95
102
|
)
|
|
96
103
|
handleProcessResult(result)
|
|
@@ -60,18 +60,10 @@ data class SchemaDefinition(
|
|
|
60
60
|
object BridgeMessageValidator {
|
|
61
61
|
|
|
62
62
|
private const val TAG = "BridgeMessageValidator"
|
|
63
|
-
private const val MAX_MESSAGE_SIZE =
|
|
63
|
+
private const val MAX_MESSAGE_SIZE = CatalystConstants.Bridge.MAX_MESSAGE_SIZE
|
|
64
64
|
|
|
65
|
-
// Valid commands
|
|
66
|
-
private val validCommands =
|
|
67
|
-
"openCamera",
|
|
68
|
-
"requestCameraPermission",
|
|
69
|
-
"pickFile",
|
|
70
|
-
"requestHapticFeedback",
|
|
71
|
-
"openFileWithIntent",
|
|
72
|
-
"getDeviceInfo",
|
|
73
|
-
"logger"
|
|
74
|
-
)
|
|
65
|
+
// Valid commands — single source of truth from CatalystConstants
|
|
66
|
+
private val validCommands = CatalystConstants.Bridge.VALID_COMMANDS
|
|
75
67
|
|
|
76
68
|
// MARK: - Schema Definitions (mirror iOS schemas)
|
|
77
69
|
|
package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/CustomWebview.kt
CHANGED
|
@@ -233,7 +233,7 @@ class CustomWebView(
|
|
|
233
233
|
}
|
|
234
234
|
|
|
235
235
|
// Additional security check: only allow whitelisted interface names
|
|
236
|
-
val allowedInterfaces = setOf("NativeBridge", "AndroidBridge")
|
|
236
|
+
val allowedInterfaces = setOf("NativeBridge", "AndroidBridge", "PluginBridge")
|
|
237
237
|
if (name !in allowedInterfaces) {
|
|
238
238
|
Log.e(TAG, "❌ Security: Interface name '$name' is not in whitelist. Refusing to add interface.")
|
|
239
239
|
return
|
|
@@ -246,6 +246,17 @@ class CustomWebView(
|
|
|
246
246
|
}
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
+
fun removeJavascriptInterface(name: String) {
|
|
250
|
+
try {
|
|
251
|
+
webView.removeJavascriptInterface(name)
|
|
252
|
+
if (BuildConfig.DEBUG) {
|
|
253
|
+
Log.d(TAG, "🔌 Removed JavaScript interface: $name")
|
|
254
|
+
}
|
|
255
|
+
} catch (e: Exception) {
|
|
256
|
+
Log.w(TAG, "⚠️ Failed to remove JavaScript interface '$name': ${e.message}")
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
249
260
|
fun destroy() {
|
|
250
261
|
job.cancel()
|
|
251
262
|
webView.destroy()
|
package/dist/native/androidProject/app/src/main/java/io/yourname/androidproject/MainActivity.kt
CHANGED
|
@@ -14,6 +14,7 @@ import org.json.JSONObject
|
|
|
14
14
|
import java.util.Properties
|
|
15
15
|
import io.yourname.androidproject.databinding.ActivityMainBinding
|
|
16
16
|
import io.yourname.androidproject.NativeBridge
|
|
17
|
+
import io.yourname.androidproject.plugins.PluginBridge
|
|
17
18
|
import io.yourname.androidproject.utils.BridgeUtils
|
|
18
19
|
import io.yourname.androidproject.utils.KeyboardUtil
|
|
19
20
|
import io.yourname.androidproject.utils.NetworkUtils
|
|
@@ -39,6 +40,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
|
|
|
39
40
|
|
|
40
41
|
private lateinit var binding: ActivityMainBinding
|
|
41
42
|
private lateinit var nativeBridge: NativeBridge
|
|
43
|
+
private lateinit var pluginBridge: PluginBridge
|
|
42
44
|
private lateinit var customWebView: CustomWebView
|
|
43
45
|
lateinit var properties: Properties
|
|
44
46
|
private lateinit var metricsMonitor: MetricsMonitor
|
|
@@ -328,6 +330,14 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
|
|
|
328
330
|
Log.e(TAG, "Failed to initialize NativeBridge: ${e.message}")
|
|
329
331
|
}
|
|
330
332
|
|
|
333
|
+
// Setup isolated PluginBridge
|
|
334
|
+
try {
|
|
335
|
+
pluginBridge = PluginBridge(this, customWebView.getWebView(), properties)
|
|
336
|
+
customWebView.addJavascriptInterface(pluginBridge, "PluginBridge")
|
|
337
|
+
} catch (e: Exception) {
|
|
338
|
+
Log.e(TAG, "Failed to initialize PluginBridge: ${e.message}")
|
|
339
|
+
}
|
|
340
|
+
|
|
331
341
|
setupSafeAreaHandling()
|
|
332
342
|
|
|
333
343
|
// Setup back press handler (modern API)
|
|
@@ -445,11 +455,16 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
|
|
|
445
455
|
if (::keyboardUtil.isInitialized) {
|
|
446
456
|
keyboardUtil.cleanup()
|
|
447
457
|
}
|
|
448
|
-
if (::
|
|
449
|
-
|
|
458
|
+
if (::customWebView.isInitialized) {
|
|
459
|
+
if (::pluginBridge.isInitialized) {
|
|
460
|
+
customWebView.removeJavascriptInterface("PluginBridge")
|
|
461
|
+
}
|
|
462
|
+
if (::nativeBridge.isInitialized) {
|
|
463
|
+
customWebView.removeJavascriptInterface("NativeBridge")
|
|
464
|
+
}
|
|
465
|
+
customWebView.destroy()
|
|
450
466
|
}
|
|
451
467
|
coroutineContext.cancelChildren()
|
|
452
|
-
customWebView.destroy()
|
|
453
468
|
super.onDestroy()
|
|
454
469
|
}
|
|
455
470
|
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
package io.yourname.androidproject.plugins
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.util.Log
|
|
6
|
+
import android.webkit.JavascriptInterface
|
|
7
|
+
import android.webkit.WebView
|
|
8
|
+
import io.yourname.androidproject.CatalystConstants
|
|
9
|
+
import org.json.JSONArray
|
|
10
|
+
import org.json.JSONException
|
|
11
|
+
import org.json.JSONObject
|
|
12
|
+
import java.util.Properties
|
|
13
|
+
|
|
14
|
+
internal data class PluginRequest(
|
|
15
|
+
val pluginId: String,
|
|
16
|
+
val command: String,
|
|
17
|
+
val data: Any?,
|
|
18
|
+
val requestId: String?
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
private class PluginBridgeRuntimeError(
|
|
22
|
+
override val message: String,
|
|
23
|
+
val code: String,
|
|
24
|
+
cause: Throwable? = null
|
|
25
|
+
) : Exception(message, cause)
|
|
26
|
+
|
|
27
|
+
class PluginBridge(
|
|
28
|
+
private val activity: Activity,
|
|
29
|
+
private val webView: WebView,
|
|
30
|
+
private val properties: Properties
|
|
31
|
+
) {
|
|
32
|
+
companion object {
|
|
33
|
+
private const val TAG = "PluginBridge"
|
|
34
|
+
private const val ERROR_EVENT = "PLUGIN_BRIDGE_ERROR"
|
|
35
|
+
private const val SYSTEM_PLUGIN_ID = "__bridge__"
|
|
36
|
+
private const val ERROR_CODE_INVALID_PAYLOAD = "INVALID_PAYLOAD"
|
|
37
|
+
private const val ERROR_CODE_PLUGIN_NOT_FOUND = "PLUGIN_NOT_FOUND"
|
|
38
|
+
private const val ERROR_CODE_COMMAND_NOT_SUPPORTED = "COMMAND_NOT_SUPPORTED"
|
|
39
|
+
private const val ERROR_CODE_PLUGIN_NOT_REGISTERED = "PLUGIN_NOT_REGISTERED"
|
|
40
|
+
private const val ERROR_CODE_PLUGIN_INSTANTIATION_FAILED = "PLUGIN_INSTANTIATION_FAILED"
|
|
41
|
+
private const val ERROR_CODE_PLUGIN_EXECUTION_FAILED = "PLUGIN_EXECUTION_FAILED"
|
|
42
|
+
|
|
43
|
+
private fun readRequiredString(body: JSONObject, key: String): String {
|
|
44
|
+
if (!body.has(key) || body.isNull(key)) {
|
|
45
|
+
return ""
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
val rawValue = body.get(key)
|
|
49
|
+
if (rawValue !is String) {
|
|
50
|
+
throw IllegalArgumentException("$key must be a string")
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return rawValue.trim()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private fun readOptionalString(body: JSONObject, key: String): String? {
|
|
57
|
+
if (!body.has(key) || body.isNull(key)) {
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
val rawValue = body.get(key)
|
|
62
|
+
if (rawValue !is String) {
|
|
63
|
+
throw IllegalArgumentException("$key must be a string when provided")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return rawValue.trim().ifEmpty { null }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
internal fun parseRequest(payload: String?): PluginRequest {
|
|
70
|
+
if (payload.isNullOrBlank()) {
|
|
71
|
+
throw IllegalArgumentException("Payload is required")
|
|
72
|
+
}
|
|
73
|
+
val messageSize = payload.toByteArray(Charsets.UTF_8).size
|
|
74
|
+
if (messageSize > CatalystConstants.Bridge.MAX_MESSAGE_SIZE) {
|
|
75
|
+
throw IllegalArgumentException("Payload exceeds maximum size")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
val body = JSONObject(payload)
|
|
79
|
+
return PluginRequest(
|
|
80
|
+
pluginId = readRequiredString(body, "pluginId"),
|
|
81
|
+
command = readRequiredString(body, "command"),
|
|
82
|
+
data = if (body.has("data") && !body.isNull("data")) body.get("data") else null,
|
|
83
|
+
requestId = readOptionalString(body, "requestId")
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private val pluginIdToClassName = GeneratedPluginIndex.pluginIdToClassName
|
|
89
|
+
private val pluginToCommands = GeneratedPluginIndex.pluginToCommands
|
|
90
|
+
|
|
91
|
+
@JavascriptInterface
|
|
92
|
+
fun emit(payload: String?) {
|
|
93
|
+
var request: PluginRequest? = null
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
request = parseRequest(payload)
|
|
97
|
+
|
|
98
|
+
if (request.pluginId.isEmpty()) {
|
|
99
|
+
sendBridgeError("pluginId is required", ERROR_CODE_INVALID_PAYLOAD, request)
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
if (request.command.isEmpty()) {
|
|
103
|
+
sendBridgeError("command is required", ERROR_CODE_INVALID_PAYLOAD, request)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!hasPlugin(request.pluginId)) {
|
|
108
|
+
sendBridgeError("Unsupported plugin: ${request.pluginId}", ERROR_CODE_PLUGIN_NOT_FOUND, request)
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!hasCommand(request.pluginId, request.command)) {
|
|
113
|
+
sendBridgeError(
|
|
114
|
+
"Unsupported command '${request.command}' for plugin '${request.pluginId}'",
|
|
115
|
+
ERROR_CODE_COMMAND_NOT_SUPPORTED,
|
|
116
|
+
request
|
|
117
|
+
)
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
val plugin = try {
|
|
122
|
+
getPluginForId(request.pluginId)
|
|
123
|
+
} catch (error: PluginBridgeRuntimeError) {
|
|
124
|
+
sendBridgeError(error.message, error.code, request)
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
val callbackContext = PluginBridgeContext(
|
|
129
|
+
activity = activity,
|
|
130
|
+
webView = webView,
|
|
131
|
+
properties = properties,
|
|
132
|
+
pluginId = request.pluginId,
|
|
133
|
+
command = request.command,
|
|
134
|
+
requestId = request.requestId
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
plugin.handle(request.command, request.data, callbackContext)
|
|
138
|
+
} catch (error: IllegalArgumentException) {
|
|
139
|
+
sendBridgeError(error.message ?: "Invalid payload", ERROR_CODE_INVALID_PAYLOAD, request)
|
|
140
|
+
} catch (error: JSONException) {
|
|
141
|
+
sendBridgeError("Invalid JSON payload: ${error.message}", ERROR_CODE_INVALID_PAYLOAD, request)
|
|
142
|
+
} catch (error: Exception) {
|
|
143
|
+
Log.e(TAG, "Plugin command failed for ${request?.pluginId ?: "<unknown>"}.${request?.command ?: "<unknown>"}", error)
|
|
144
|
+
sendBridgeError("Plugin execution failed: ${error.message}", ERROR_CODE_PLUGIN_EXECUTION_FAILED, request)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private fun sendBridgeError(message: String, code: String, request: PluginRequest?) {
|
|
149
|
+
PluginBridgeContext(
|
|
150
|
+
activity = activity,
|
|
151
|
+
webView = webView,
|
|
152
|
+
properties = properties,
|
|
153
|
+
pluginId = SYSTEM_PLUGIN_ID,
|
|
154
|
+
command = request?.command,
|
|
155
|
+
requestId = request?.requestId
|
|
156
|
+
).callback(
|
|
157
|
+
ERROR_EVENT,
|
|
158
|
+
JSONObject().apply {
|
|
159
|
+
put("message", message)
|
|
160
|
+
put("code", code)
|
|
161
|
+
put("pluginId", request?.pluginId ?: SYSTEM_PLUGIN_ID)
|
|
162
|
+
request?.command?.takeIf { it.isNotEmpty() }?.let { put("command", it) }
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private fun hasPlugin(pluginId: String): Boolean {
|
|
168
|
+
return pluginIdToClassName.containsKey(pluginId)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private fun hasCommand(pluginId: String, command: String): Boolean {
|
|
172
|
+
return pluginToCommands[pluginId]?.contains(command) ?: false
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private fun getPluginForId(pluginId: String): CatalystPlugin {
|
|
176
|
+
val className = pluginIdToClassName[pluginId]
|
|
177
|
+
?: throw PluginBridgeRuntimeError(
|
|
178
|
+
"No plugin registered for id: $pluginId",
|
|
179
|
+
ERROR_CODE_PLUGIN_NOT_REGISTERED
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
return try {
|
|
183
|
+
val clazz = Class.forName(className)
|
|
184
|
+
val instance = clazz.getDeclaredConstructor().newInstance()
|
|
185
|
+
instance as? CatalystPlugin
|
|
186
|
+
?: throw IllegalStateException("Plugin class '$className' must implement CatalystPlugin")
|
|
187
|
+
} catch (error: Exception) {
|
|
188
|
+
Log.e(TAG, "Failed to instantiate plugin class $className for plugin $pluginId", error)
|
|
189
|
+
throw PluginBridgeRuntimeError(
|
|
190
|
+
"Failed to instantiate plugin class '$className' for plugin '$pluginId': ${error.message ?: error.javaClass.simpleName}",
|
|
191
|
+
ERROR_CODE_PLUGIN_INSTANTIATION_FAILED,
|
|
192
|
+
error
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
class PluginBridgeContext(
|
|
199
|
+
val activity: Activity,
|
|
200
|
+
val webView: WebView,
|
|
201
|
+
val properties: Properties,
|
|
202
|
+
val pluginId: String,
|
|
203
|
+
val command: String?,
|
|
204
|
+
val requestId: String?
|
|
205
|
+
) {
|
|
206
|
+
val context: Context
|
|
207
|
+
get() = activity
|
|
208
|
+
|
|
209
|
+
fun callback(
|
|
210
|
+
eventName: String,
|
|
211
|
+
data: Any?,
|
|
212
|
+
command: String? = this.command
|
|
213
|
+
) {
|
|
214
|
+
require(eventName.isNotBlank()) { "Callback eventName is required" }
|
|
215
|
+
|
|
216
|
+
val pluginLiteral = JSONObject.quote(pluginId)
|
|
217
|
+
val eventLiteral = JSONObject.quote(eventName)
|
|
218
|
+
val dataLiteral = toJavaScriptLiteral(data)
|
|
219
|
+
val requestLiteral = requestId?.let(JSONObject::quote) ?: "null"
|
|
220
|
+
val commandLiteral = command?.takeIf { it.isNotBlank() }?.let(JSONObject::quote) ?: "null"
|
|
221
|
+
|
|
222
|
+
webView.post {
|
|
223
|
+
webView.evaluateJavascript(
|
|
224
|
+
"window.PluginBridgeWeb && window.PluginBridgeWeb.callback($pluginLiteral, $eventLiteral, $dataLiteral, $requestLiteral, $commandLiteral);",
|
|
225
|
+
null
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private fun toJavaScriptLiteral(value: Any?): String {
|
|
231
|
+
return when (value) {
|
|
232
|
+
null -> "null"
|
|
233
|
+
is JSONObject -> value.toString()
|
|
234
|
+
is JSONArray -> value.toString()
|
|
235
|
+
is Number, is Boolean -> value.toString()
|
|
236
|
+
is String -> JSONObject.quote(value)
|
|
237
|
+
else -> JSONObject.wrap(value)?.toString() ?: "null"
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
package io.yourname.androidproject
|
|
2
|
+
|
|
3
|
+
import org.json.JSONObject
|
|
4
|
+
import org.junit.Assert.*
|
|
5
|
+
import org.junit.Test
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Unit tests for security bridge command routing and JSON injection safety.
|
|
9
|
+
*
|
|
10
|
+
* Coverage:
|
|
11
|
+
* - setScreenSecure command routing (5 tests)
|
|
12
|
+
* - getScreenSecure command routing (2 tests)
|
|
13
|
+
* - clearWebData command routing (2 tests)
|
|
14
|
+
* - JSON injection safety in error responses (3 tests)
|
|
15
|
+
*
|
|
16
|
+
* Total: 12 tests
|
|
17
|
+
*
|
|
18
|
+
* Note: These tests operate on static parsing/validation logic and JSON data
|
|
19
|
+
* structures. Full lifecycle (FLAG_SECURE, CookieManager) is exercised in
|
|
20
|
+
* device integration tests; here we confirm command acceptance, param parsing,
|
|
21
|
+
* and that error payloads produced via JSONObject cannot carry injected content.
|
|
22
|
+
*/
|
|
23
|
+
class SecurityBridgeTest {
|
|
24
|
+
|
|
25
|
+
// ============================================================
|
|
26
|
+
// CATEGORY 1: setScreenSecure COMMAND ROUTING (5 tests)
|
|
27
|
+
// ============================================================
|
|
28
|
+
|
|
29
|
+
@Test
|
|
30
|
+
fun `test setScreenSecure command is accepted by validator`() {
|
|
31
|
+
val messageJson = """{"command": "setScreenSecure", "data": {"enable": true}}"""
|
|
32
|
+
|
|
33
|
+
val result = NativeBridge.parseAndValidateMessage(messageJson)
|
|
34
|
+
|
|
35
|
+
assertTrue("setScreenSecure should be a valid command", result.isValid)
|
|
36
|
+
assertEquals("setScreenSecure", result.command)
|
|
37
|
+
assertNull(result.error)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@Test
|
|
41
|
+
fun `test setScreenSecure enable true parsed from params`() {
|
|
42
|
+
val params = JSONObject().apply { put("enable", true) }
|
|
43
|
+
|
|
44
|
+
val enable = params.optBoolean("enable", false)
|
|
45
|
+
|
|
46
|
+
assertTrue("enable should be true", enable)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@Test
|
|
50
|
+
fun `test setScreenSecure enable false parsed from params`() {
|
|
51
|
+
val params = JSONObject().apply { put("enable", false) }
|
|
52
|
+
|
|
53
|
+
val enable = params.optBoolean("enable", true)
|
|
54
|
+
|
|
55
|
+
assertFalse("enable should be false", enable)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@Test
|
|
59
|
+
fun `test setScreenSecure defaults to true when params are malformed`() {
|
|
60
|
+
// NativeBridge.setScreenSecure falls back to true when JSON parse fails
|
|
61
|
+
val malformedParams = "not-json"
|
|
62
|
+
val enable = try {
|
|
63
|
+
JSONObject(malformedParams).optBoolean("enable", true)
|
|
64
|
+
} catch (e: Exception) {
|
|
65
|
+
true
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
assertTrue("Malformed params should default to enable=true", enable)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@Test
|
|
72
|
+
fun `test setScreenSecure success response shape`() {
|
|
73
|
+
val enable = true
|
|
74
|
+
val response = JSONObject().apply {
|
|
75
|
+
put("secure", enable)
|
|
76
|
+
put("success", true)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
assertTrue(response.getBoolean("secure"))
|
|
80
|
+
assertTrue(response.getBoolean("success"))
|
|
81
|
+
// Verify no extra fields bleed in
|
|
82
|
+
assertEquals(2, response.length())
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ============================================================
|
|
86
|
+
// CATEGORY 2: getScreenSecure COMMAND ROUTING (2 tests)
|
|
87
|
+
// ============================================================
|
|
88
|
+
|
|
89
|
+
@Test
|
|
90
|
+
fun `test getScreenSecure command is accepted by validator`() {
|
|
91
|
+
val messageJson = """{"command": "getScreenSecure", "data": {}}"""
|
|
92
|
+
|
|
93
|
+
val result = NativeBridge.parseAndValidateMessage(messageJson)
|
|
94
|
+
|
|
95
|
+
assertTrue("getScreenSecure should be a valid command", result.isValid)
|
|
96
|
+
assertEquals("getScreenSecure", result.command)
|
|
97
|
+
assertNull(result.error)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
@Test
|
|
101
|
+
fun `test getScreenSecure status response shape`() {
|
|
102
|
+
// Validate that the JSON payload produced by getScreenSecure is well-formed
|
|
103
|
+
val isSecure = false
|
|
104
|
+
val response = JSONObject().apply {
|
|
105
|
+
put("secure", isSecure)
|
|
106
|
+
put("success", true)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
assertFalse(response.getBoolean("secure"))
|
|
110
|
+
assertTrue(response.getBoolean("success"))
|
|
111
|
+
assertEquals(2, response.length())
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ============================================================
|
|
115
|
+
// CATEGORY 3: clearWebData COMMAND ROUTING (2 tests)
|
|
116
|
+
// ============================================================
|
|
117
|
+
|
|
118
|
+
@Test
|
|
119
|
+
fun `test clearWebData command is accepted by validator`() {
|
|
120
|
+
val messageJson = """{"command": "clearWebData", "data": {}}"""
|
|
121
|
+
|
|
122
|
+
val result = NativeBridge.parseAndValidateMessage(messageJson)
|
|
123
|
+
|
|
124
|
+
assertTrue("clearWebData should be a valid command", result.isValid)
|
|
125
|
+
assertEquals("clearWebData", result.command)
|
|
126
|
+
assertNull(result.error)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
@Test
|
|
130
|
+
fun `test clearWebData success response shape`() {
|
|
131
|
+
val cookiesRemoved = true
|
|
132
|
+
val response = JSONObject().apply {
|
|
133
|
+
put("success", true)
|
|
134
|
+
put("cookiesRemoved", cookiesRemoved)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
assertTrue(response.getBoolean("success"))
|
|
138
|
+
assertTrue(response.getBoolean("cookiesRemoved"))
|
|
139
|
+
assertEquals(2, response.length())
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ============================================================
|
|
143
|
+
// CATEGORY 4: JSON INJECTION SAFETY (3 tests)
|
|
144
|
+
// ============================================================
|
|
145
|
+
|
|
146
|
+
@Test
|
|
147
|
+
fun `test error message with injection characters is safe via JSONObject`() {
|
|
148
|
+
// Simulate an exception message that contains characters dangerous in naive JS
|
|
149
|
+
// string interpolation: double-quotes and backslashes.
|
|
150
|
+
// JSONObject must escape them so the serialized JSON stays valid and cannot
|
|
151
|
+
// break out of the surrounding JS string literal in notifyWebJson.
|
|
152
|
+
val injectionPayload = "failed\"; window[\"evil\"]=\"injected"
|
|
153
|
+
|
|
154
|
+
val response = JSONObject().apply {
|
|
155
|
+
put("success", false)
|
|
156
|
+
put("error", injectionPayload)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
val serialized = response.toString()
|
|
160
|
+
|
|
161
|
+
// The raw unescaped double-quote sequence that would break JS must not appear
|
|
162
|
+
assertFalse(
|
|
163
|
+
"Unescaped double-quote injection sequence must not appear in serialized JSON",
|
|
164
|
+
serialized.contains("\"evil\"")
|
|
165
|
+
)
|
|
166
|
+
// The value must survive a round-trip intact
|
|
167
|
+
assertEquals(injectionPayload, JSONObject(serialized).getString("error"))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
@Test
|
|
171
|
+
fun `test error message with backslash sequences is safe via JSONObject`() {
|
|
172
|
+
val backslashPayload = "err\\\"injected\\\""
|
|
173
|
+
|
|
174
|
+
val response = JSONObject().apply {
|
|
175
|
+
put("success", false)
|
|
176
|
+
put("error", backslashPayload)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Round-trip must survive without throwing
|
|
180
|
+
val roundTripped = JSONObject(response.toString()).getString("error")
|
|
181
|
+
assertEquals(backslashPayload, roundTripped)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
@Test
|
|
185
|
+
fun `test setScreenSecure and clearWebData commands are in VALID_COMMANDS whitelist`() {
|
|
186
|
+
assertTrue(
|
|
187
|
+
"setScreenSecure must be whitelisted",
|
|
188
|
+
CatalystConstants.Bridge.VALID_COMMANDS.contains("setScreenSecure")
|
|
189
|
+
)
|
|
190
|
+
assertTrue(
|
|
191
|
+
"getScreenSecure must be whitelisted",
|
|
192
|
+
CatalystConstants.Bridge.VALID_COMMANDS.contains("getScreenSecure")
|
|
193
|
+
)
|
|
194
|
+
assertTrue(
|
|
195
|
+
"clearWebData must be whitelisted",
|
|
196
|
+
CatalystConstants.Bridge.VALID_COMMANDS.contains("clearWebData")
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
}
|