@dynamic-labs-sdk/client 1.7.0 → 1.8.1
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/android/build.gradle +231 -0
- package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/android/gradle/wrapper/gradle-wrapper.properties +7 -0
- package/android/gradle.properties +8 -0
- package/android/gradlew +251 -0
- package/android/gradlew.bat +94 -0
- package/android/settings.gradle +30 -0
- package/android/src/main/java/xyz/dynamic/client/DynamicClientPackage.kt +52 -0
- package/android/src/main/java/xyz/dynamic/client/keychain/KeyStoreKeyManager.kt +147 -0
- package/android/src/main/java/xyz/dynamic/client/keychain/KeychainModule.kt +85 -0
- package/android/src/main/java/xyz/dynamic/client/manifest/ReactNativeManifestModule.kt +25 -0
- package/android/src/main/java/xyz/dynamic/client/webview/DynamicWebViewClient.kt +83 -0
- package/android/src/main/java/xyz/dynamic/client/webview/DynamicWebViewHost.kt +518 -0
- package/android/src/main/java/xyz/dynamic/client/webview/DynamicWebViewModule.kt +147 -0
- package/android/src/test/java/xyz/dynamic/client/keychain/KeyStoreKeyManagerTest.kt +288 -0
- package/android/src/test/java/xyz/dynamic/client/webview/DynamicWebViewClientTest.kt +196 -0
- package/android/src/test/java/xyz/dynamic/client/webview/DynamicWebViewHostOpenTest.kt +347 -0
- package/android/src/test/java/xyz/dynamic/client/webview/DynamicWebViewHostTest.kt +171 -0
- package/android/src/test/java/xyz/dynamic/client/webview/DynamicWebViewModuleTest.kt +191 -0
- package/dist/{InvalidParamError-Crwntjl7.native.esm.js → InvalidParamError-BKnC9rcJ.esm.js} +441 -10
- package/dist/InvalidParamError-BKnC9rcJ.esm.js.map +1 -0
- package/dist/{InvalidParamError-fQLJaGBW.esm.js → InvalidParamError-Btv8VGSa.native.esm.js} +3 -3
- package/dist/InvalidParamError-Btv8VGSa.native.esm.js.map +1 -0
- package/dist/{InvalidParamError-CTssOe86.cjs → InvalidParamError-DGBih-LJ.cjs} +550 -4
- package/dist/InvalidParamError-DGBih-LJ.cjs.map +1 -0
- package/dist/{NotWaasWalletAccountError-DF_S7gqo.esm.js → NotWaasWalletAccountError-CHr72wTH.esm.js} +3 -3
- package/dist/{NotWaasWalletAccountError-DF_S7gqo.esm.js.map → NotWaasWalletAccountError-CHr72wTH.esm.js.map} +1 -1
- package/dist/{NotWaasWalletAccountError-BJLGGYmU.native.esm.js → NotWaasWalletAccountError-CRmyVoB3.native.esm.js} +3 -3
- package/dist/{NotWaasWalletAccountError-BJLGGYmU.native.esm.js.map → NotWaasWalletAccountError-CRmyVoB3.native.esm.js.map} +1 -1
- package/dist/{NotWaasWalletAccountError-2RaDipZu.cjs → NotWaasWalletAccountError-i1FozGep.cjs} +3 -3
- package/dist/{NotWaasWalletAccountError-2RaDipZu.cjs.map → NotWaasWalletAccountError-i1FozGep.cjs.map} +1 -1
- package/dist/core.cjs +23 -23
- package/dist/core.cjs.map +1 -1
- package/dist/core.esm.js +5 -5
- package/dist/core.native.esm.js +7 -7
- package/dist/core.native.esm.js.map +1 -1
- package/dist/errors/WebViewLoadFailedError.d.ts +1 -1
- package/dist/errors/WebViewLoadFailedError.d.ts.map +1 -1
- package/dist/exports/index.d.ts +2 -0
- package/dist/exports/index.d.ts.map +1 -1
- package/dist/{getNetworkProviderFromNetworkId-C1p9Rrg3.cjs → getNetworkProviderFromNetworkId-BdgI7g5U.cjs} +11 -317
- package/dist/getNetworkProviderFromNetworkId-BdgI7g5U.cjs.map +1 -0
- package/dist/{getNetworkProviderFromNetworkId-BCSmILlH.native.esm.js → getNetworkProviderFromNetworkId-Bm41knUq.native.esm.js} +6 -6
- package/dist/getNetworkProviderFromNetworkId-Bm41knUq.native.esm.js.map +1 -0
- package/dist/{getNetworkProviderFromNetworkId-BWhaD23J.esm.js → getNetworkProviderFromNetworkId-CRuh5pgc.esm.js} +4 -242
- package/dist/getNetworkProviderFromNetworkId-CRuh5pgc.esm.js.map +1 -0
- package/dist/{getSignedSessionId-CxxYNC_8.cjs → getSignedSessionId-Cwmt4BkY.cjs} +3 -3
- package/dist/{getSignedSessionId-CxxYNC_8.cjs.map → getSignedSessionId-Cwmt4BkY.cjs.map} +1 -1
- package/dist/{getSignedSessionId-c7dS4PQ9.esm.js → getSignedSessionId-D2w_IRdt.esm.js} +3 -3
- package/dist/{getSignedSessionId-c7dS4PQ9.esm.js.map → getSignedSessionId-D2w_IRdt.esm.js.map} +1 -1
- package/dist/{getSignedSessionId-DPOXdh6y.native.esm.js → getSignedSessionId-S6CBWkOn.native.esm.js} +3 -3
- package/dist/{getSignedSessionId-DPOXdh6y.native.esm.js.map → getSignedSessionId-S6CBWkOn.native.esm.js.map} +1 -1
- package/dist/getVerifiedCredentialForWalletAccount-CDuP1kaI.cjs +347 -0
- package/dist/getVerifiedCredentialForWalletAccount-CDuP1kaI.cjs.map +1 -0
- package/dist/{getVerifiedCredentialForWalletAccount-Cahp763h.esm.js → getVerifiedCredentialForWalletAccount-D33r7Drd.native.esm.js} +2 -2
- package/dist/{getVerifiedCredentialForWalletAccount-DuNbORNW.native.esm.js.map → getVerifiedCredentialForWalletAccount-D33r7Drd.native.esm.js.map} +1 -1
- package/dist/getVerifiedCredentialForWalletAccount-QwfkKHfr.esm.js +269 -0
- package/dist/getVerifiedCredentialForWalletAccount-QwfkKHfr.esm.js.map +1 -0
- package/dist/index.cjs +67 -67
- package/dist/index.cjs.map +1 -1
- package/dist/index.esm.js +5 -5
- package/dist/index.esm.js.map +1 -1
- package/dist/index.native.esm.js +5 -5
- package/dist/index.native.esm.js.map +1 -1
- package/dist/isMfaRequiredForAction-5p3i98-Z.esm.js +318 -0
- package/dist/isMfaRequiredForAction-5p3i98-Z.esm.js.map +1 -0
- package/dist/{isMfaRequiredForAction-D8G5PBAd.native.esm.js → isMfaRequiredForAction-cmElIVof.native.esm.js} +2 -2
- package/dist/{isMfaRequiredForAction-D8G5PBAd.native.esm.js.map → isMfaRequiredForAction-cmElIVof.native.esm.js.map} +1 -1
- package/dist/isMfaRequiredForAction-z74isCQ7.cjs +405 -0
- package/dist/isMfaRequiredForAction-z74isCQ7.cjs.map +1 -0
- package/dist/modules/waas/createWaasClient/createWaasClient.d.ts.map +1 -1
- package/dist/modules/waas/createWaasClient/translateNativeOpenError.d.ts.map +1 -1
- package/dist/modules/wallets/walletProvider/events/onWalletProviderEvent/onWalletProviderEvent.d.ts +1 -2
- package/dist/modules/wallets/walletProvider/events/onWalletProviderEvent/onWalletProviderEvent.d.ts.map +1 -1
- package/dist/tsconfig.lib.tsbuildinfo +1 -1
- package/dist/waas.cjs +9 -9
- package/dist/waas.cjs.map +1 -1
- package/dist/waas.esm.js +3 -3
- package/dist/waas.native.esm.js +3 -3
- package/dist/waasCore.cjs +8 -5
- package/dist/waasCore.cjs.map +1 -1
- package/dist/waasCore.esm.js +8 -4
- package/dist/waasCore.esm.js.map +1 -1
- package/dist/waasCore.native.esm.js +9 -8
- package/dist/waasCore.native.esm.js.map +1 -1
- package/dynamic-labs-sdk-client.podspec +27 -0
- package/ios/DynamicClientKeychain.h +4 -0
- package/ios/DynamicClientKeychain.mm +101 -0
- package/ios/DynamicClientManifest.h +4 -0
- package/ios/DynamicClientManifest.mm +45 -0
- package/ios/DynamicClientWebView.h +5 -0
- package/ios/DynamicClientWebView.mm +143 -0
- package/ios/DynamicWebViewImpl.swift +603 -0
- package/ios/LiveSecKeyAPI.swift +86 -0
- package/ios/ReactNativeManifestImpl.swift +20 -0
- package/ios/SecKeyAPI.swift +57 -0
- package/ios/SecureEnclaveKeyManager.swift +191 -0
- package/package.json +14 -5
- package/react-native.config.cjs +14 -0
- package/src/turboModules/NativeDynamicWebView.native.spec.ts +61 -0
- package/src/turboModules/NativeDynamicWebView.ts +40 -0
- package/src/turboModules/NativeKeychain.native.spec.ts +47 -0
- package/src/turboModules/NativeKeychain.ts +19 -0
- package/src/turboModules/NativeReactNativeManifest.native.spec.ts +55 -0
- package/src/turboModules/NativeReactNativeManifest.ts +28 -0
- package/dist/InvalidParamError-CTssOe86.cjs.map +0 -1
- package/dist/InvalidParamError-Crwntjl7.native.esm.js.map +0 -1
- package/dist/InvalidParamError-fQLJaGBW.esm.js.map +0 -1
- package/dist/getNetworkProviderFromNetworkId-BCSmILlH.native.esm.js.map +0 -1
- package/dist/getNetworkProviderFromNetworkId-BWhaD23J.esm.js.map +0 -1
- package/dist/getNetworkProviderFromNetworkId-C1p9Rrg3.cjs.map +0 -1
- package/dist/getVerifiedCredentialForWalletAccount-Cahp763h.esm.js.map +0 -1
- package/dist/getVerifiedCredentialForWalletAccount-Di8dM5Wx.cjs +0 -887
- package/dist/getVerifiedCredentialForWalletAccount-Di8dM5Wx.cjs.map +0 -1
- package/dist/getVerifiedCredentialForWalletAccount-DuNbORNW.native.esm.js +0 -701
- package/dist/isMfaRequiredForAction-BjRvPrU6.esm.js +0 -79
- package/dist/isMfaRequiredForAction-BjRvPrU6.esm.js.map +0 -1
- package/dist/isMfaRequiredForAction-hlhj0qAC.cjs +0 -96
- package/dist/isMfaRequiredForAction-hlhj0qAC.cjs.map +0 -1
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
package xyz.dynamic.client.webview
|
|
2
|
+
|
|
3
|
+
import android.annotation.SuppressLint
|
|
4
|
+
import android.content.pm.ApplicationInfo
|
|
5
|
+
import android.net.Uri
|
|
6
|
+
import android.os.Message
|
|
7
|
+
import android.view.View
|
|
8
|
+
import android.view.ViewGroup
|
|
9
|
+
import android.webkit.WebChromeClient
|
|
10
|
+
import android.webkit.WebSettings
|
|
11
|
+
import android.webkit.WebView
|
|
12
|
+
import androidx.webkit.JavaScriptReplyProxy
|
|
13
|
+
import androidx.webkit.ProfileStore
|
|
14
|
+
import androidx.webkit.WebViewCompat
|
|
15
|
+
import androidx.webkit.WebViewFeature
|
|
16
|
+
import com.facebook.react.bridge.Arguments
|
|
17
|
+
import com.facebook.react.bridge.Promise
|
|
18
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
19
|
+
import com.facebook.react.bridge.ReadableMap
|
|
20
|
+
import com.facebook.react.bridge.WritableArray
|
|
21
|
+
import com.facebook.react.bridge.WritableMap
|
|
22
|
+
import org.json.JSONArray
|
|
23
|
+
import org.json.JSONObject
|
|
24
|
+
|
|
25
|
+
// Glue injected into the hosted page before its own scripts run. The page is
|
|
26
|
+
// written for the browser iframe transport: it calls window.parent.postMessage
|
|
27
|
+
// to reach the SDK host and listens via window.addEventListener('message', ...).
|
|
28
|
+
// In a top-level WebView there is no real parent, so we:
|
|
29
|
+
// 1. Override window.postMessage to forward the page's outbound payload to
|
|
30
|
+
// native through window.dynamicBridge.postMessage — the JS object injected
|
|
31
|
+
// by androidx.webkit's WebMessageListener (origin-scoped, string-only).
|
|
32
|
+
// 2. Wire window.dynamicBridge.onmessage (fired by JavaScriptReplyProxy on the
|
|
33
|
+
// native side) to re-dispatch a synthetic MessageEvent so the page's
|
|
34
|
+
// existing 'message' listener fires exactly as in a browser iframe.
|
|
35
|
+
// Mirrors bridgeUserScript in ios/DynamicWebViewImpl.swift.
|
|
36
|
+
internal const val BRIDGE_USER_SCRIPT = """
|
|
37
|
+
(function () {
|
|
38
|
+
if (window.__dynamicBridgeInstalled) { return; }
|
|
39
|
+
window.__dynamicBridgeInstalled = true;
|
|
40
|
+
|
|
41
|
+
var post = function (data) {
|
|
42
|
+
try {
|
|
43
|
+
var json = typeof data === 'string' ? data : JSON.stringify(data);
|
|
44
|
+
// Injected by the native WebMessageListener for the pinned origin; present
|
|
45
|
+
// by the time the page posts.
|
|
46
|
+
window.dynamicBridge.postMessage(json);
|
|
47
|
+
} catch (e) {}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
Object.defineProperty(window, 'postMessage', {
|
|
52
|
+
configurable: true,
|
|
53
|
+
writable: true,
|
|
54
|
+
value: function (data, targetOrigin, transfer) { post(data); }
|
|
55
|
+
});
|
|
56
|
+
} catch (e) {
|
|
57
|
+
try { window.postMessage = function (d) { post(d); }; } catch (_) {}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
var deliver = function (event) {
|
|
61
|
+
try {
|
|
62
|
+
var payload = JSON.parse(event.data);
|
|
63
|
+
window.dispatchEvent(new MessageEvent('message', {
|
|
64
|
+
data: payload,
|
|
65
|
+
origin: window.location.origin,
|
|
66
|
+
source: window
|
|
67
|
+
}));
|
|
68
|
+
} catch (e) {}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// window.dynamicBridge is normally present at document start; retry briefly if
|
|
72
|
+
// the listener object has not been injected yet.
|
|
73
|
+
var wire = function () {
|
|
74
|
+
if (!window.dynamicBridge) { return false; }
|
|
75
|
+
window.dynamicBridge.onmessage = deliver;
|
|
76
|
+
return true;
|
|
77
|
+
};
|
|
78
|
+
if (!wire()) {
|
|
79
|
+
var tries = 0;
|
|
80
|
+
var timer = setInterval(function () {
|
|
81
|
+
if (wire() || ++tries > 50) { clearInterval(timer); }
|
|
82
|
+
}, 10);
|
|
83
|
+
}
|
|
84
|
+
})();
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
private const val BRIDGE_INTERFACE_NAME = "dynamicBridge"
|
|
88
|
+
|
|
89
|
+
// Prefix for the per-instance androidx.webkit profile that gives each WebView an
|
|
90
|
+
// isolated, ephemeral cookie/storage store (deleted on teardown).
|
|
91
|
+
private const val PROFILE_NAME_PREFIX = "dynamic-waas-"
|
|
92
|
+
|
|
93
|
+
// userInfo keys on the Promise rejection — JS reads these via
|
|
94
|
+
// `rejection.userInfo.DynamicWebViewStatusCode` / `DynamicWebViewUnderlying`
|
|
95
|
+
// in createWaasSDKContainer.native.ts. Must stay in sync with the
|
|
96
|
+
// equivalent constants in ios/DynamicWebViewImpl.swift.
|
|
97
|
+
private const val USER_INFO_STATUS_CODE_KEY = "DynamicWebViewStatusCode"
|
|
98
|
+
private const val USER_INFO_UNDERLYING_KEY = "DynamicWebViewUnderlying"
|
|
99
|
+
|
|
100
|
+
internal class DynamicWebViewHost(
|
|
101
|
+
private val reactContext: ReactApplicationContext,
|
|
102
|
+
private val instanceId: String,
|
|
103
|
+
private val onMessage: (Any?) -> Unit,
|
|
104
|
+
private val onRelease: () -> Unit,
|
|
105
|
+
) {
|
|
106
|
+
private var webView: WebView? = null
|
|
107
|
+
private var pendingPromise: Promise? = null
|
|
108
|
+
|
|
109
|
+
// The https origin (scheme://host[:port]) of the URL passed to open(). The
|
|
110
|
+
// WebView is pinned to it: the JS bridge is scoped to it and navigation off
|
|
111
|
+
// it is blocked. Null until open() validates the URL. Read by
|
|
112
|
+
// DynamicWebViewClient for the navigation pin.
|
|
113
|
+
internal var expectedOrigin: String? = null
|
|
114
|
+
private set
|
|
115
|
+
|
|
116
|
+
// The native -> page reply channel, captured from the first WebMessageListener
|
|
117
|
+
// callback (the page posts the iframe-ready handshake before the host sends).
|
|
118
|
+
@Volatile
|
|
119
|
+
private var replyProxy: JavaScriptReplyProxy? = null
|
|
120
|
+
|
|
121
|
+
// Guards replyProxy capture + pendingOutbound so a host->page send racing the
|
|
122
|
+
// channel-open callback is either buffered or sent, never lost.
|
|
123
|
+
private val outboundLock = Any()
|
|
124
|
+
|
|
125
|
+
// Host->page messages sent before the reply channel opened. The page normally
|
|
126
|
+
// posts first (the iframe-ready handshake) so this stays empty, but if the
|
|
127
|
+
// host ever sends first the messages are buffered here and flushed in order
|
|
128
|
+
// once replyProxy arrives — mirroring the iOS .mm pre-listener buffer.
|
|
129
|
+
private val pendingOutbound = mutableListOf<String>()
|
|
130
|
+
|
|
131
|
+
// Name of this instance's ephemeral profile, if MULTI_PROFILE was available.
|
|
132
|
+
private var profileName: String? = null
|
|
133
|
+
|
|
134
|
+
// Touched from the main thread today; @Volatile documents the
|
|
135
|
+
// main-thread-only invariant and guards the off-thread bridge callback.
|
|
136
|
+
@Volatile
|
|
137
|
+
private var released = false
|
|
138
|
+
|
|
139
|
+
// String-safe, origin-scoped JS->native channel. allowedOriginRules (set at
|
|
140
|
+
// registration) restricts the source origin; isMainFrame is belt-and-suspenders
|
|
141
|
+
// (the WaaS page has no subframes). The string crosses verbatim — no JS-source
|
|
142
|
+
// interpolation, so none of the escaping pitfalls of evaluateJavascript.
|
|
143
|
+
private val webMessageListener =
|
|
144
|
+
WebViewCompat.WebMessageListener { _, message, _, isMainFrame, proxy ->
|
|
145
|
+
if (!isMainFrame) return@WebMessageListener
|
|
146
|
+
// Capture the reply channel and drain anything the host queued before
|
|
147
|
+
// it opened, in order, before delivering this inbound message.
|
|
148
|
+
val toFlush: List<String>
|
|
149
|
+
synchronized(outboundLock) {
|
|
150
|
+
replyProxy = proxy
|
|
151
|
+
toFlush = pendingOutbound.toList()
|
|
152
|
+
pendingOutbound.clear()
|
|
153
|
+
}
|
|
154
|
+
toFlush.forEach { proxy.postMessage(it) }
|
|
155
|
+
message.data?.let { onBridgeMessage(it) }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
@SuppressLint("SetJavaScriptEnabled")
|
|
159
|
+
fun open(url: String, promise: Promise) {
|
|
160
|
+
val activity = reactContext.currentActivity
|
|
161
|
+
if (activity == null) {
|
|
162
|
+
rejectHostMissing(promise)
|
|
163
|
+
release()
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Reject non-https schemes (file://, javascript:, content://, http://,
|
|
168
|
+
// about:, etc.) and a missing host. The WebView hosts WaaS/MPC content
|
|
169
|
+
// and its bridge grants the page reach into the SDK, so it must never
|
|
170
|
+
// load attacker-controlled local files or pseudo-URL handlers. Mirrors
|
|
171
|
+
// the iOS guard in DynamicWebViewImpl.swift.
|
|
172
|
+
val parsed = runCatching { Uri.parse(url) }.getOrNull()
|
|
173
|
+
if (parsed == null ||
|
|
174
|
+
!"https".equals(parsed.scheme, ignoreCase = true) ||
|
|
175
|
+
parsed.host.isNullOrEmpty()
|
|
176
|
+
) {
|
|
177
|
+
rejectInvalidUrl(promise, url)
|
|
178
|
+
release()
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
// Pin this instance to the validated origin (navigation + bridge scope).
|
|
182
|
+
val origin = originOf(parsed)
|
|
183
|
+
expectedOrigin = origin
|
|
184
|
+
|
|
185
|
+
// The bridge requires androidx.webkit's WebMessageListener (a string-safe,
|
|
186
|
+
// origin-scoped JS<->native channel). Fail closed on a System WebView too
|
|
187
|
+
// old to support it rather than fall back to a weaker, unscoped bridge —
|
|
188
|
+
// an MPC ceremony should not run on such a WebView.
|
|
189
|
+
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
|
|
190
|
+
rejectUnsupported(promise)
|
|
191
|
+
release()
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Enables Chrome DevTools inspection in debuggable builds. Inspect at
|
|
196
|
+
// chrome://inspect/#devices on a connected host. Mirrors iOS's
|
|
197
|
+
// `webView.isInspectable` DEBUG branch.
|
|
198
|
+
if ((activity.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0) {
|
|
199
|
+
WebView.setWebContentsDebuggingEnabled(true)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
val web = WebView(activity)
|
|
203
|
+
|
|
204
|
+
// Per-instance ephemeral storage where supported (matches iOS's
|
|
205
|
+
// .nonPersistent() store). Must be set before the WebView is used, and is
|
|
206
|
+
// concurrency-safe: each instance gets its own named profile (deleted on
|
|
207
|
+
// teardown), so clearing one never disturbs a sibling ceremony. On older
|
|
208
|
+
// WebView (no MULTI_PROFILE) this is skipped and the shared default store
|
|
209
|
+
// is used — documented, best-effort.
|
|
210
|
+
if (WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE)) {
|
|
211
|
+
runCatching {
|
|
212
|
+
val name = "$PROFILE_NAME_PREFIX$instanceId"
|
|
213
|
+
ProfileStore.getInstance().getOrCreateProfile(name)
|
|
214
|
+
WebViewCompat.setProfile(web, name)
|
|
215
|
+
profileName = name
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
web.apply {
|
|
220
|
+
settings.javaScriptEnabled = true
|
|
221
|
+
settings.domStorageEnabled = true
|
|
222
|
+
// Secure-by-default parity with react-native-webview: the MPC page
|
|
223
|
+
// needs none of local-file / content:// / mixed-content access and
|
|
224
|
+
// must not be able to spawn windows. Shrinks the bridge's blast radius.
|
|
225
|
+
settings.allowFileAccess = false
|
|
226
|
+
settings.allowContentAccess = false
|
|
227
|
+
@Suppress("DEPRECATION")
|
|
228
|
+
settings.allowFileAccessFromFileURLs = false
|
|
229
|
+
@Suppress("DEPRECATION")
|
|
230
|
+
settings.allowUniversalAccessFromFileURLs = false
|
|
231
|
+
settings.mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
|
|
232
|
+
settings.javaScriptCanOpenWindowsAutomatically = false
|
|
233
|
+
// Counter-intuitively, multi-window support is ENABLED so that the
|
|
234
|
+
// onCreateWindow override below is actually invoked (it never fires
|
|
235
|
+
// when multiple-windows is off). This matches react-native-webview's
|
|
236
|
+
// mitigation for CVE-2020-6506: with multi-window off, a window
|
|
237
|
+
// request from a sandboxed iframe navigates the TOP frame instead of
|
|
238
|
+
// being contained, allowing a sandbox escape. We enable it and then
|
|
239
|
+
// deny every window request, which both contains iframes and blocks
|
|
240
|
+
// popups / target=_blank / window.open.
|
|
241
|
+
settings.setSupportMultipleWindows(true)
|
|
242
|
+
visibility = View.INVISIBLE
|
|
243
|
+
isEnabled = false
|
|
244
|
+
webViewClient = DynamicWebViewClient(host = this@DynamicWebViewHost)
|
|
245
|
+
// Deny window creation (target=_blank / window.open) outright.
|
|
246
|
+
webChromeClient = object : WebChromeClient() {
|
|
247
|
+
override fun onCreateWindow(
|
|
248
|
+
view: WebView?,
|
|
249
|
+
isDialog: Boolean,
|
|
250
|
+
isUserGesture: Boolean,
|
|
251
|
+
resultMsg: Message?,
|
|
252
|
+
): Boolean = false
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Origin-scoped, main-frame-aware bridge. Replaces addJavascriptInterface
|
|
257
|
+
// (exposed to every frame, blind to the caller). allowedOriginRules pins
|
|
258
|
+
// the channel to the WaaS origin.
|
|
259
|
+
WebViewCompat.addWebMessageListener(
|
|
260
|
+
web,
|
|
261
|
+
BRIDGE_INTERFACE_NAME,
|
|
262
|
+
setOf(origin),
|
|
263
|
+
webMessageListener,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
// Install the postMessage-override + onmessage glue before any page script
|
|
267
|
+
// runs, scoped to the pinned origin. Falls back to onPageStarted injection
|
|
268
|
+
// (DynamicWebViewClient) on WebView too old for document-start scripts.
|
|
269
|
+
if (WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
|
|
270
|
+
WebViewCompat.addDocumentStartJavaScript(
|
|
271
|
+
web,
|
|
272
|
+
BRIDGE_USER_SCRIPT,
|
|
273
|
+
setOf(origin),
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
val container = activity.window.decorView as ViewGroup
|
|
278
|
+
// 1x1 with INVISIBLE to keep the WebContent renderer scheduled while
|
|
279
|
+
// remaining unrendered. View.GONE skips layout and can starve the
|
|
280
|
+
// renderer process during long-running MPC ceremonies.
|
|
281
|
+
container.addView(web, ViewGroup.LayoutParams(1, 1))
|
|
282
|
+
|
|
283
|
+
webView = web
|
|
284
|
+
pendingPromise = promise
|
|
285
|
+
|
|
286
|
+
web.loadUrl(url)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
fun sendMessage(data: ReadableMap) {
|
|
290
|
+
// The page normally opens the reply channel by posting first (the
|
|
291
|
+
// iframe-ready handshake), so replyProxy is usually set before the host
|
|
292
|
+
// sends transport messages. If the host sends first, buffer the message
|
|
293
|
+
// until the channel opens rather than dropping it (which would stall the
|
|
294
|
+
// ceremony to the 20s handshake timeout). JavaScriptReplyProxy.postMessage
|
|
295
|
+
// takes the string verbatim — no JS-source interpolation, so none of the
|
|
296
|
+
// escaping pitfalls (U+2028/U+2029, quote/backslash breakouts) of
|
|
297
|
+
// evaluateJavascript.
|
|
298
|
+
val json = readableMapToJsonString(data)
|
|
299
|
+
synchronized(outboundLock) {
|
|
300
|
+
val proxy = replyProxy
|
|
301
|
+
if (proxy == null) {
|
|
302
|
+
pendingOutbound.add(json)
|
|
303
|
+
return
|
|
304
|
+
}
|
|
305
|
+
proxy.postMessage(json)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
fun close() {
|
|
310
|
+
// A close while `open` is pending means the caller intentionally
|
|
311
|
+
// aborted the load. Reject with `cancelled` so the awaiter doesn't
|
|
312
|
+
// hang. The JS wrapper swallows `cancelled` in onError since it is
|
|
313
|
+
// caused by our own teardown.
|
|
314
|
+
pendingPromise?.let {
|
|
315
|
+
pendingPromise = null
|
|
316
|
+
rejectCancelled(it)
|
|
317
|
+
}
|
|
318
|
+
teardown()
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
internal fun onCommitVisible() {
|
|
322
|
+
// Defer the resolve one UI-queue turn: on a 4xx/5xx the WebView fires both
|
|
323
|
+
// onPageCommitVisible (this) and onReceivedHttpError with no guaranteed
|
|
324
|
+
// order. Deferring lets a same-pass onHttpError claim the pending promise
|
|
325
|
+
// first, so an error page is reported as http_error instead of a spurious
|
|
326
|
+
// success. The 20s WaaS handshake timeout backstops any residual edge.
|
|
327
|
+
reactContext.runOnUiQueueThread {
|
|
328
|
+
pendingPromise?.let {
|
|
329
|
+
pendingPromise = null
|
|
330
|
+
it.resolve(null)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
internal fun onNavigationFailed(description: String?) {
|
|
336
|
+
val pending = pendingPromise ?: return
|
|
337
|
+
pendingPromise = null
|
|
338
|
+
pending.reject(
|
|
339
|
+
"webview_navigation_failed",
|
|
340
|
+
description ?: "Navigation failed.",
|
|
341
|
+
makeUserInfo(underlying = description),
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
internal fun onHttpError(statusCode: Int) {
|
|
346
|
+
val pending = pendingPromise ?: return
|
|
347
|
+
pendingPromise = null
|
|
348
|
+
webView?.stopLoading()
|
|
349
|
+
pending.reject(
|
|
350
|
+
"webview_http_error",
|
|
351
|
+
"HTTP $statusCode",
|
|
352
|
+
makeUserInfo(statusCode = statusCode),
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
internal fun onRenderProcessGone() {
|
|
357
|
+
pendingPromise?.let {
|
|
358
|
+
pendingPromise = null
|
|
359
|
+
it.reject(
|
|
360
|
+
"webview_content_process_terminated",
|
|
361
|
+
"WebView render process terminated.",
|
|
362
|
+
makeUserInfo(),
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
teardown()
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Called from the WebMessageListener (UI thread); marshals to the RN UI queue
|
|
369
|
+
// so message delivery is ordered the same as on iOS.
|
|
370
|
+
internal fun onBridgeMessage(payload: String) {
|
|
371
|
+
reactContext.runOnUiQueueThread {
|
|
372
|
+
onMessage(parseBridgePayload(payload))
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private fun teardown() {
|
|
377
|
+
if (released) return
|
|
378
|
+
released = true
|
|
379
|
+
synchronized(outboundLock) {
|
|
380
|
+
replyProxy = null
|
|
381
|
+
pendingOutbound.clear()
|
|
382
|
+
}
|
|
383
|
+
val web = webView
|
|
384
|
+
webView = null
|
|
385
|
+
if (web != null) {
|
|
386
|
+
web.stopLoading()
|
|
387
|
+
(web.parent as? ViewGroup)?.removeView(web)
|
|
388
|
+
web.destroy()
|
|
389
|
+
}
|
|
390
|
+
// Delete this instance's ephemeral profile after the WebView is destroyed
|
|
391
|
+
// (an in-use profile cannot be deleted). Scoped per-instance, so it never
|
|
392
|
+
// touches a sibling's session. Best-effort.
|
|
393
|
+
profileName?.let { name ->
|
|
394
|
+
profileName = null
|
|
395
|
+
if (WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE)) {
|
|
396
|
+
runCatching { ProfileStore.getInstance().deleteProfile(name) }
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
onRelease()
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private fun release() {
|
|
403
|
+
teardown()
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private fun rejectInvalidUrl(promise: Promise, url: String) {
|
|
407
|
+
promise.reject("webview_invalid_url", "Invalid URL: $url", makeUserInfo())
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private fun rejectHostMissing(promise: Promise) {
|
|
411
|
+
promise.reject(
|
|
412
|
+
"webview_no_host_view",
|
|
413
|
+
"No foreground activity available to attach WebView.",
|
|
414
|
+
makeUserInfo(),
|
|
415
|
+
)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private fun rejectUnsupported(promise: Promise) {
|
|
419
|
+
promise.reject(
|
|
420
|
+
"webview_unsupported",
|
|
421
|
+
"Android System WebView is too old to support the secure WaaS bridge. " +
|
|
422
|
+
"Please update Android System WebView.",
|
|
423
|
+
makeUserInfo(),
|
|
424
|
+
)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private fun rejectCancelled(promise: Promise) {
|
|
428
|
+
promise.reject(
|
|
429
|
+
"webview_cancelled",
|
|
430
|
+
"WebView was closed before the load settled.",
|
|
431
|
+
makeUserInfo(),
|
|
432
|
+
)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private fun makeUserInfo(
|
|
436
|
+
statusCode: Int? = null,
|
|
437
|
+
underlying: String? = null,
|
|
438
|
+
): WritableMap {
|
|
439
|
+
val map = Arguments.createMap()
|
|
440
|
+
if (statusCode != null) map.putInt(USER_INFO_STATUS_CODE_KEY, statusCode)
|
|
441
|
+
if (underlying != null) map.putString(USER_INFO_UNDERLYING_KEY, underlying)
|
|
442
|
+
return map
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// scheme://host[:port] for a validated https URL — the unit the WebView pins to.
|
|
447
|
+
// The scheme-default port (443 for https) is omitted so the origin matches both
|
|
448
|
+
// the page's normalized origin and WebMessageListener's allowedOriginRules.
|
|
449
|
+
internal fun originOf(uri: Uri): String {
|
|
450
|
+
val scheme = uri.scheme?.lowercase()
|
|
451
|
+
val port = uri.port
|
|
452
|
+
val isDefaultPort = port == -1 || (scheme == "https" && port == 443)
|
|
453
|
+
val authority = if (isDefaultPort) uri.host else "${uri.host}:$port"
|
|
454
|
+
return "$scheme://$authority"
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// The hosted iframe always posts object data, so `{...}` is the common case.
|
|
458
|
+
// `[...]` and primitive string fallthrough are kept for completeness.
|
|
459
|
+
private fun parseBridgePayload(payload: String): Any? =
|
|
460
|
+
runCatching {
|
|
461
|
+
val trimmed = payload.trim()
|
|
462
|
+
when {
|
|
463
|
+
trimmed.startsWith("{") -> jsonObjectToWritableMap(JSONObject(trimmed))
|
|
464
|
+
trimmed.startsWith("[") -> jsonArrayToWritableArray(JSONArray(trimmed))
|
|
465
|
+
else -> payload
|
|
466
|
+
}
|
|
467
|
+
}.getOrDefault(payload)
|
|
468
|
+
|
|
469
|
+
private fun readableMapToJsonString(map: ReadableMap): String =
|
|
470
|
+
(mapToJson(map.toHashMap()) as JSONObject).toString()
|
|
471
|
+
|
|
472
|
+
private fun mapToJson(value: Any?): Any =
|
|
473
|
+
when (value) {
|
|
474
|
+
null -> JSONObject.NULL
|
|
475
|
+
is Map<*, *> -> JSONObject().also { obj ->
|
|
476
|
+
for ((k, v) in value) obj.put(k.toString(), mapToJson(v))
|
|
477
|
+
}
|
|
478
|
+
is List<*> -> JSONArray().also { arr ->
|
|
479
|
+
for (v in value) arr.put(mapToJson(v))
|
|
480
|
+
}
|
|
481
|
+
else -> value
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
private fun jsonObjectToWritableMap(obj: JSONObject): WritableMap {
|
|
485
|
+
val out = Arguments.createMap()
|
|
486
|
+
val keys = obj.keys()
|
|
487
|
+
while (keys.hasNext()) {
|
|
488
|
+
val key = keys.next()
|
|
489
|
+
when (val value = obj.opt(key)) {
|
|
490
|
+
null, JSONObject.NULL -> out.putNull(key)
|
|
491
|
+
is Boolean -> out.putBoolean(key, value)
|
|
492
|
+
is Int -> out.putInt(key, value)
|
|
493
|
+
is Long -> out.putDouble(key, value.toDouble())
|
|
494
|
+
is Double -> out.putDouble(key, value)
|
|
495
|
+
is String -> out.putString(key, value)
|
|
496
|
+
is JSONObject -> out.putMap(key, jsonObjectToWritableMap(value))
|
|
497
|
+
is JSONArray -> out.putArray(key, jsonArrayToWritableArray(value))
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return out
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private fun jsonArrayToWritableArray(arr: JSONArray): WritableArray {
|
|
504
|
+
val out = Arguments.createArray()
|
|
505
|
+
for (i in 0 until arr.length()) {
|
|
506
|
+
when (val value = arr.opt(i)) {
|
|
507
|
+
null, JSONObject.NULL -> out.pushNull()
|
|
508
|
+
is Boolean -> out.pushBoolean(value)
|
|
509
|
+
is Int -> out.pushInt(value)
|
|
510
|
+
is Long -> out.pushDouble(value.toDouble())
|
|
511
|
+
is Double -> out.pushDouble(value)
|
|
512
|
+
is String -> out.pushString(value)
|
|
513
|
+
is JSONObject -> out.pushMap(jsonObjectToWritableMap(value))
|
|
514
|
+
is JSONArray -> out.pushArray(jsonArrayToWritableArray(value))
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return out
|
|
518
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
package xyz.dynamic.client.webview
|
|
2
|
+
|
|
3
|
+
import android.os.Handler
|
|
4
|
+
import android.os.Looper
|
|
5
|
+
import com.facebook.react.bridge.Arguments
|
|
6
|
+
import com.facebook.react.bridge.Promise
|
|
7
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
8
|
+
import com.facebook.react.bridge.ReadableMap
|
|
9
|
+
import com.facebook.react.bridge.WritableArray
|
|
10
|
+
import com.facebook.react.bridge.WritableMap
|
|
11
|
+
import com.facebook.react.module.annotations.ReactModule
|
|
12
|
+
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
|
|
13
|
+
import xyz.dynamic.client.NativeDynamicWebViewSpec
|
|
14
|
+
|
|
15
|
+
@ReactModule(name = DynamicWebViewModule.NAME)
|
|
16
|
+
class DynamicWebViewModule(reactContext: ReactApplicationContext) :
|
|
17
|
+
NativeDynamicWebViewSpec(reactContext) {
|
|
18
|
+
|
|
19
|
+
companion object {
|
|
20
|
+
const val NAME = "DynamicClientWebView"
|
|
21
|
+
internal const val MESSAGE_EVENT_NAME = "dynamicBridgeMessage"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private val mainHandler = Handler(Looper.getMainLooper())
|
|
25
|
+
private val hosts = mutableMapOf<String, DynamicWebViewHost>()
|
|
26
|
+
|
|
27
|
+
// Guards listenerCount + bufferedEvents. Bridge messages arrive on the RN UI
|
|
28
|
+
// queue (via onBridgeMessage) while addListener/removeListeners may run on a
|
|
29
|
+
// different thread, so access is serialized.
|
|
30
|
+
private val listenerLock = Any()
|
|
31
|
+
|
|
32
|
+
// Number of JS-side subscribers, driven by NativeEventEmitter's
|
|
33
|
+
// addListener/removeListeners calls. RCTDeviceEventEmitter.emit drops events
|
|
34
|
+
// when there is no JS listener, so we gate on this count.
|
|
35
|
+
private var listenerCount = 0
|
|
36
|
+
|
|
37
|
+
// Events that arrived before the first JS listener attached (e.g. the
|
|
38
|
+
// iframe-ready handshake posted before onMessage subscribes). Flushed in
|
|
39
|
+
// order on the 0->1 listener transition so the handshake is not lost —
|
|
40
|
+
// mirroring the iOS .mm pre-listener buffer.
|
|
41
|
+
private val bufferedEvents = mutableListOf<WritableMap>()
|
|
42
|
+
|
|
43
|
+
override fun getName(): String = NAME
|
|
44
|
+
|
|
45
|
+
override fun open(instanceId: String, url: String, promise: Promise) {
|
|
46
|
+
mainHandler.post {
|
|
47
|
+
if (hosts.containsKey(instanceId)) {
|
|
48
|
+
val userInfo: WritableMap = Arguments.createMap()
|
|
49
|
+
promise.reject(
|
|
50
|
+
"webview_instance_busy",
|
|
51
|
+
"open called on an instanceId that already has an active WebView.",
|
|
52
|
+
userInfo
|
|
53
|
+
)
|
|
54
|
+
return@post
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
val host = DynamicWebViewHost(
|
|
58
|
+
reactContext = reactApplicationContext,
|
|
59
|
+
instanceId = instanceId,
|
|
60
|
+
onMessage = { data -> emitMessage(instanceId, data) },
|
|
61
|
+
onRelease = { hosts.remove(instanceId) },
|
|
62
|
+
)
|
|
63
|
+
hosts[instanceId] = host
|
|
64
|
+
host.open(url, promise)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
override fun close(instanceId: String) {
|
|
69
|
+
mainHandler.post {
|
|
70
|
+
hosts.remove(instanceId)?.close()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
override fun sendMessage(instanceId: String, data: ReadableMap) {
|
|
75
|
+
mainHandler.post {
|
|
76
|
+
hosts[instanceId]?.sendMessage(data)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Track JS-subscriber count so emitMessage can buffer until the first
|
|
81
|
+
// listener attaches, then flush — matching the iOS .mm buffer. Without this,
|
|
82
|
+
// an early event (the iframe-ready handshake) emitted before onMessage
|
|
83
|
+
// subscribes is dropped by RCTDeviceEventEmitter and the ceremony stalls.
|
|
84
|
+
override fun addListener(eventName: String) {
|
|
85
|
+
val pending: List<WritableMap>
|
|
86
|
+
synchronized(listenerLock) {
|
|
87
|
+
listenerCount++
|
|
88
|
+
// Only flush on the 0 -> 1 transition.
|
|
89
|
+
if (listenerCount != 1) return
|
|
90
|
+
pending = bufferedEvents.toList()
|
|
91
|
+
bufferedEvents.clear()
|
|
92
|
+
}
|
|
93
|
+
pending.forEach { emitEvent(it) }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
override fun removeListeners(count: Double) {
|
|
97
|
+
synchronized(listenerLock) {
|
|
98
|
+
listenerCount = maxOf(0, listenerCount - count.toInt())
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
override fun invalidate() {
|
|
103
|
+
super.invalidate()
|
|
104
|
+
mainHandler.post {
|
|
105
|
+
// close() -> onRelease removes the host from `hosts`, so iterate a
|
|
106
|
+
// snapshot to avoid ConcurrentModificationException when more than one
|
|
107
|
+
// host is active (the RN reload / teardown path).
|
|
108
|
+
for (host in hosts.values.toList()) host.close()
|
|
109
|
+
hosts.clear()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private fun emitMessage(instanceId: String, data: Any?) {
|
|
114
|
+
val event = Arguments.createMap().apply {
|
|
115
|
+
putString("instanceId", instanceId)
|
|
116
|
+
}
|
|
117
|
+
putBridgeData(event, data)
|
|
118
|
+
synchronized(listenerLock) {
|
|
119
|
+
// No JS listener yet → buffer so an early handshake isn't dropped.
|
|
120
|
+
if (listenerCount == 0) {
|
|
121
|
+
bufferedEvents.add(event)
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
emitEvent(event)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private fun emitEvent(event: WritableMap) {
|
|
129
|
+
reactApplicationContext
|
|
130
|
+
.getJSModule(RCTDeviceEventEmitter::class.java)
|
|
131
|
+
.emit(MESSAGE_EVENT_NAME, event)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Maps a parsed bridge payload onto the event's `data` field. Extracted as a
|
|
136
|
+
// top-level function so the type handling (notably the WritableArray case the
|
|
137
|
+
// hosted page can produce, which previously fell through to putNull) is
|
|
138
|
+
// unit-testable without constructing the codegen TurboModule base class.
|
|
139
|
+
internal fun putBridgeData(target: WritableMap, data: Any?) {
|
|
140
|
+
when (data) {
|
|
141
|
+
null -> target.putNull("data")
|
|
142
|
+
is WritableMap -> target.putMap("data", data)
|
|
143
|
+
is WritableArray -> target.putArray("data", data)
|
|
144
|
+
is String -> target.putString("data", data)
|
|
145
|
+
else -> target.putNull("data")
|
|
146
|
+
}
|
|
147
|
+
}
|