@dynamic-labs/react-native-extension 4.81.0 → 4.83.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/EmbeddedWebViewController.kt +476 -0
- package/android/EmbeddedWebViewModule.kt +55 -0
- package/android/KeyStoreKeyManager.kt +7 -1
- package/android/dynamic/embeddedwebview/EmbeddedWebViewController.kt +476 -0
- package/android/dynamic/embeddedwebview/EmbeddedWebViewModule.kt +55 -0
- package/android/dynamic/keychain/KeyStoreKeyManager.kt +7 -1
- package/android/embeddedwebview/EmbeddedWebViewController.kt +476 -0
- package/android/embeddedwebview/EmbeddedWebViewModule.kt +55 -0
- package/android/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewController.kt +476 -0
- package/android/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewModule.kt +55 -0
- package/android/java/xyz/dynamic/keychain/KeyStoreKeyManager.kt +7 -1
- package/android/keychain/KeyStoreKeyManager.kt +7 -1
- package/android/main/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewController.kt +476 -0
- package/android/main/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewModule.kt +55 -0
- package/android/main/java/xyz/dynamic/keychain/KeyStoreKeyManager.kt +7 -1
- package/android/src/main/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewController.kt +476 -0
- package/android/src/main/java/xyz/dynamic/embeddedwebview/EmbeddedWebViewModule.kt +55 -0
- package/android/src/main/java/xyz/dynamic/keychain/KeyStoreKeyManager.kt +7 -1
- package/android/xyz/dynamic/embeddedwebview/EmbeddedWebViewController.kt +476 -0
- package/android/xyz/dynamic/embeddedwebview/EmbeddedWebViewModule.kt +55 -0
- package/android/xyz/dynamic/keychain/KeyStoreKeyManager.kt +7 -1
- package/expo-module.config.json +5 -2
- package/index.cjs +178 -42
- package/index.js +178 -42
- package/ios/EmbeddedWebViewController.swift +426 -0
- package/ios/EmbeddedWebViewModule.swift +62 -0
- package/ios/Keychain.podspec +2 -2
- package/package.json +6 -6
- package/src/ReactNativeExtension/ReactNativeExtension.d.ts +24 -1
- package/src/components/WebView/EmbeddedWebView/EmbeddedWebView.d.ts +7 -0
- package/src/components/WebView/EmbeddedWebView/index.d.ts +1 -0
- package/src/components/WebView/utils/shouldAllowNavigation/index.d.ts +1 -0
- package/src/components/WebView/utils/shouldAllowNavigation/shouldAllowNavigation.d.ts +5 -0
- package/src/errors/WebViewFailedToLoadError.d.ts +84 -1
- 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
|
+
}
|
|
@@ -39,7 +39,13 @@ class KeyStoreKeyManager {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
fun generateKeyPair(alias: String): String {
|
|
42
|
-
|
|
42
|
+
|
|
43
|
+
// Defensive: if alias somehow exists, clean it up before generating.
|
|
44
|
+
// Matches iOS Secure Enclave's permissive overwrite semantics and keeps
|
|
45
|
+
// the function single-call atomic so callers don't need to pre-delete.
|
|
46
|
+
if (hasKey(alias)) {
|
|
47
|
+
deleteKey(alias)
|
|
48
|
+
}
|
|
43
49
|
|
|
44
50
|
val specBuilder = KeyGenParameterSpec.Builder(
|
|
45
51
|
alias,
|
package/expo-module.config.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"platforms": ["ios", "android"],
|
|
3
3
|
"ios": {
|
|
4
|
-
"modules": ["KeychainModule"]
|
|
4
|
+
"modules": ["KeychainModule", "EmbeddedWebViewModule"]
|
|
5
5
|
},
|
|
6
6
|
"android": {
|
|
7
|
-
"modules": [
|
|
7
|
+
"modules": [
|
|
8
|
+
"xyz.dynamic.keychain.KeychainModule",
|
|
9
|
+
"xyz.dynamic.embeddedwebview.EmbeddedWebViewModule"
|
|
10
|
+
]
|
|
8
11
|
}
|
|
9
12
|
}
|