@dynamic-labs/react-native-extension 4.80.0 → 4.83.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 (27) hide show
  1. package/android/EmbeddedWebViewController.kt +476 -0
  2. package/android/EmbeddedWebViewModule.kt +55 -0
  3. package/android/dynamic/embeddedwebview/EmbeddedWebViewController.kt +476 -0
  4. package/android/dynamic/embeddedwebview/EmbeddedWebViewModule.kt +55 -0
  5. package/android/embeddedwebview/EmbeddedWebViewController.kt +476 -0
  6. package/android/embeddedwebview/EmbeddedWebViewModule.kt +55 -0
  7. package/android/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewController.kt +476 -0
  8. package/android/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewModule.kt +55 -0
  9. package/android/main/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewController.kt +476 -0
  10. package/android/main/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewModule.kt +55 -0
  11. package/android/src/main/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewController.kt +476 -0
  12. package/android/src/main/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewModule.kt +55 -0
  13. package/android/xyz/dynamic/embeddedwebview/EmbeddedWebViewController.kt +476 -0
  14. package/android/xyz/dynamic/embeddedwebview/EmbeddedWebViewModule.kt +55 -0
  15. package/expo-module.config.json +5 -2
  16. package/index.cjs +172 -39
  17. package/index.js +172 -39
  18. package/ios/EmbeddedWebViewController.swift +426 -0
  19. package/ios/EmbeddedWebViewModule.swift +62 -0
  20. package/ios/Keychain.podspec +2 -2
  21. package/package.json +6 -6
  22. package/src/ReactNativeExtension/ReactNativeExtension.d.ts +24 -1
  23. package/src/components/WebView/EmbeddedWebView/EmbeddedWebView.d.ts +7 -0
  24. package/src/components/WebView/EmbeddedWebView/index.d.ts +1 -0
  25. package/src/components/WebView/utils/shouldAllowNavigation/index.d.ts +1 -0
  26. package/src/components/WebView/utils/shouldAllowNavigation/shouldAllowNavigation.d.ts +5 -0
  27. package/src/nativeModules/EmbeddedWebView.d.ts +29 -0
