@dynamic-labs-sdk/client 1.6.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 (135) 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-BIptAjbC.native.esm.js → InvalidParamError-Bc6OZBog.native.esm.js} +91 -829
  21. package/dist/InvalidParamError-Bc6OZBog.native.esm.js.map +1 -0
  22. package/dist/{InvalidParamError-BZ0LHaml.esm.js → InvalidParamError-IHYLdzFd.esm.js} +108 -381
  23. package/dist/InvalidParamError-IHYLdzFd.esm.js.map +1 -0
  24. package/dist/{InvalidParamError-DyXEKMWn.cjs → InvalidParamError-S56rFFDP.cjs} +108 -450
  25. package/dist/InvalidParamError-S56rFFDP.cjs.map +1 -0
  26. package/dist/{NotWaasWalletAccountError-nT4jrLbp.esm.js → NotWaasWalletAccountError-BP-bODbJ.native.esm.js} +4 -4
  27. package/dist/NotWaasWalletAccountError-BP-bODbJ.native.esm.js.map +1 -0
  28. package/dist/{NotWaasWalletAccountError-BbOA5rPd.native.esm.js → NotWaasWalletAccountError-BYEL2l-W.esm.js} +4 -4
  29. package/dist/NotWaasWalletAccountError-BYEL2l-W.esm.js.map +1 -0
  30. package/dist/{NotWaasWalletAccountError-CElqwdQO.cjs → NotWaasWalletAccountError-CbhIKUDM.cjs} +4 -4
  31. package/dist/NotWaasWalletAccountError-CbhIKUDM.cjs.map +1 -0
  32. package/dist/core.cjs +11 -11
  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-DbI5MZhq.cjs → getNetworkProviderFromNetworkId-BDdcpGHH.cjs} +56 -55
  42. package/dist/{getNetworkProviderFromNetworkId-DbI5MZhq.cjs.map → getNetworkProviderFromNetworkId-BDdcpGHH.cjs.map} +1 -1
  43. package/dist/{getNetworkProviderFromNetworkId-BkAPIdRC.esm.js → getNetworkProviderFromNetworkId-D8rWYXlT.esm.js} +54 -54
  44. package/dist/{getNetworkProviderFromNetworkId-BkAPIdRC.esm.js.map → getNetworkProviderFromNetworkId-D8rWYXlT.esm.js.map} +1 -1
  45. package/dist/{getNetworkProviderFromNetworkId-B7DIqT2T.native.esm.js → getNetworkProviderFromNetworkId-DVmF7pH2.native.esm.js} +312 -41
  46. package/dist/getNetworkProviderFromNetworkId-DVmF7pH2.native.esm.js.map +1 -0
  47. package/dist/{getSignedSessionId-BF6n8gve.cjs → getSignedSessionId-BBaBCi9S.cjs} +3 -3
  48. package/dist/{getSignedSessionId-BF6n8gve.cjs.map → getSignedSessionId-BBaBCi9S.cjs.map} +1 -1
  49. package/dist/{getSignedSessionId-y3YtxFHZ.esm.js → getSignedSessionId-CND07Eca.esm.js} +3 -3
  50. package/dist/{getSignedSessionId-y3YtxFHZ.esm.js.map → getSignedSessionId-CND07Eca.esm.js.map} +1 -1
  51. package/dist/{getSignedSessionId-1TV-x__C.native.esm.js → getSignedSessionId-RwV7JPn8.native.esm.js} +3 -3
  52. package/dist/{getSignedSessionId-1TV-x__C.native.esm.js.map → getSignedSessionId-RwV7JPn8.native.esm.js.map} +1 -1
  53. package/dist/getVerifiedCredentialForWalletAccount-BVrGV8qf.native.esm.js +701 -0
  54. package/dist/getVerifiedCredentialForWalletAccount-BVrGV8qf.native.esm.js.map +1 -0
  55. package/dist/{getVerifiedCredentialForWalletAccount-etV50QRH.esm.js → getVerifiedCredentialForWalletAccount-BiOS65Tk.esm.js} +2 -2
  56. package/dist/{getVerifiedCredentialForWalletAccount-etV50QRH.esm.js.map → getVerifiedCredentialForWalletAccount-BiOS65Tk.esm.js.map} +1 -1
  57. package/dist/{getVerifiedCredentialForWalletAccount-BfJ29erz.cjs → getVerifiedCredentialForWalletAccount-FrxYWSuD.cjs} +3 -2
  58. package/dist/{getVerifiedCredentialForWalletAccount-BfJ29erz.cjs.map → getVerifiedCredentialForWalletAccount-FrxYWSuD.cjs.map} +1 -1
  59. package/dist/index.cjs +35 -35
  60. package/dist/index.cjs.map +1 -1
  61. package/dist/index.esm.js +14 -14
  62. package/dist/index.esm.js.map +1 -1
  63. package/dist/index.native.esm.js +14 -14
  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-B2vKX31S.native.esm.js → isMfaRequiredForAction-DQDoPsi6.native.esm.js} +2 -2
  70. package/dist/{isMfaRequiredForAction-B2vKX31S.native.esm.js.map → isMfaRequiredForAction-DQDoPsi6.native.esm.js.map} +1 -1
  71. package/dist/modules/apiClient/apiClient.types.d.ts +0 -12
  72. package/dist/modules/apiClient/apiClient.types.d.ts.map +1 -1
  73. package/dist/modules/apiClient/createApiClient.d.ts.map +1 -1
  74. package/dist/modules/auth/logoutReason.d.ts +1 -1
  75. package/dist/modules/auth/logoutReason.d.ts.map +1 -1
  76. package/dist/modules/auth/logoutWithReason/logoutWithReason.d.ts.map +1 -1
  77. package/dist/modules/deviceRegistration/getRegisteredDevices/getRegisteredDevices.d.ts.map +1 -1
  78. package/dist/modules/user/refreshAuth/refreshAuth.d.ts.map +1 -1
  79. package/dist/modules/waas/backupWaasKeySharesToGoogleDrive/backupWaasKeySharesToGoogleDrive.d.ts +12 -1
  80. package/dist/modules/waas/backupWaasKeySharesToGoogleDrive/backupWaasKeySharesToGoogleDrive.d.ts.map +1 -1
  81. package/dist/modules/waas/createWaasClient/createWaasClient.d.ts.map +1 -1
  82. package/dist/modules/waas/createWaasClient/translateNativeOpenError.d.ts.map +1 -1
  83. package/dist/modules/waas/createWaasProvider/createWaasProvider.d.ts.map +1 -1
  84. package/dist/modules/waas/waas.types.d.ts +1 -0
  85. package/dist/modules/waas/waas.types.d.ts.map +1 -1
  86. package/dist/modules/wallets/walletProvider/events/onWalletProviderEvent/onWalletProviderEvent.d.ts +1 -2
  87. package/dist/modules/wallets/walletProvider/events/onWalletProviderEvent/onWalletProviderEvent.d.ts.map +1 -1
  88. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  89. package/dist/waas.cjs +7 -4
  90. package/dist/waas.cjs.map +1 -1
  91. package/dist/waas.esm.js +6 -4
  92. package/dist/waas.esm.js.map +1 -1
  93. package/dist/waas.native.esm.js +6 -4
  94. package/dist/waas.native.esm.js.map +1 -1
  95. package/dist/waasCore.cjs +10 -5
  96. package/dist/waasCore.cjs.map +1 -1
  97. package/dist/waasCore.esm.js +10 -5
  98. package/dist/waasCore.esm.js.map +1 -1
  99. package/dist/waasCore.native.esm.js +10 -8
  100. package/dist/waasCore.native.esm.js.map +1 -1
  101. package/dynamic-labs-sdk-client.podspec +27 -0
  102. package/ios/DynamicClientKeychain.h +4 -0
  103. package/ios/DynamicClientKeychain.mm +101 -0
  104. package/ios/DynamicClientManifest.h +4 -0
  105. package/ios/DynamicClientManifest.mm +45 -0
  106. package/ios/DynamicClientWebView.h +5 -0
  107. package/ios/DynamicClientWebView.mm +143 -0
  108. package/ios/DynamicWebViewImpl.swift +603 -0
  109. package/ios/LiveSecKeyAPI.swift +86 -0
  110. package/ios/ReactNativeManifestImpl.swift +20 -0
  111. package/ios/SecKeyAPI.swift +57 -0
  112. package/ios/SecureEnclaveKeyManager.swift +191 -0
  113. package/package.json +16 -5
  114. package/react-native.config.cjs +14 -0
  115. package/src/turboModules/NativeDynamicWebView.native.spec.ts +61 -0
  116. package/src/turboModules/NativeDynamicWebView.ts +40 -0
  117. package/src/turboModules/NativeKeychain.native.spec.ts +47 -0
  118. package/src/turboModules/NativeKeychain.ts +19 -0
  119. package/src/turboModules/NativeReactNativeManifest.native.spec.ts +55 -0
  120. package/src/turboModules/NativeReactNativeManifest.ts +28 -0
  121. package/dist/InvalidParamError-BIptAjbC.native.esm.js.map +0 -1
  122. package/dist/InvalidParamError-BZ0LHaml.esm.js.map +0 -1
  123. package/dist/InvalidParamError-DyXEKMWn.cjs.map +0 -1
  124. package/dist/NotWaasWalletAccountError-BbOA5rPd.native.esm.js.map +0 -1
  125. package/dist/NotWaasWalletAccountError-CElqwdQO.cjs.map +0 -1
  126. package/dist/NotWaasWalletAccountError-nT4jrLbp.esm.js.map +0 -1
  127. package/dist/getNetworkProviderFromNetworkId-B7DIqT2T.native.esm.js.map +0 -1
  128. package/dist/getVerifiedCredentialForWalletAccount-CLyC2nOl.native.esm.js +0 -269
  129. package/dist/getVerifiedCredentialForWalletAccount-CLyC2nOl.native.esm.js.map +0 -1
  130. package/dist/isMfaRequiredForAction-Dy4-S3mr.esm.js +0 -79
  131. package/dist/isMfaRequiredForAction-Dy4-S3mr.esm.js.map +0 -1
  132. package/dist/isMfaRequiredForAction-K2Jh-3-j.cjs +0 -96
  133. package/dist/isMfaRequiredForAction-K2Jh-3-j.cjs.map +0 -1
  134. package/dist/modules/apiClient/utils/sessionExpiryLogoutMiddleware/createSessionExpiryLogoutMiddleware.d.ts +0 -27
  135. package/dist/modules/apiClient/utils/sessionExpiryLogoutMiddleware/createSessionExpiryLogoutMiddleware.d.ts.map +0 -1
