How it works
- The user opens cards in the native app.
- The app loads the hosted React page in a WebView.
- The user signs in on that page when the WebView has no Privy session.
- The page runs card signup and displays the card summary.
Render the card views
For the best experience, render the card views full screen in the WebView.Host the page on HTTPS. Add that origin in the Privy
Dashboard before the WebView loads
it.
Handling window popups
Card signup callswindow.open for provider terms and identity verification. Return that window to the page, and release it on close so the page can read closed.
- iOS
- Android
func webView(
_ webView: WKWebView,
createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction,
windowFeatures: WKWindowFeatures
) -> WKWebView? {
guard navigationAction.targetFrame == nil else { return nil }
let popup = WKWebView(frame: view.bounds, configuration: configuration)
popup.uiDelegate = self
view.addSubview(popup)
return popup
}
func webViewDidClose(_ webView: WKWebView) {
webView.removeFromSuperview()
}
settings.javaScriptCanOpenWindowsAutomatically = true
settings.setSupportMultipleWindows(true)
override fun onCreateWindow(
view: WebView,
isDialog: Boolean,
isUserGesture: Boolean,
resultMsg: Message
): Boolean {
val popup = WebView(view.context)
val transport = resultMsg.obj as WebView.WebViewTransport
transport.webView = popup
resultMsg.sendToTarget()
return true
}
override fun onCloseWindow(window: WebView) {
window.destroy()
}
Open the hosted page
The app loads the hosted React page in the WebView. On Android, enable JavaScript, DOM storage, and third-party cookies, and match the parent height so the cards layout keeps its size.- iOS
- Android
let config = WKWebViewConfiguration()
config.allowsInlineMediaPlayback = true
let webView = WKWebView(frame: view.bounds, configuration: config)
webView.uiDelegate = self
view.addSubview(webView)
if let url = URL(string: "https://your-domain.com/cards") {
webView.load(URLRequest(url: url))
}
val webView = WebView(this).apply {
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
}
webView.loadUrl("https://your-domain.com/cards")
Handling webview permissions
Identity verification uses the camera and microphone. Declare that access, then grant the capture request to the WebView.- iOS
- Android
Add these keys to
Info.plist.<key>NSCameraUsageDescription</key>
<string>This app uses the camera to verify the cardholder's identity.</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app uses the microphone to verify the cardholder's identity.</string>
func webView(
_ webView: WKWebView,
requestMediaCapturePermissionFor origin: WKSecurityOrigin,
initiatedByFrame frame: WKFrameInfo,
type: WKMediaCaptureType,
decisionHandler: @escaping (WKPermissionDecision) -> Void
) {
decisionHandler(.grant)
}
Add these permissions to
AndroidManifest.xml.<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { granted ->
if (granted.values.all { it }) webView.loadUrl(cardPageUrl)
}.launch(arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO))
override fun onPermissionRequest(request: PermissionRequest) {
request.grant(request.resources)
}
Complete example
- iOS
- Android
import UIKit
import WebKit
class CardViewController: UIViewController, WKUIDelegate {
private var webView: WKWebView!
private var popupWebView: WKWebView?
private let cardPageUrl = "https://your-domain.com/cards"
private let popupCloseButton: UIButton = {
let button = UIButton(type: .system)
button.setTitle("Close", for: .normal)
button.isHidden = true
return button
}()
override func viewDidLoad() {
super.viewDidLoad()
let config = WKWebViewConfiguration()
config.allowsInlineMediaPlayback = true
webView = WKWebView(frame: view.bounds, configuration: config)
webView.uiDelegate = self
webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(webView)
popupCloseButton.addTarget(self, action: #selector(closePopup), for: .touchUpInside)
popupCloseButton.frame = CGRect(x: 16, y: 16, width: 72, height: 32)
view.addSubview(popupCloseButton)
if let url = URL(string: cardPageUrl) {
webView.load(URLRequest(url: url))
}
}
func webView(
_ webView: WKWebView,
createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction,
windowFeatures: WKWindowFeatures
) -> WKWebView? {
guard navigationAction.targetFrame == nil else { return nil }
popupWebView?.removeFromSuperview()
// Use this configuration so JavaScript receives the new window.
let popup = WKWebView(frame: view.bounds, configuration: configuration)
popup.uiDelegate = self
popup.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(popup)
popupWebView = popup
popupCloseButton.isHidden = false
view.bringSubviewToFront(popupCloseButton)
return popup
}
func webViewDidClose(_ webView: WKWebView) {
guard webView == popupWebView else { return }
webView.removeFromSuperview()
popupWebView = nil
popupCloseButton.isHidden = true
}
@objc private func closePopup() {
popupWebView?.removeFromSuperview()
popupWebView = nil
popupCloseButton.isHidden = true
}
func webView(
_ webView: WKWebView,
requestMediaCapturePermissionFor origin: WKSecurityOrigin,
initiatedByFrame frame: WKFrameInfo,
type: WKMediaCaptureType,
decisionHandler: @escaping (WKPermissionDecision) -> Void
) {
decisionHandler(.grant)
}
}
import android.Manifest
import android.annotation.SuppressLint
import android.content.pm.PackageManager
import android.os.Bundle
import android.os.Message
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.webkit.CookieManager
import android.webkit.PermissionRequest
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
class CardActivity : AppCompatActivity() {
private val cardPageUrl = "https://your-domain.com/cards"
private lateinit var root: FrameLayout
private var webView: WebView? = null
private var popupWebView: WebView? = null
private var popupLayer: FrameLayout? = null
private val requestCapture = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) {
webView?.loadUrl(cardPageUrl)
}
private val backCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
when {
popupWebView != null -> dismissPopup(popupWebView)
webView?.canGoBack() == true -> webView?.goBack()
else -> {
isEnabled = false
onBackPressedDispatcher.onBackPressed()
}
}
}
}
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
onBackPressedDispatcher.addCallback(this, backCallback)
root = FrameLayout(this)
setContentView(root)
val wv = newWebView(onCreatePopup = { resultMsg ->
val popup = newWebView(onCreatePopup = null, onClosePopup = { dismissPopup(popupWebView) })
showPopup(popup)
val transport = resultMsg.obj as WebView.WebViewTransport
transport.webView = popup
resultMsg.sendToTarget()
})
webView = wv
root.addView(wv, FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT))
val needed = arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO)
val missing = needed.any {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (missing) {
requestCapture.launch(needed)
} else {
wv.loadUrl(cardPageUrl)
}
}
override fun onResume() {
super.onResume()
webView?.onResume()
popupWebView?.onResume()
}
override fun onPause() {
webView?.onPause()
popupWebView?.onPause()
super.onPause()
}
@SuppressLint("SetJavaScriptEnabled")
private fun newWebView(
onCreatePopup: ((Message) -> Unit)?,
onClosePopup: (() -> Unit)? = null
): WebView {
return WebView(this).apply {
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.javaScriptCanOpenWindowsAutomatically = true
settings.setSupportMultipleWindows(true)
settings.mediaPlaybackRequiresUserGesture = false
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
webViewClient = WebViewClient()
webChromeClient = object : WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest) {
request.grant(request.resources)
}
override fun onCreateWindow(
view: WebView,
isDialog: Boolean,
isUserGesture: Boolean,
resultMsg: Message
): Boolean {
val create = onCreatePopup ?: return false
create(resultMsg)
return true
}
override fun onCloseWindow(window: WebView) {
onClosePopup?.invoke()
}
}
}
}
private fun showPopup(popup: WebView) {
dismissPopup(popupWebView)
popup.onResume()
val layer = FrameLayout(this)
layer.addView(popup, FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT))
root.addView(layer, FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT))
popupLayer = layer
popupWebView = popup
}
private fun dismissPopup(popup: WebView?) {
val layer = popupLayer ?: return
root.removeView(layer)
popupLayer = null
popupWebView = null
popup?.destroy()
}
}