@@ -0,0 +1,476 @@
1
+ package xyz.dynamic.embeddedwebview
2
+
3
+ import android.annotation.SuppressLint
4
+ import android.content.pm.ApplicationInfo
5
+ import android.graphics.Bitmap
6
+ import android.graphics.Color
7
+ import android.net.http.SslError
8
+ import android.os.Handler
9
+ import android.os.Looper
10
+ import android.view.View
11
+ import android.view.ViewGroup
12
+ import android.webkit.JavascriptInterface
13
+ import android.webkit.RenderProcessGoneDetail
14
+ import android.webkit.SslErrorHandler
15
+ import android.webkit.WebChromeClient
16
+ import android.webkit.WebResourceError
17
+ import android.webkit.WebResourceRequest
18
+ import android.webkit.WebResourceResponse
19
+ import android.webkit.WebView
20
+ import android.webkit.WebViewClient
21
+ import android.widget.FrameLayout
22
+ import expo.modules.kotlin.AppContext
23
+ import org.json.JSONObject
24
+ import java.util.UUID
25
+
26
+ private const val SCRIPT_HANDLER_NAME = "DynamicEmbeddedWebView"
27
+ private const val NAVIGATION_DECISION_TIMEOUT_MS = 2000L
28
+
29
+ // Singleton owning the in-app overlay view that hosts the WebView.
30
+ // Mirrors EmbeddedWebViewController on iOS.
31
+ //
32
+ // Lifecycle: lazy creation on the first setUrl call, retained until destroy()
33
+ // is invoked explicitly. The overlay is attached to the activity's decorView
34
+ // so it sits above the React Native root view (which lives at
35
+ // android.R.id.content). RN's reconciler does not traverse decorView, so the
36
+ // overlay is effectively outside the React Native ecosystem.
37
+ object EmbeddedWebViewController {
38
+ private val mainHandler = Handler(Looper.getMainLooper())
39
+
40
+ private var appContext: AppContext? = null
41
+ private var eventEmitter: ((String, Map<String, Any?>) -> Unit)? = null
42
+
43
+ private var overlayContainer: FrameLayout? = null
44
+ private var webView: WebView? = null
45
+ private var debuggingEnabled = false
46
+
47
+ private val pendingNavigationUrls = mutableMapOf<String, String>()
48
+ private val pendingTimeouts = mutableMapOf<String, Runnable>()
49
+
50
+ // Cleartext http is only permitted when the consuming app is debuggable.
51
+ // Release builds reject http top-frame and sub-frame navigations regardless
52
+ // of the JS allowlist, so a JS compromise cannot trick production into
53
+ // loading http content. Reads the application's FLAG_DEBUGGABLE — set
54
+ // automatically by the build system based on the manifest's debuggable
55
+ // attribute, which is true for debug builds and false for release.
56
+ private val allowsHttpScheme: Boolean
57
+ get() {
58
+ val flags = appContext?.reactContext?.applicationInfo?.flags ?: 0
59
+ return (flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
60
+ }
61
+
62
+ // Single-element bypass for re-entry into shouldOverrideUrlLoading.
63
+ // When JS approves a navigation, we call webView.loadUrl(url) which may
64
+ // re-trigger shouldOverrideUrlLoading on the same URL. We stash the URL
65
+ // here so the re-entry returns false (let WebView handle it) instead of
66
+ // prompting JS again — avoiding a potential approval loop.
67
+ private var bypassUrl: String? = null
68
+
69
+ private var emitterToken: java.util.UUID? = null
70
+
71
+ // Module-side hook: assign the emitter under a token. The token lets
72
+ // detach() avoid clobbering an emitter that a newer attach() already
73
+ // installed — relevant during dev hot reloads where module instances
74
+ // briefly overlap.
75
+ fun attach(
76
+ context: AppContext,
77
+ emitter: (String, Map<String, Any?>) -> Unit,
78
+ ): java.util.UUID {
79
+ val token = java.util.UUID.randomUUID()
80
+ this.appContext = context
81
+ this.eventEmitter = emitter
82
+ this.emitterToken = token
83
+ return token
84
+ }
85
+
86
+ fun detach(token: java.util.UUID) {
87
+ if (this.emitterToken == token) {
88
+ this.eventEmitter = null
89
+ this.emitterToken = null
90
+ }
91
+ }
92
+
93
+ // ---- Public control surface (called from the Expo module) -----------------
94
+
95
+ fun setUrl(url: String) {
96
+ runOnMain {
97
+ // Match react-native-webview parity: a malformed URL must surface as a
98
+ // load error instead of silently no-op'ing, so the JS side throws
99
+ // WebViewFailedToLoadError.
100
+ if (!isValidUrl(url)) {
101
+ emitLoadError(
102
+ url = url,
103
+ code = -1,
104
+ domain = "EmbeddedWebViewInvalidUrl",
105
+ description = "Invalid URL: $url",
106
+ isProvisional = true,
107
+ )
108
+ return@runOnMain
109
+ }
110
+ val webView = ensureWebView() ?: return@runOnMain
111
+ webView.loadUrl(url)
112
+ }
113
+ }
114
+
115
+ private fun isValidUrl(url: String): Boolean {
116
+ if (url.isBlank()) return false
117
+ val parsed = runCatching { android.net.Uri.parse(url) }.getOrNull() ?: return false
118
+ return !parsed.scheme.isNullOrBlank()
119
+ }
120
+
121
+ fun setVisible(visible: Boolean) {
122
+ runOnMain {
123
+ ensureWebView() ?: return@runOnMain
124
+ updateVisibility(visible)
125
+ }
126
+ }
127
+
128
+ fun setDebuggingEnabled(enabled: Boolean) {
129
+ runOnMain {
130
+ debuggingEnabled = enabled
131
+ WebView.setWebContentsDebuggingEnabled(enabled)
132
+ }
133
+ }
134
+
135
+ fun destroy() {
136
+ runOnMain { teardown() }
137
+ }
138
+
139
+ fun postMessage(message: String) {
140
+ runOnMain {
141
+ val webView = webView ?: return@runOnMain
142
+ val escaped = JSONObject.quote(message)
143
+ webView.evaluateJavascript(
144
+ "window.dispatchEvent(new MessageEvent('message', { data: $escaped }));",
145
+ null,
146
+ )
147
+ }
148
+ }
149
+
150
+ fun respondToShouldStartLoad(id: String, allow: Boolean) {
151
+ runOnMain {
152
+ val url = pendingNavigationUrls.remove(id) ?: return@runOnMain
153
+ pendingTimeouts.remove(id)?.let { mainHandler.removeCallbacks(it) }
154
+ if (allow) {
155
+ // Set the bypass before calling loadUrl so the re-entry into
156
+ // shouldOverrideUrlLoading returns false (let the WebView handle it
157
+ // natively) instead of prompting JS again for the same URL.
158
+ bypassUrl = url
159
+ webView?.loadUrl(url)
160
+ }
161
+ }
162
+ }
163
+
164
+ private fun emitLoadError(
165
+ url: String,
166
+ code: Int,
167
+ domain: String,
168
+ description: String,
169
+ isProvisional: Boolean,
170
+ ) {
171
+ eventEmitter?.invoke(
172
+ "onLoadError",
173
+ mapOf(
174
+ "url" to url,
175
+ "code" to code,
176
+ "domain" to domain,
177
+ "description" to description,
178
+ "isProvisional" to isProvisional,
179
+ ),
180
+ )
181
+ }
182
+
183
+ // ---- Lazy creation --------------------------------------------------------
184
+
185
+ @SuppressLint("SetJavaScriptEnabled", "JavascriptInterface")
186
+ private fun ensureWebView(): WebView? {
187
+ webView?.let { return it }
188
+
189
+ val activity = appContext?.currentActivity ?: return null
190
+
191
+ WebView.setWebContentsDebuggingEnabled(debuggingEnabled)
192
+
193
+ val webView = WebView(activity).apply {
194
+ setBackgroundColor(Color.TRANSPARENT)
195
+ settings.javaScriptEnabled = true
196
+ settings.domStorageEnabled = true
197
+ settings.databaseEnabled = true
198
+ settings.mediaPlaybackRequiresUserGesture = false
199
+ settings.allowFileAccess = false
200
+ settings.allowContentAccess = false
201
+ // Match react-native-webview's `setSupportMultipleWindows={false}`:
202
+ // refuse `target=_blank` / window.open popups so they don't escape
203
+ // the embedded webview into an unmanaged context.
204
+ settings.setSupportMultipleWindows(false)
205
+ addJavascriptInterface(JsBridge(), SCRIPT_HANDLER_NAME)
206
+ webViewClient = NavigationClient()
207
+ webChromeClient = NoPopupWebChromeClient()
208
+ }
209
+
210
+ val container = FrameLayout(activity).apply {
211
+ setBackgroundColor(Color.TRANSPARENT)
212
+ addView(
213
+ webView,
214
+ FrameLayout.LayoutParams(
215
+ ViewGroup.LayoutParams.MATCH_PARENT,
216
+ ViewGroup.LayoutParams.MATCH_PARENT,
217
+ ),
218
+ )
219
+ // INVISIBLE (not GONE) so the WebView stays attached to its window
220
+ // and its JS runtime keeps running. View.dispatchTouchEvent
221
+ // short-circuits for non-VISIBLE views, so touches pass through to
222
+ // the RN content underneath.
223
+ visibility = View.INVISIBLE
224
+ }
225
+
226
+ // Attach the overlay above the activity's content view. The decorView is
227
+ // the root of the activity's window — its children include the system bar
228
+ // backgrounds and android.R.id.content (where RN's root view lives).
229
+ // Adding our overlay last places it on top in draw order.
230
+ val decor = activity.window.decorView as ViewGroup
231
+ decor.addView(
232
+ container,
233
+ ViewGroup.LayoutParams(
234
+ ViewGroup.LayoutParams.MATCH_PARENT,
235
+ ViewGroup.LayoutParams.MATCH_PARENT,
236
+ ),
237
+ )
238
+
239
+ this.overlayContainer = container
240
+ this.webView = webView
241
+ return webView
242
+ }
243
+
244
+ private fun updateVisibility(visible: Boolean) {
245
+ val container = overlayContainer ?: return
246
+
247
+ // INVISIBLE (not GONE) when hidden: the WebView stays attached to its
248
+ // window so its JS runtime keeps processing fetch/XHR/postMessage,
249
+ // and View.dispatchTouchEvent short-circuits for non-VISIBLE views so
250
+ // touches pass through to the RN content beneath.
251
+ container.visibility = if (visible) View.VISIBLE else View.INVISIBLE
252
+
253
+ if (visible) {
254
+ // Defensively re-raise to the top of the decorView's draw order in case
255
+ // some other library inserted siblings above us after our initial attach.
256
+ container.bringToFront()
257
+ (container.parent as? View)?.invalidate()
258
+ container.requestFocus()
259
+ }
260
+ }
261
+
262
+ private fun teardown() {
263
+ overlayContainer?.let { container ->
264
+ (container.parent as? ViewGroup)?.removeView(container)
265
+ container.removeAllViews()
266
+ }
267
+ webView?.let {
268
+ it.stopLoading()
269
+ try {
270
+ it.removeJavascriptInterface(SCRIPT_HANDLER_NAME)
271
+ } catch (_: Exception) {
272
+ // No-op
273
+ }
274
+ it.webChromeClient = null
275
+ it.destroy()
276
+ }
277
+ overlayContainer = null
278
+ webView = null
279
+ pendingNavigationUrls.clear()
280
+ pendingTimeouts.values.forEach { mainHandler.removeCallbacks(it) }
281
+ pendingTimeouts.clear()
282
+ bypassUrl = null
283
+ }
284
+
285
+ private fun runOnMain(action: () -> Unit) {
286
+ if (Looper.myLooper() == Looper.getMainLooper()) {
287
+ action()
288
+ } else {
289
+ mainHandler.post(action)
290
+ }
291
+ }
292
+
293
+ // ---- Bridges --------------------------------------------------------------
294
+
295
+ private class JsBridge {
296
+ @JavascriptInterface
297
+ fun postMessage(message: String) {
298
+ mainHandler.post {
299
+ eventEmitter?.invoke("onMessage", mapOf("message" to message))
300
+ }
301
+ }
302
+ }
303
+
304
+ private class NavigationClient : WebViewClient() {
305
+ override fun shouldOverrideUrlLoading(
306
+ view: WebView,
307
+ request: WebResourceRequest,
308
+ ): Boolean {
309
+ val url = request.url.toString()
310
+ val scheme = request.url.scheme?.lowercase()
311
+ val httpAllowed = allowsHttpScheme
312
+
313
+ // Sub-frame requests are auto-allowed (matches iOS + JS allowlist).
314
+ // The trusted top-frame controls iframe content; iframes routinely
315
+ // start as `about:blank` and use `blob:` / `data:` / `about:srcdoc`
316
+ // URLs for legitimate functionality (WaaS MPC iframes, web workers,
317
+ // sandboxed inline content), so we defer iframe trust to the top-frame.
318
+ // shouldOverrideUrlLoading: false = let WebView load.
319
+ if (!request.isForMainFrame) return false
320
+
321
+ // Re-entry from our own loadUrl after JS approval: let the WebView
322
+ // handle it without a second JS round-trip.
323
+ if (url == bypassUrl) {
324
+ bypassUrl = null
325
+ return false
326
+ }
327
+
328
+ // Defense-in-depth: reject non-https top-frame schemes before prompting
329
+ // JS. The JS allowlist would also reject these, but a JS bug must not be
330
+ // enough to load `javascript:` / `file:` / `data:` / custom schemes.
331
+ // http is permitted only in debug builds.
332
+ val isAllowedTopFrameScheme =
333
+ scheme == "https" || (httpAllowed && scheme == "http")
334
+ if (!isAllowedTopFrameScheme) {
335
+ emitLoadError(
336
+ url = url,
337
+ code = -1,
338
+ domain = "EmbeddedWebViewBlockedScheme",
339
+ description = "Blocked navigation to disallowed scheme: $url",
340
+ isProvisional = true,
341
+ )
342
+ return true
343
+ }
344
+
345
+ val id = UUID.randomUUID().toString()
346
+ pendingNavigationUrls[id] = url
347
+
348
+ val timeout = Runnable {
349
+ // 2 s default-cancel: drop the stashed URL and surface as a load
350
+ // error so JS sees the timeout (matches iOS's cancellation semantics
351
+ // but adds an explicit signal — iOS gets WebKitErrorDomain 102, which
352
+ // is filtered out, so neither side emits a network error).
353
+ if (pendingNavigationUrls.remove(id) != null) {
354
+ pendingTimeouts.remove(id)
355
+ emitLoadError(
356
+ url = url,
357
+ code = -1,
358
+ domain = "EmbeddedWebViewNavigationTimeout",
359
+ description = "Navigation decision timed out after ${NAVIGATION_DECISION_TIMEOUT_MS}ms",
360
+ isProvisional = true,
361
+ )
362
+ }
363
+ }
364
+ pendingTimeouts[id] = timeout
365
+ mainHandler.postDelayed(timeout, NAVIGATION_DECISION_TIMEOUT_MS)
366
+
367
+ eventEmitter?.invoke(
368
+ "onShouldStartLoad",
369
+ mapOf(
370
+ "id" to id,
371
+ "url" to url,
372
+ "isTopFrame" to true,
373
+ ),
374
+ )
375
+
376
+ // Cancel; if JS allows, respondToShouldStartLoad re-invokes loadUrl.
377
+ return true
378
+ }
379
+
380
+ override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {
381
+ super.onPageStarted(view, url, favicon)
382
+ // Inject the ReactNativeWebView polyfill so the webview-controller's
383
+ // existing outbound code (window.ReactNativeWebView.postMessage) routes
384
+ // through our JavaScriptInterface — same surface as iOS.
385
+ val polyfill =
386
+ "(function() {" +
387
+ "if (window.ReactNativeWebView) return;" +
388
+ "window.ReactNativeWebView = {" +
389
+ "postMessage: function(message) {" +
390
+ "window." + SCRIPT_HANDLER_NAME + ".postMessage(message);" +
391
+ "}" +
392
+ "};" +
393
+ "})();"
394
+ view.evaluateJavascript(polyfill, null)
395
+ }
396
+
397
+ override fun onReceivedError(
398
+ view: WebView,
399
+ request: WebResourceRequest,
400
+ error: WebResourceError,
401
+ ) {
402
+ if (!request.isForMainFrame) return
403
+ emitLoadError(
404
+ url = request.url.toString(),
405
+ code = error.errorCode,
406
+ domain = "EmbeddedWebViewLoadError",
407
+ description = error.description?.toString().orEmpty(),
408
+ isProvisional = true,
409
+ )
410
+ }
411
+
412
+ override fun onReceivedHttpError(
413
+ view: WebView,
414
+ request: WebResourceRequest,
415
+ errorResponse: WebResourceResponse,
416
+ ) {
417
+ if (!request.isForMainFrame) return
418
+ emitLoadError(
419
+ url = request.url.toString(),
420
+ code = errorResponse.statusCode,
421
+ domain = "EmbeddedWebViewHttpError",
422
+ description = errorResponse.reasonPhrase ?: "HTTP ${errorResponse.statusCode}",
423
+ isProvisional = false,
424
+ )
425
+ }
426
+
427
+ override fun onReceivedSslError(
428
+ view: WebView,
429
+ handler: SslErrorHandler,
430
+ error: SslError,
431
+ ) {
432
+ // Always reject — never proceed past an SSL error. Default
433
+ // WebViewClient already cancels but emits no event; surface it so the
434
+ // SDK's load-error path runs.
435
+ handler.cancel()
436
+ emitLoadError(
437
+ url = error.url ?: view.url ?: "",
438
+ code = error.primaryError,
439
+ domain = "EmbeddedWebViewSslError",
440
+ description = "SSL error: ${error.primaryError}",
441
+ isProvisional = true,
442
+ )
443
+ }
444
+
445
+ override fun onRenderProcessGone(
446
+ view: WebView,
447
+ detail: RenderProcessGoneDetail,
448
+ ): Boolean {
449
+ emitLoadError(
450
+ url = view.url ?: "",
451
+ code = -1,
452
+ domain = "EmbeddedWebViewProcessTerminated",
453
+ description = "WebContent process terminated",
454
+ isProvisional = false,
455
+ )
456
+ // Returning true tells the system we've handled it so the host process
457
+ // is not killed. The webView is in an unusable state; we tear it down.
458
+ runOnMain { teardown() }
459
+ return true
460
+ }
461
+ }
462
+
463
+ // Refuse target=_blank / window.open popups (matches react-native-webview's
464
+ // `setSupportMultipleWindows={false}` semantics: there's nowhere reasonable
465
+ // to open the new window inside the overlay).
466
+ private class NoPopupWebChromeClient : WebChromeClient() {
467
+ override fun onCreateWindow(
468
+ view: WebView,
469
+ isDialog: Boolean,
470
+ isUserGesture: Boolean,
471
+ resultMsg: android.os.Message?,
472
+ ): Boolean {
473
+ return false
474
+ }
475
+ }
476
+ }
@@ -0,0 +1,55 @@
1
+ package xyz.dynamic.embeddedwebview
2
+
3
+ import expo.modules.kotlin.modules.Module
4
+ import expo.modules.kotlin.modules.ModuleDefinition
5
+ import java.util.UUID
6
+
7
+ class EmbeddedWebViewModule : Module() {
8
+ private var emitterToken: UUID? = null
9
+
10
+ override fun definition() = ModuleDefinition {
11
+ Name("EmbeddedWebView")
12
+
13
+ Events("onMessage", "onShouldStartLoad", "onLoadError")
14
+
15
+ OnCreate {
16
+ emitterToken = EmbeddedWebViewController.attach(appContext) { name, payload ->
17
+ sendEvent(name, payload)
18
+ }
19
+ }
20
+
21
+ OnDestroy {
22
+ // Only clear if our emitter is still the live one. Prevents a stale
23
+ // OnDestroy (e.g. during dev hot reload after a newer OnCreate already
24
+ // ran) from nulling the active emitter.
25
+ emitterToken?.let { token ->
26
+ EmbeddedWebViewController.detach(token)
27
+ emitterToken = null
28
+ }
29
+ }
30
+
31
+ AsyncFunction("setUrl") { url: String ->
32
+ EmbeddedWebViewController.setUrl(url)
33
+ }
34
+
35
+ AsyncFunction("setVisible") { visible: Boolean ->
36
+ EmbeddedWebViewController.setVisible(visible)
37
+ }
38
+
39
+ AsyncFunction("setDebuggingEnabled") { enabled: Boolean ->
40
+ EmbeddedWebViewController.setDebuggingEnabled(enabled)
41
+ }
42
+
43
+ AsyncFunction("destroy") {
44
+ EmbeddedWebViewController.destroy()
45
+ }
46
+
47
+ AsyncFunction("postMessage") { message: String ->
48
+ EmbeddedWebViewController.postMessage(message)
49
+ }
50
+
51
+ AsyncFunction("respondToShouldStartLoad") { id: String, allow: Boolean ->
52
+ EmbeddedWebViewController.respondToShouldStartLoad(id, allow)
53
+ }
54
+ }
55
+ }