@@ -0,0 +1,347 @@
1
+ package xyz.dynamic.client.webview
2
+
3
+ import android.app.Activity
4
+ import android.net.Uri
5
+ import android.webkit.WebSettings
6
+ import android.webkit.WebView
7
+ import androidx.webkit.JavaScriptReplyProxy
8
+ import androidx.webkit.ProfileStore
9
+ import androidx.webkit.WebMessageCompat
10
+ import androidx.webkit.WebViewCompat
11
+ import androidx.webkit.WebViewFeature
12
+ import com.facebook.react.bridge.Arguments
13
+ import com.facebook.react.bridge.JavaOnlyMap
14
+ import com.facebook.react.bridge.Promise
15
+ import com.facebook.react.bridge.ReactApplicationContext
16
+ import com.facebook.react.bridge.WritableMap
17
+ import io.mockk.CapturingSlot
18
+ import io.mockk.Runs
19
+ import io.mockk.every
20
+ import io.mockk.just
21
+ import io.mockk.mockk
22
+ import io.mockk.mockkStatic
23
+ import io.mockk.slot
24
+ import io.mockk.unmockkAll
25
+ import io.mockk.verify
26
+ import org.junit.After
27
+ import org.junit.Assert.assertEquals
28
+ import org.junit.Assert.assertFalse
29
+ import org.junit.Assert.assertTrue
30
+ import org.junit.Before
31
+ import org.junit.Test
32
+ import org.junit.runner.RunWith
33
+ import org.robolectric.Robolectric
34
+ import org.robolectric.RobolectricTestRunner
35
+ import org.robolectric.Shadows.shadowOf
36
+ import org.robolectric.annotation.Config
37
+
38
+ // Exercises the open() happy path that constructs a real (Robolectric-shadowed)
39
+ // WebView. The androidx.webkit statics (WebViewCompat / WebViewFeature /
40
+ // ProfileStore) are mocked because they delegate to the device's System WebView
41
+ // provider, which does not exist off-device — so the tests assert that the host
42
+ // CALLS them correctly (origin-scoped bridge, document-start glue, per-instance
43
+ // profile) and applies the secure WebSettings, rather than exercising the
44
+ // provider itself. WEB_MESSAGE_LISTENER is reported supported so open() proceeds.
45
+ @RunWith(RobolectricTestRunner::class)
46
+ @Config(sdk = [35])
47
+ class DynamicWebViewHostOpenTest {
48
+
49
+ private lateinit var activity: Activity
50
+ private lateinit var reactContext: ReactApplicationContext
51
+
52
+ private lateinit var webSlot: CapturingSlot<WebView>
53
+ private lateinit var originRulesSlot: CapturingSlot<Set<String>>
54
+ private lateinit var listenerSlot: CapturingSlot<WebViewCompat.WebMessageListener>
55
+
56
+ @Before
57
+ fun setUp() {
58
+ activity = Robolectric.buildActivity(Activity::class.java).setup().get()
59
+ reactContext = mockk(relaxed = true)
60
+ every { reactContext.currentActivity } returns activity
61
+ // Run UI-queue work inline so onBridgeMessage delivery is synchronous.
62
+ // (The http-error-race tests re-stub this to capture instead.)
63
+ every { reactContext.runOnUiQueueThread(any()) } answers { firstArg<Runnable>().run() }
64
+
65
+ mockkStatic(Arguments::class)
66
+ every { Arguments.createMap() } answers { JavaOnlyMap() }
67
+
68
+ mockkStatic(WebViewFeature::class)
69
+ // Default: only the (required) bridge feature is available. Document-start
70
+ // scripts and multi-profile default off; individual tests opt in.
71
+ every { WebViewFeature.isFeatureSupported(any()) } returns false
72
+ every {
73
+ WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)
74
+ } returns true
75
+
76
+ mockkStatic(WebViewCompat::class)
77
+ webSlot = slot()
78
+ originRulesSlot = slot()
79
+ listenerSlot = slot()
80
+ every {
81
+ WebViewCompat.addWebMessageListener(
82
+ capture(webSlot),
83
+ any(),
84
+ capture(originRulesSlot),
85
+ capture(listenerSlot),
86
+ )
87
+ } just Runs
88
+ every {
89
+ WebViewCompat.addDocumentStartJavaScript(any(), any(), any())
90
+ } returns mockk(relaxed = true)
91
+ every { WebViewCompat.setProfile(any(), any()) } just Runs
92
+ }
93
+
94
+ @After
95
+ fun tearDown() {
96
+ unmockkAll()
97
+ }
98
+
99
+ private fun host(
100
+ instanceId: String = "instance-1",
101
+ onMessage: (Any?) -> Unit = {},
102
+ onRelease: () -> Unit = {},
103
+ ) = DynamicWebViewHost(reactContext, instanceId, onMessage, onRelease)
104
+
105
+ private fun openHost(
106
+ url: String = "https://app.dynamicauth.com/waas-v1/env",
107
+ instanceId: String = "instance-1",
108
+ onMessage: (Any?) -> Unit = {},
109
+ ): Pair<DynamicWebViewHost, Promise> {
110
+ val subject = host(instanceId = instanceId, onMessage = onMessage)
111
+ val promise = mockk<Promise>(relaxed = true)
112
+ subject.open(url, promise)
113
+ return subject to promise
114
+ }
115
+
116
+ // region secure WebSettings
117
+
118
+ @Test
119
+ fun open_appliesSecureWebSettings() {
120
+ openHost()
121
+ val s = webSlot.captured.settings
122
+
123
+ assertTrue(s.javaScriptEnabled)
124
+ assertTrue(s.domStorageEnabled)
125
+ assertFalse(s.allowFileAccess)
126
+ assertFalse(s.allowContentAccess)
127
+ @Suppress("DEPRECATION")
128
+ assertFalse(s.allowFileAccessFromFileURLs)
129
+ @Suppress("DEPRECATION")
130
+ assertFalse(s.allowUniversalAccessFromFileURLs)
131
+ assertEquals(WebSettings.MIXED_CONTENT_NEVER_ALLOW, s.mixedContentMode)
132
+ assertFalse(s.javaScriptCanOpenWindowsAutomatically)
133
+ // Multi-window is enabled so onCreateWindow fires (CVE-2020-6506
134
+ // mitigation: contains iframe window requests instead of letting them
135
+ // navigate the top frame).
136
+ assertTrue(s.supportMultipleWindows())
137
+ }
138
+
139
+ // onCreateWindow must deny every window request (popups / target=_blank /
140
+ // window.open), so the enabled multi-window support never actually opens one.
141
+ @Test
142
+ fun open_deniesWindowCreation() {
143
+ openHost()
144
+ val chromeClient = shadowOf(webSlot.captured).webChromeClient
145
+ assertFalse(chromeClient.onCreateWindow(webSlot.captured, false, false, null))
146
+ }
147
+
148
+ // endregion
149
+
150
+ // region origin-scoped bridge + navigation target
151
+
152
+ @Test
153
+ fun open_registersWebMessageListenerScopedToNormalizedOrigin() {
154
+ openHost(url = "https://app.dynamicauth.com:443/waas-v1/env")
155
+ // Default https port normalized away → matches the page's origin.
156
+ assertEquals(setOf("https://app.dynamicauth.com"), originRulesSlot.captured)
157
+ }
158
+
159
+ @Test
160
+ fun open_loadsTheValidatedUrl() {
161
+ openHost(url = "https://app.dynamicauth.com/waas-v1/xyz")
162
+ assertEquals(
163
+ "https://app.dynamicauth.com/waas-v1/xyz",
164
+ shadowOf(webSlot.captured).lastLoadedUrl,
165
+ )
166
+ }
167
+
168
+ @Test
169
+ fun open_injectsDocumentStartScriptScopedToOriginWhenSupported() {
170
+ every {
171
+ WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)
172
+ } returns true
173
+
174
+ openHost(url = "https://app.dynamicauth.com/x")
175
+
176
+ verify {
177
+ WebViewCompat.addDocumentStartJavaScript(
178
+ any(),
179
+ BRIDGE_USER_SCRIPT,
180
+ setOf("https://app.dynamicauth.com"),
181
+ )
182
+ }
183
+ }
184
+
185
+ @Test
186
+ fun open_skipsDocumentStartScriptWhenUnsupported() {
187
+ openHost()
188
+ verify(exactly = 0) {
189
+ WebViewCompat.addDocumentStartJavaScript(any(), any(), any())
190
+ }
191
+ }
192
+
193
+ // endregion
194
+
195
+ // region per-instance profile (storage isolation)
196
+
197
+ @Test
198
+ fun open_assignsPerInstanceProfileWhenMultiProfileSupported() {
199
+ every {
200
+ WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE)
201
+ } returns true
202
+ val store = mockk<ProfileStore>(relaxed = true)
203
+ mockkStatic(ProfileStore::class)
204
+ every { ProfileStore.getInstance() } returns store
205
+
206
+ openHost(instanceId = "abc")
207
+
208
+ verify { store.getOrCreateProfile("dynamic-waas-abc") }
209
+ verify { WebViewCompat.setProfile(any(), "dynamic-waas-abc") }
210
+ }
211
+
212
+ @Test
213
+ fun open_skipsProfileWhenMultiProfileUnsupported() {
214
+ openHost()
215
+ verify(exactly = 0) { WebViewCompat.setProfile(any(), any()) }
216
+ }
217
+
218
+ // endregion
219
+
220
+ // region http-error vs commit race (#8)
221
+
222
+ @Test
223
+ fun httpErrorClaimsPromiseEvenWhenCommitFiresFirst() {
224
+ val deferred = slot<Runnable>()
225
+ // Capture the deferred resolve instead of running it inline.
226
+ every { reactContext.runOnUiQueueThread(capture(deferred)) } just Runs
227
+ val (subject, promise) = openHost()
228
+
229
+ subject.onCommitVisible() // schedules the deferred resolve
230
+ subject.onHttpError(503) // synchronously claims the pending promise
231
+ deferred.captured.run() // deferred resolve runs last — but promise is gone
232
+
233
+ verify { promise.reject("webview_http_error", "HTTP 503", any<WritableMap>()) }
234
+ verify(exactly = 0) { promise.resolve(any()) }
235
+ }
236
+
237
+ @Test
238
+ fun httpErrorBeforeCommit_reportsHttpError() {
239
+ val deferred = slot<Runnable>()
240
+ every { reactContext.runOnUiQueueThread(capture(deferred)) } just Runs
241
+ val (subject, promise) = openHost()
242
+
243
+ subject.onHttpError(404)
244
+ subject.onCommitVisible()
245
+ if (deferred.isCaptured) deferred.captured.run()
246
+
247
+ verify { promise.reject("webview_http_error", "HTTP 404", any<WritableMap>()) }
248
+ verify(exactly = 0) { promise.resolve(any()) }
249
+ }
250
+
251
+ @Test
252
+ fun commitVisibleResolvesWhenNoHttpError() {
253
+ val (subject, promise) = openHost()
254
+ subject.onCommitVisible()
255
+ verify { promise.resolve(null) }
256
+ }
257
+
258
+ // endregion
259
+
260
+ // region sendMessage via reply proxy (#9)
261
+
262
+ @Test
263
+ fun sendMessage_postsJsonViaReplyProxyVerbatimIncludingLineSeparators() {
264
+ val (subject, _) = openHost()
265
+
266
+ // The page opens the reply channel by posting first; invoke the captured
267
+ // listener to deliver a replyProxy to the host.
268
+ val proxy = mockk<JavaScriptReplyProxy>(relaxed = true)
269
+ val handshake = mockk<WebMessageCompat> { every { data } returns "{\"type\":\"ready\"}" }
270
+ listenerSlot.captured.onPostMessage(
271
+ webSlot.captured,
272
+ handshake,
273
+ Uri.parse("https://app.dynamicauth.com"),
274
+ true,
275
+ proxy,
276
+ )
277
+
278
+ // A dApp-controlled string containing U+2028 must round-trip verbatim —
279
+ // the old evaluateJavascript('...') path missed U+2028/U+2029 and would
280
+ // have broken out of the JS string literal.
281
+ val payload = JavaOnlyMap().apply {
282
+ putString("type", "signMessage")
283
+ putString("message", "approve\u2028tx")
284
+ }
285
+ subject.sendMessage(payload)
286
+
287
+ val sent = slot<String>()
288
+ verify { proxy.postMessage(capture(sent)) }
289
+ assertTrue(sent.captured.contains("\u2028"))
290
+ assertTrue(sent.captured.contains("signMessage"))
291
+ }
292
+
293
+ @Test
294
+ fun sendMessage_buffersBeforeReplyChannelThenFlushesInOrder() {
295
+ val (subject, _) = openHost()
296
+
297
+ // Host sends before the page has posted (no replyProxy yet): both must
298
+ // be buffered, not dropped.
299
+ subject.sendMessage(JavaOnlyMap().apply { putString("seq", "first") })
300
+ subject.sendMessage(JavaOnlyMap().apply { putString("seq", "second") })
301
+
302
+ // Page opens the reply channel → buffered messages flush in order.
303
+ val proxy = mockk<JavaScriptReplyProxy>(relaxed = true)
304
+ val handshake = mockk<WebMessageCompat> { every { data } returns "{\"type\":\"ready\"}" }
305
+ listenerSlot.captured.onPostMessage(
306
+ webSlot.captured,
307
+ handshake,
308
+ Uri.parse("https://app.dynamicauth.com"),
309
+ true,
310
+ proxy,
311
+ )
312
+
313
+ // The capturing list accumulates invocations in call order.
314
+ val sent = mutableListOf<String>()
315
+ verify(exactly = 2) { proxy.postMessage(capture(sent)) }
316
+ assertEquals(2, sent.size)
317
+ assertTrue(sent[0].contains("first"))
318
+ assertTrue(sent[1].contains("second"))
319
+ }
320
+
321
+ @Test
322
+ fun sendMessage_doesNotCrashWhenReplyChannelNeverOpens() {
323
+ val (subject, _) = openHost()
324
+ // No listener callback ever → message stays buffered, no crash.
325
+ subject.sendMessage(JavaOnlyMap().apply { putString("type", "early") })
326
+ }
327
+
328
+ @Test
329
+ fun bridgeListener_dropsNonMainFrameMessages() {
330
+ var delivered: Any? = "UNSET"
331
+ val (subject, _) = openHost(onMessage = { delivered = it })
332
+ val proxy = mockk<JavaScriptReplyProxy>(relaxed = true)
333
+ val message = mockk<WebMessageCompat> { every { data } returns "{\"type\":\"x\"}" }
334
+
335
+ listenerSlot.captured.onPostMessage(
336
+ webSlot.captured,
337
+ message,
338
+ Uri.parse("https://app.dynamicauth.com"),
339
+ false, // not main frame
340
+ proxy,
341
+ )
342
+
343
+ assertEquals("UNSET", delivered)
344
+ }
345
+
346
+ // endregion
347
+ }
@@ -0,0 +1,171 @@
1
+ package xyz.dynamic.client.webview
2
+
3
+ import android.app.Activity
4
+ import androidx.webkit.WebViewFeature
5
+ import com.facebook.react.bridge.Arguments
6
+ import com.facebook.react.bridge.JavaOnlyArray
7
+ import com.facebook.react.bridge.JavaOnlyMap
8
+ import com.facebook.react.bridge.Promise
9
+ import com.facebook.react.bridge.ReactApplicationContext
10
+ import com.facebook.react.bridge.ReadableMap
11
+ import com.facebook.react.bridge.WritableMap
12
+ import io.mockk.every
13
+ import io.mockk.mockk
14
+ import io.mockk.mockkStatic
15
+ import io.mockk.unmockkAll
16
+ import io.mockk.verify
17
+ import org.junit.After
18
+ import org.junit.Assert.assertEquals
19
+ import org.junit.Assert.assertTrue
20
+ import org.junit.Before
21
+ import org.junit.Test
22
+ import org.junit.runner.RunWith
23
+ import org.robolectric.RobolectricTestRunner
24
+ import org.robolectric.annotation.Config
25
+
26
+ // Runs under Robolectric for the android.* types. Arguments.createMap()/
27
+ // createArray() are JNI-backed (WritableNativeMap) and can't run off-device, so
28
+ // they are stubbed to return the pure-Java JavaOnlyMap/JavaOnlyArray — the
29
+ // canonical RN unit-test stand-ins for ReadableMap/WritableMap.
30
+ @RunWith(RobolectricTestRunner::class)
31
+ @Config(sdk = [35])
32
+ class DynamicWebViewHostTest {
33
+
34
+ private lateinit var reactContext: ReactApplicationContext
35
+
36
+ @Before
37
+ fun setUp() {
38
+ mockkStatic(Arguments::class)
39
+ every { Arguments.createMap() } answers { JavaOnlyMap() }
40
+ every { Arguments.createArray() } answers { JavaOnlyArray() }
41
+ reactContext = mockk(relaxed = true)
42
+ }
43
+
44
+ @After
45
+ fun tearDown() {
46
+ unmockkAll()
47
+ }
48
+
49
+ private fun host(onMessage: (Any?) -> Unit = {}, onRelease: () -> Unit = {}) =
50
+ DynamicWebViewHost(reactContext, "instance-1", onMessage, onRelease)
51
+
52
+ @Test
53
+ fun open_withNoForegroundActivity_rejectsAndReleases() {
54
+ every { reactContext.currentActivity } returns null
55
+ var released = false
56
+ val subject = host(onRelease = { released = true })
57
+ val promise = mockk<Promise>(relaxed = true)
58
+
59
+ subject.open("https://app.dynamic.xyz", promise)
60
+
61
+ verify {
62
+ promise.reject(
63
+ "webview_no_host_view",
64
+ "No foreground activity available to attach WebView.",
65
+ any<WritableMap>(),
66
+ )
67
+ }
68
+ assertTrue(released)
69
+ }
70
+
71
+ @Test
72
+ fun onBridgeMessage_passesNonJsonPayloadThroughUnchanged() {
73
+ every { reactContext.runOnUiQueueThread(any()) } answers {
74
+ firstArg<Runnable>().run()
75
+ }
76
+ var received: Any? = "UNSET"
77
+ val subject = host(onMessage = { received = it })
78
+
79
+ subject.onBridgeMessage("plain-text")
80
+
81
+ assertEquals("plain-text", received)
82
+ }
83
+
84
+ @Test
85
+ fun onBridgeMessage_marshalsJsonObjectIntoReadableMap() {
86
+ every { reactContext.runOnUiQueueThread(any()) } answers {
87
+ firstArg<Runnable>().run()
88
+ }
89
+ var received: Any? = null
90
+ val subject = host(onMessage = { received = it })
91
+
92
+ subject.onBridgeMessage("""{"foo":"bar","n":7,"flag":true}""")
93
+
94
+ val map = received as ReadableMap
95
+ assertEquals("bar", map.getString("foo"))
96
+ assertEquals(7, map.getInt("n"))
97
+ assertTrue(map.getBoolean("flag"))
98
+ }
99
+
100
+ // region URL validation (https + host pin)
101
+
102
+ // A non-https / hostless URL is rejected before any WebView is constructed,
103
+ // so a relaxed Activity stub (never actually touched on the reject path) is
104
+ // enough to get past the foreground-activity guard.
105
+ private fun assertRejectsInvalidUrl(url: String) {
106
+ every { reactContext.currentActivity } returns mockk<Activity>(relaxed = true)
107
+ val promise = mockk<Promise>(relaxed = true)
108
+
109
+ host().open(url, promise)
110
+
111
+ verify {
112
+ promise.reject("webview_invalid_url", "Invalid URL: $url", any<WritableMap>())
113
+ }
114
+ }
115
+
116
+ @Test
117
+ fun open_rejectsHttpScheme() = assertRejectsInvalidUrl("http://app.dynamicauth.com")
118
+
119
+ @Test
120
+ fun open_rejectsFileScheme() = assertRejectsInvalidUrl("file:///etc/passwd")
121
+
122
+ @Test
123
+ fun open_rejectsJavascriptScheme() = assertRejectsInvalidUrl("javascript:alert(1)")
124
+
125
+ @Test
126
+ fun open_rejectsContentScheme() = assertRejectsInvalidUrl("content://com.evil/secret")
127
+
128
+ @Test
129
+ fun open_rejectsAboutScheme() = assertRejectsInvalidUrl("about:blank")
130
+
131
+ @Test
132
+ fun open_rejectsHttpsWithoutHost() = assertRejectsInvalidUrl("https://")
133
+
134
+ // endregion
135
+
136
+ // region origin pin + fail-closed
137
+ //
138
+ // Both reject before any WebView is constructed (the WEB_MESSAGE_LISTENER
139
+ // check sits between URL validation and WebView creation), so they exercise
140
+ // originOf() normalization + expectedOrigin assignment without a real WebView.
141
+
142
+ @Test
143
+ fun open_pinsExpectedOriginAndFailsClosedWhenWebMessageListenerUnsupported() {
144
+ mockkStatic(WebViewFeature::class)
145
+ every { WebViewFeature.isFeatureSupported(any()) } returns false
146
+ every { reactContext.currentActivity } returns mockk<Activity>(relaxed = true)
147
+ val subject = host()
148
+ val promise = mockk<Promise>(relaxed = true)
149
+
150
+ // The explicit default https port (:443) must normalize away so the
151
+ // pinned origin matches the page's normalized origin / allowedOriginRules.
152
+ subject.open("https://app.dynamicauth.com:443/waas-v1/env", promise)
153
+
154
+ assertEquals("https://app.dynamicauth.com", subject.expectedOrigin)
155
+ verify { promise.reject("webview_unsupported", any<String>(), any<WritableMap>()) }
156
+ }
157
+
158
+ @Test
159
+ fun open_pinsExpectedOriginKeepingNonDefaultPort() {
160
+ mockkStatic(WebViewFeature::class)
161
+ every { WebViewFeature.isFeatureSupported(any()) } returns false
162
+ every { reactContext.currentActivity } returns mockk<Activity>(relaxed = true)
163
+ val subject = host()
164
+
165
+ subject.open("https://localhost:8443/waas", mockk(relaxed = true))
166
+
167
+ assertEquals("https://localhost:8443", subject.expectedOrigin)
168
+ }
169
+
170
+ // endregion
171
+ }