@dynamic-labs-sdk/client 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/android/build.gradle +231 -0
  2. package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  3. package/android/gradle/wrapper/gradle-wrapper.properties +7 -0
  4. package/android/gradle.properties +8 -0
  5. package/android/gradlew +251 -0
  6. package/android/gradlew.bat +94 -0
  7. package/android/settings.gradle +30 -0
  8. package/android/src/main/java/xyz/dynamic/client/DynamicClientPackage.kt +52 -0
  9. package/android/src/main/java/xyz/dynamic/client/keychain/KeyStoreKeyManager.kt +147 -0
  10. package/android/src/main/java/xyz/dynamic/client/keychain/KeychainModule.kt +85 -0
  11. package/android/src/main/java/xyz/dynamic/client/manifest/ReactNativeManifestModule.kt +25 -0
  12. package/android/src/main/java/xyz/dynamic/client/webview/DynamicWebViewClient.kt +83 -0
  13. package/android/src/main/java/xyz/dynamic/client/webview/DynamicWebViewHost.kt +518 -0
  14. package/android/src/main/java/xyz/dynamic/client/webview/DynamicWebViewModule.kt +147 -0
  15. package/android/src/test/java/xyz/dynamic/client/keychain/KeyStoreKeyManagerTest.kt +288 -0
  16. package/android/src/test/java/xyz/dynamic/client/webview/DynamicWebViewClientTest.kt +196 -0
  17. package/android/src/test/java/xyz/dynamic/client/webview/DynamicWebViewHostOpenTest.kt +347 -0
  18. package/android/src/test/java/xyz/dynamic/client/webview/DynamicWebViewHostTest.kt +171 -0
  19. package/android/src/test/java/xyz/dynamic/client/webview/DynamicWebViewModuleTest.kt +191 -0
  20. package/dist/{InvalidParamError-Crwntjl7.native.esm.js → InvalidParamError-Bc6OZBog.native.esm.js} +3 -3
  21. package/dist/{InvalidParamError-Crwntjl7.native.esm.js.map → InvalidParamError-Bc6OZBog.native.esm.js.map} +1 -1
  22. package/dist/{InvalidParamError-fQLJaGBW.esm.js → InvalidParamError-IHYLdzFd.esm.js} +437 -5
  23. package/dist/InvalidParamError-IHYLdzFd.esm.js.map +1 -0
  24. package/dist/{InvalidParamError-CTssOe86.cjs → InvalidParamError-S56rFFDP.cjs} +550 -4
  25. package/dist/InvalidParamError-S56rFFDP.cjs.map +1 -0
  26. package/dist/{NotWaasWalletAccountError-BJLGGYmU.native.esm.js → NotWaasWalletAccountError-BP-bODbJ.native.esm.js} +3 -3
  27. package/dist/{NotWaasWalletAccountError-BJLGGYmU.native.esm.js.map → NotWaasWalletAccountError-BP-bODbJ.native.esm.js.map} +1 -1
  28. package/dist/{NotWaasWalletAccountError-DF_S7gqo.esm.js → NotWaasWalletAccountError-BYEL2l-W.esm.js} +3 -3
  29. package/dist/{NotWaasWalletAccountError-DF_S7gqo.esm.js.map → NotWaasWalletAccountError-BYEL2l-W.esm.js.map} +1 -1
  30. package/dist/{NotWaasWalletAccountError-2RaDipZu.cjs → NotWaasWalletAccountError-CbhIKUDM.cjs} +3 -3
  31. package/dist/{NotWaasWalletAccountError-2RaDipZu.cjs.map → NotWaasWalletAccountError-CbhIKUDM.cjs.map} +1 -1
  32. package/dist/core.cjs +23 -23
  33. package/dist/core.cjs.map +1 -1
  34. package/dist/core.esm.js +5 -5
  35. package/dist/core.native.esm.js +7 -7
  36. package/dist/core.native.esm.js.map +1 -1
  37. package/dist/errors/WebViewLoadFailedError.d.ts +1 -1
  38. package/dist/errors/WebViewLoadFailedError.d.ts.map +1 -1
  39. package/dist/exports/index.d.ts +2 -0
  40. package/dist/exports/index.d.ts.map +1 -1
  41. package/dist/{getNetworkProviderFromNetworkId-C1p9Rrg3.cjs → getNetworkProviderFromNetworkId-BDdcpGHH.cjs} +11 -317
  42. package/dist/getNetworkProviderFromNetworkId-BDdcpGHH.cjs.map +1 -0
  43. package/dist/{getNetworkProviderFromNetworkId-BWhaD23J.esm.js → getNetworkProviderFromNetworkId-D8rWYXlT.esm.js} +4 -242
  44. package/dist/getNetworkProviderFromNetworkId-D8rWYXlT.esm.js.map +1 -0
  45. package/dist/{getNetworkProviderFromNetworkId-BCSmILlH.native.esm.js → getNetworkProviderFromNetworkId-DVmF7pH2.native.esm.js} +6 -6
  46. package/dist/getNetworkProviderFromNetworkId-DVmF7pH2.native.esm.js.map +1 -0
  47. package/dist/{getSignedSessionId-CxxYNC_8.cjs → getSignedSessionId-BBaBCi9S.cjs} +3 -3
  48. package/dist/{getSignedSessionId-CxxYNC_8.cjs.map → getSignedSessionId-BBaBCi9S.cjs.map} +1 -1
  49. package/dist/{getSignedSessionId-c7dS4PQ9.esm.js → getSignedSessionId-CND07Eca.esm.js} +3 -3
  50. package/dist/{getSignedSessionId-c7dS4PQ9.esm.js.map → getSignedSessionId-CND07Eca.esm.js.map} +1 -1
  51. package/dist/{getSignedSessionId-DPOXdh6y.native.esm.js → getSignedSessionId-RwV7JPn8.native.esm.js} +3 -3
  52. package/dist/{getSignedSessionId-DPOXdh6y.native.esm.js.map → getSignedSessionId-RwV7JPn8.native.esm.js.map} +1 -1
  53. package/dist/{getVerifiedCredentialForWalletAccount-Cahp763h.esm.js → getVerifiedCredentialForWalletAccount-BVrGV8qf.native.esm.js} +2 -2
  54. package/dist/{getVerifiedCredentialForWalletAccount-DuNbORNW.native.esm.js.map → getVerifiedCredentialForWalletAccount-BVrGV8qf.native.esm.js.map} +1 -1
  55. package/dist/getVerifiedCredentialForWalletAccount-BiOS65Tk.esm.js +269 -0
  56. package/dist/getVerifiedCredentialForWalletAccount-BiOS65Tk.esm.js.map +1 -0
  57. package/dist/getVerifiedCredentialForWalletAccount-FrxYWSuD.cjs +347 -0
  58. package/dist/getVerifiedCredentialForWalletAccount-FrxYWSuD.cjs.map +1 -0
  59. package/dist/index.cjs +67 -67
  60. package/dist/index.cjs.map +1 -1
  61. package/dist/index.esm.js +5 -5
  62. package/dist/index.esm.js.map +1 -1
  63. package/dist/index.native.esm.js +5 -5
  64. package/dist/index.native.esm.js.map +1 -1
  65. package/dist/isMfaRequiredForAction-BPL_dgIM.esm.js +318 -0
  66. package/dist/isMfaRequiredForAction-BPL_dgIM.esm.js.map +1 -0
  67. package/dist/isMfaRequiredForAction-BolTrtCO.cjs +405 -0
  68. package/dist/isMfaRequiredForAction-BolTrtCO.cjs.map +1 -0
  69. package/dist/{isMfaRequiredForAction-D8G5PBAd.native.esm.js → isMfaRequiredForAction-DQDoPsi6.native.esm.js} +2 -2
  70. package/dist/{isMfaRequiredForAction-D8G5PBAd.native.esm.js.map → isMfaRequiredForAction-DQDoPsi6.native.esm.js.map} +1 -1
  71. package/dist/modules/waas/createWaasClient/createWaasClient.d.ts.map +1 -1
  72. package/dist/modules/waas/createWaasClient/translateNativeOpenError.d.ts.map +1 -1
  73. package/dist/modules/wallets/walletProvider/events/onWalletProviderEvent/onWalletProviderEvent.d.ts +1 -2
  74. package/dist/modules/wallets/walletProvider/events/onWalletProviderEvent/onWalletProviderEvent.d.ts.map +1 -1
  75. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  76. package/dist/waas.cjs +9 -9
  77. package/dist/waas.cjs.map +1 -1
  78. package/dist/waas.esm.js +3 -3
  79. package/dist/waas.native.esm.js +3 -3
  80. package/dist/waasCore.cjs +8 -5
  81. package/dist/waasCore.cjs.map +1 -1
  82. package/dist/waasCore.esm.js +8 -4
  83. package/dist/waasCore.esm.js.map +1 -1
  84. package/dist/waasCore.native.esm.js +8 -7
  85. package/dist/waasCore.native.esm.js.map +1 -1
  86. package/dynamic-labs-sdk-client.podspec +27 -0
  87. package/ios/DynamicClientKeychain.h +4 -0
  88. package/ios/DynamicClientKeychain.mm +101 -0
  89. package/ios/DynamicClientManifest.h +4 -0
  90. package/ios/DynamicClientManifest.mm +45 -0
  91. package/ios/DynamicClientWebView.h +5 -0
  92. package/ios/DynamicClientWebView.mm +143 -0
  93. package/ios/DynamicWebViewImpl.swift +603 -0
  94. package/ios/LiveSecKeyAPI.swift +86 -0
  95. package/ios/ReactNativeManifestImpl.swift +20 -0
  96. package/ios/SecKeyAPI.swift +57 -0
  97. package/ios/SecureEnclaveKeyManager.swift +191 -0
  98. package/package.json +14 -5
  99. package/react-native.config.cjs +14 -0
  100. package/src/turboModules/NativeDynamicWebView.native.spec.ts +61 -0
  101. package/src/turboModules/NativeDynamicWebView.ts +40 -0
  102. package/src/turboModules/NativeKeychain.native.spec.ts +47 -0
  103. package/src/turboModules/NativeKeychain.ts +19 -0
  104. package/src/turboModules/NativeReactNativeManifest.native.spec.ts +55 -0
  105. package/src/turboModules/NativeReactNativeManifest.ts +28 -0
  106. package/dist/InvalidParamError-CTssOe86.cjs.map +0 -1
  107. package/dist/InvalidParamError-fQLJaGBW.esm.js.map +0 -1
  108. package/dist/getNetworkProviderFromNetworkId-BCSmILlH.native.esm.js.map +0 -1
  109. package/dist/getNetworkProviderFromNetworkId-BWhaD23J.esm.js.map +0 -1
  110. package/dist/getNetworkProviderFromNetworkId-C1p9Rrg3.cjs.map +0 -1
  111. package/dist/getVerifiedCredentialForWalletAccount-Cahp763h.esm.js.map +0 -1
  112. package/dist/getVerifiedCredentialForWalletAccount-Di8dM5Wx.cjs +0 -887
  113. package/dist/getVerifiedCredentialForWalletAccount-Di8dM5Wx.cjs.map +0 -1
  114. package/dist/getVerifiedCredentialForWalletAccount-DuNbORNW.native.esm.js +0 -701
  115. package/dist/isMfaRequiredForAction-BjRvPrU6.esm.js +0 -79
  116. package/dist/isMfaRequiredForAction-BjRvPrU6.esm.js.map +0 -1
  117. package/dist/isMfaRequiredForAction-hlhj0qAC.cjs +0 -96
  118. 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
+ }