Skip to content
Developer Tools
Accelerate development using AI assistants and tools

How to Support Universal Link, App Link and Scheme to Launch Apps in App WebView ​

Some payment channels may redirect users to other applications during transactions, such as known e-wallets: Indonesia (DANA, GOPAY, SHOPEEPAY), South Korea (NAVERPAY, PAYCO, TOSS, KAKAOPAY, SSGPAY), Malaysia (TNG, AXIATABOOST), Philippines (GCASH), Singapore (SHOPEEPAY), Thailand (PAOTANGPAY, SHOPEEPAY, TRUEMONEY, LINEPAY), Japan (AUPAY, MERPAY, PAYPAY), Chinese Taiwan (JKOPAY), Chinese Hong Kong (ALIPAYHK). All of them can directly launch the wallet App for payment.

However, inside WebView, the redirect flow may be blocked (error: ERR_UNKNOWN_URL_SCHEME). As a result, users cannot complete payment smoothly, or they will be forced into the downgraded H5 payment flow (extra information needs to be entered on the H5 page or authorization completed inside the wallet App).

  • Recommended Method: Use the mobile system browser to open the PayerMax checkout page

  • Advantages:

    • The system browser does not restrict redirect behavior
    • It ensures that wallet redirect payments can jump to the corresponding App successfully

⚠️ If your product needs to simplify the user payment process and support correct handling of redirection inside the App WebView to launch the wallets mentioned above for payment, please ensure the merchant App WebView completes the optimization adjustments below (you can let AI read this document for implementation):

Android ​

Principle ​


Kotlin Implementation ​

  1. Custom WebViewClientDeepLinkWebViewClient.kt

The code below only depends on Android SDK and can be copied directly to your project (remember to update the top package name)

Kotlin
package com.example.deeplink

import android.content.ActivityNotFoundException
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.net.Uri
import android.os.Build
import android.os.SystemClock
import android.view.MotionEvent
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient

/**
 * Forward App Link, custom scheme and intent:// inside WebView.
 * No enumeration of target App domains, package names or schemes.
 *
 * Rules to judge whether a navigation is a user intent: the navigation comes with a gesture,
 * it is a server redirect, or it falls within the activation window after the user’s latest operation.
 * For https App Link: handlers that only declare host without path are treated as landing pages
 * and remain in WebView first; App Link with path, custom scheme and intent:// are all recognized for direct launch.
 *
 * The activation window is required because many payment pages trigger deep links asynchronously via scripts
 * after page load (location.href = "xxx://…"). This jump carries no user gesture and would be missed
 * if only hasGesture() is checked. LINE Pay’s liff.line.me is a landing page and will not be launched directly via the window.
 *
 * Sub-frame (iframe) navigation will also trigger callbacks: Chromium only passes non http(s) scheme sub-frame navigation
 * to the host. These navigations are deep links such as nidlogin:// and must be processed the same way as main-frame navigation.
 * Sub-frame http(s) navigation is left to WebView.
 *
 * @param requireUserGesture Whether user intent (gesture, redirect, or inside activation window; landing pages excluded) is required
 * @param activation Host-side activation status tracker, default 10s window (Chromium’s kActivationLifespan is 5s)
 * @param onOpenFailed Callback for unhandled custom scheme / intent:// or failed App Link launch
 */
class DeepLinkWebViewClient(
    private val requireUserGesture: Boolean = true,
    private val activation: UserActivationTracker = UserActivationTracker(),
    private val onOpenFailed: (Uri) -> Unit = {},
) : WebViewClient() {

    private var webView: WebView? = null

    /**
     * Navigation initiated by the host on behalf of the user (address bar enter, native button, etc.)
     *
     * Touches for these operations land on native controls, and WebView will not receive events,
     * so we explicitly open a user intent window (same time limit as touch events).
     */
    fun loadUrlByUser(url: String) {
        activation.onUserNavigation()
        webView?.loadUrl(url)
    }

    override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
        val uri = request.url ?: return false
        val scheme = uri.scheme?.lowercase()

        // Leave sub-frame http(s) navigation to WebView: Chromium native layer will not pass these navigations to host.
        // Only non http(s) scheme sub-frame navigations reach the host, these are deep links and need identical handling as main frame.
        if (!request.isForMainFrame && (scheme == "http" || scheme == "https")) return false

        val byNavigation = request.hasGesture() || request.isRedirect
        // Activation window for script-initiated async jumps: custom scheme / intent:// and direct App Link with path.
        // https App Link handlers that only declare host without path are landing pages and stay inside WebView, not using the activation window.
        val gestureAllowed = !requireUserGesture || byNavigation || activation.hasTransientActivation()

        val handled = when (scheme) {
            "http", "https" -> {
                if (isLandingPageHandler(view.context, uri)) false
                else openAppLink(view, uri, gestureAllowed)
            }
            "intent" -> openIntentUri(view, uri.toString(), gestureAllowed)
            else -> openCustomScheme(view, uri, gestureAllowed)
        }
        // If the launch relies on activation window, consume it after use: one user operation only triggers one external jump
        if (handled && !byNavigation) activation.consumeTransientActivation()
        return handled
    }

    // 1) https: Let the system judge if there is a non-browser default handler
    private fun openAppLink(view: WebView, uri: Uri, allowed: Boolean): Boolean {
        if (!allowed) return false
        val context = view.context

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
            // 1) User selected "Allow": only match default handler and launch directly
            val strict = viewIntent(uri).apply {
                flags = flags or
                    Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER or
                    Intent.FLAG_ACTIVITY_REQUIRE_DEFAULT
            }
            if (startActivitySafely(context, strict)) return true

            // Ordinary web pages without non-browser handlers: leave to WebView, no prompt or query.
            // This is the root cause of false positives for onOpenFailed: only links with an available App handler proceed to downgrade logic.
            if (!hasNonBrowserHandler(context, uri)) return false

            // 2) User selected "Ask every time": REQUIRE_DEFAULT will throw an exception directly.
            // Remove it to let the system show the selection dialog instead of silently staying inside WebView.
            val askSystem = viewIntent(uri).apply {
                flags = flags or Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER
            }
            if (startActivitySafely(context, askSystem)) return true

            // 3) System still cannot handle: trigger host callback to avoid silent fallback inside WebView
            onOpenFailed(uri)
            return false
        }

        // API < 30: Query candidates and filter using browser heuristic rules
        val intent = viewIntent(uri)
        val best = findBestNonBrowserActivity(context.packageManager, intent) ?: return false
        intent.component = ComponentName(best.activityInfo.packageName, best.activityInfo.name)
        return startActivitySafely(context, intent)
    }

    /** Check whether this URL has a non-browser handler (an App that can take over this link). */
    private fun hasNonBrowserHandler(context: Context, uri: Uri): Boolean {
        val candidates = context.packageManager.queryIntentActivities(
            viewIntent(uri),
            PackageManager.GET_RESOLVED_FILTER,
        )
        return candidates.any { !isBrowserApp(it) }
    }

    /**
     * https App Link handlers that only declare host without path are treated as landing pages:
     * load the page inside WebView first, and the page Open action will generate the real custom scheme.
     * LINE Pay LIFF is this case; TNG cashier links with path are treated as direct deep links.
     */
    private fun isLandingPageHandler(context: Context, uri: Uri): Boolean {
        val candidates = context.packageManager.queryIntentActivities(
            viewIntent(uri),
            PackageManager.GET_RESOLVED_FILTER,
        )
        val nonBrowser = candidates.filterNot { isBrowserApp(it) }
        return nonBrowser.isNotEmpty() && nonBrowser.all {
            (it.filter?.countDataPaths() ?: 0) == 0
        }
    }

    private fun findBestNonBrowserActivity(
        packageManager: PackageManager,
        intent: Intent,
    ): ResolveInfo? {
        val candidates = packageManager.queryIntentActivities(
            intent,
            PackageManager.GET_RESOLVED_FILTER,
        )
        val best = when {
            candidates.size == 1 -> candidates[0]
            candidates.size > 1 -> {
                val first = candidates[0]
                val second = candidates[1]
                if (first.priority > second.priority ||
                    first.preferredOrder > second.preferredOrder ||
                    first.isDefault != second.isDefault
                ) {
                    first
                } else {
                    null
                }
            }

            else -> null
        } ?: return null
        return if (isBrowserApp(best)) null else best
    }

    private fun isBrowserApp(info: ResolveInfo): Boolean {
        val filter = info.filter ?: return false
        return filter.hasCategory(Intent.CATEGORY_APP_BROWSER) ||
            (filter.hasDataScheme("http") &&
                filter.hasDataScheme("https") &&
                filter.countDataAuthorities() == 0)
    }

    // 2) custom scheme: launch directly; trigger host fallback on failure
    private fun openCustomScheme(view: WebView, uri: Uri, gestureAllowed: Boolean): Boolean {
        val scheme = uri.scheme?.lowercase()
        if (scheme.isNullOrEmpty() || !uri.isAbsolute) return false
        if (scheme in BLOCKED_SCHEMES) return true
        if (scheme in NON_EXTERNAL_SCHEMES) return false
        if (!gestureAllowed) return true

        val intent = viewIntent(uri)
        if (startActivitySafely(view.context, intent)) return true

        onOpenFailed(uri)
        return true
    }

    // 3) intent://: parse, sanitize and launch; read fallback on failure
    private fun openIntentUri(view: WebView, url: String, gestureAllowed: Boolean): Boolean {
        if (!gestureAllowed) return true
        val intent = try {
            Intent.parseUri(url, Intent.URI_INTENT_SCHEME)
        } catch (e: Exception) {
            onOpenFailed(Uri.parse(url))
            return true
        }

        val fallbackUrl = intent.getStringExtra(EXTRA_BROWSER_FALLBACK_URL)
        intent.removeExtra(EXTRA_BROWSER_FALLBACK_URL)

        // Retain only safe flags; only allow BROWSABLE components
        intent.flags = intent.flags and ALLOWED_INTENT_FLAGS
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        intent.addCategory(Intent.CATEGORY_BROWSABLE)
        intent.addCategory(Intent.CATEGORY_DEFAULT)
        intent.component = null
        intent.selector = null

        if (startActivitySafely(view.context, intent)) return true

        if (fallbackUrl != null && isHttpUrl(fallbackUrl)) {
            view.post { view.loadUrl(fallbackUrl) }
            return true
        }
        onOpenFailed(Uri.parse(url))
        return true
    }

    private fun viewIntent(uri: Uri): Intent = Intent(Intent.ACTION_VIEW, uri).apply {
        addCategory(Intent.CATEGORY_BROWSABLE)
        addCategory(Intent.CATEGORY_DEFAULT)
        flags = Intent.FLAG_ACTIVITY_NEW_TASK
    }

    private fun startActivitySafely(context: Context, intent: Intent): Boolean = try {
        context.startActivity(intent)
        true
    } catch (e: ActivityNotFoundException) {
        false
    } catch (e: SecurityException) {
        false
    }

    private fun isHttpUrl(url: String): Boolean =
        url.startsWith("https://", ignoreCase = true) ||
            url.startsWith("http://", ignoreCase = true)

    companion object {
        /**
         * One-line integration: attach client and feed touch / key events to activation tracker.
         *
         * ```
         * DeepLinkWebViewClient.attach(webView) { Toast.makeText(this, "No App available to handle this link", Toast.LENGTH_SHORT).show() }
         * ```
         */
        @JvmStatic
        fun attach(webView: WebView, onOpenFailed: (Uri) -> Unit = {}): DeepLinkWebViewClient {
            val activation = UserActivationTracker()
            val client = DeepLinkWebViewClient(activation = activation, onOpenFailed = onOpenFailed)
            client.webView = webView
            webView.webViewClient = client
            webView.setOnTouchListener { _, event ->
                if (event.actionMasked == MotionEvent.ACTION_DOWN) activation.onUserInput()
                false
            }
            webView.setOnKeyListener { _, _, _ ->
                activation.onUserInput()
                false
            }
            return client
        }

        private const val EXTRA_BROWSER_FALLBACK_URL = "browser_fallback_url"

        /** Schemes blocked directly to prevent WebView reading local files. */
        private val BLOCKED_SCHEMES = setOf("file", "content")

        /** Internal schemes that will not be converted to external Intent. */
        private val NON_EXTERNAL_SCHEMES = setOf(
            "javascript", "data", "blob", "about", "chrome", "resource", "view-source",
        )

        /** Matches Chromium AwContentsClient.ALLOWED_INTENT_FLAGS. */
        private val ALLOWED_INTENT_FLAGS: Int =
            Intent.FLAG_EXCLUDE_STOPPED_PACKAGES or
                Intent.FLAG_ACTIVITY_CLEAR_TOP or
                Intent.FLAG_ACTIVITY_SINGLE_TOP or
                Intent.FLAG_ACTIVITY_MATCH_EXTERNAL or
                Intent.FLAG_ACTIVITY_NEW_TASK or
                Intent.FLAG_ACTIVITY_MULTIPLE_TASK or
                Intent.FLAG_ACTIVITY_NEW_DOCUMENT or
                Intent.FLAG_ACTIVITY_RETAIN_IN_RECENTS or
                Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT
    }
}

/**
 * Host-side user activation state, approximating Chromium sticky / transient semantics.
 *
 * Two layers consistent with browser behavior:
 * - sticky: whether this WebView has ever received user interaction (only set, never auto-reset)
 * - transient: valid window after latest interaction, default 10s, can be consumed or expired
 *
 * WebView does not expose APIs to read internal browser activation state, only a boolean hasGesture() per navigation.
 * We record timestamps locally from host touch / key events as an approximation instead of replicating native browser state.
 * Window length set to 10s: Chromium native activation window is 5s. Measured "tap pay → script emit deep link"
 * takes 2.75–5.36s, which will miss slow cases under 5s.
 */
class UserActivationTracker(
    private val windowMs: Long = USER_INTENT_WINDOW_MS,
    private val clock: () -> Long = SystemClock::elapsedRealtime,
) {

    private var expiryAt: Long = 0L
    private var everActivated: Boolean = false

    /** User touches or presses keys inside WebView. */
    fun onUserInput() {
        expiryAt = clock() + windowMs
        everActivated = true
    }

    /**
     * Navigation initiated by host on behalf of user (address bar enter, native button etc.)
     *
     * Shares the same time limit as touch events: touches land on native controls and are invisible to WebView,
     * so the host must explicitly open the activation window.
     */
    fun onUserNavigation() {
        onUserInput()
    }

    fun hasTransientActivation(): Boolean =
        expiryAt != 0L && clock() < expiryAt

    fun hasStickyActivation(): Boolean = everActivated

    /** Invalidate immediately after one external launch to avoid launching multiple Apps inside one window. */
    fun consumeTransientActivation() {
        expiryAt = 0L
    }

    fun reset() {
        expiryAt = 0L
        everActivated = false
    }

    companion object {
        /**
         * User intent window duration, shared for touch and host-initiated navigation.
         *
         * Rationale: Measured 2.75–5.36s from tapping payment to script deep link emission on checkout pages;
         * redirect chain scenarios from address bar measured ~9s. 10s covers the slowest cases while minimizing
         * the window for automatic App launch without user interaction.
         */
        const val USER_INTENT_WINDOW_MS = 10_000L
    }
}
  1. Integrate DeepLinkWebViewClient
Kotlin
// ...
private lateinit var deepLinkClient: DeepLinkWebViewClient

// ...

// All external App launch logic is encapsulated inside DeepLinkWebViewClient, only one-line integration here
deepLinkClient = DeepLinkWebViewClient.attach(webView) { uri ->
    Toast.makeText(this, "Cannot open the App corresponding to this link", Toast.LENGTH_SHORT).show()
}

// ...

// Optional: Call this if you want the first loaded URL in WebView to support App launch
deepLinkClient.loadUrlByUser(startURL)
  1. Add http(s) handler visibility to AndroidManifest.xml
XML
<!-- Android 11+ Package Visibility: Allow queryIntentActivities to see http(s) handlers -->
<queries>
    
    
</queries>

Java Implementation ​

  1. Custom WebViewClientDeepLinkWebViewClient.java
Java
package com.example.deeplink;

import android.content.ActivityNotFoundException;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.net.Uri;
import android.os.Build;
import android.os.SystemClock;
import android.view.MotionEvent;
import android.webkit.WebResourceRequest;
import android.webkit.WebView;
import android.webkit.WebViewClient;

import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

/**
 * Forward App Link, custom scheme and intent:// inside WebView.
 * No enumeration of target App domains, package names or schemes.
 *
 * <p>Rules to judge whether a navigation is a user intent: the navigation comes with a gesture,
 * it is a server redirect, or it falls within the activation window after the user’s latest operation.
 * For https App Link: handlers that only declare host without path are treated as landing pages
 * and remain in WebView first; App Link with path, custom scheme and intent:// are all recognized for direct launch.
 *
 * <p>The activation window is required because many payment pages trigger deep links asynchronously via scripts
 * after page load (location.href = "xxx://…"). This jump carries no user gesture and would be missed
 * if only hasGesture() is checked. LINE Pay’s liff.line.me is a landing page and will not be launched directly via the window.
 *
 * <p>Sub-frame (iframe) navigation will also trigger callbacks: Chromium only passes non http(s) scheme sub-frame navigation
 * to the host. These navigations are deep links such as nidlogin:// and must be processed the same way as main-frame navigation.
 */
public class DeepLinkWebViewClient extends WebViewClient {

    /** Callback for unhandled custom scheme / intent:// or failed App Link launch. */
public interface OnOpenFailedListener {
void onOpenFailed(Uri uri);
    }

private final boolean requireUserGesture;
private final UserActivationTracker activation;
private final OnOpenFailedListener onOpenFailed;
private WebView webView;

public DeepLinkWebViewClient() {
this(true, new UserActivationTracker(), null);
    }

public DeepLinkWebViewClient(boolean requireUserGesture, OnOpenFailedListener onOpenFailed) {
this(requireUserGesture, new UserActivationTracker(), onOpenFailed);
    }

public DeepLinkWebViewClient(boolean requireUserGesture, UserActivationTracker activation,
OnOpenFailedListener onOpenFailed) {
this.requireUserGesture = requireUserGesture;
this.activation = activation;
this.onOpenFailed = onOpenFailed;
    }

    /**
     * Navigation initiated by the host on behalf of the user (address bar enter, native button, etc.)
     *
     * <p>Touches for these operations land on native controls, and WebView will not receive events,
     * so we explicitly open a user intent window (same time limit as touch events).
     */
public void loadUrlByUser(String url) {
activation.onUserNavigation();
if (webView != null) {
webView.loadUrl(url);
        }
    }

    @Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
Uri uri = request.getUrl();
if (uri == null) return false;

String scheme = uri.getScheme();
        scheme = scheme == null ? "" : scheme.toLowerCase();

// Leave sub-frame http(s) navigation to WebView: Chromium native layer will not pass these navigations to host.
// Only non http(s) scheme sub-frame navigations reach the host, these are deep links and need identical handling as main frame.
if (!request.isForMainFrame() && ("http".equals(scheme) || "https".equals(scheme))) {
return false;
        }

boolean byNavigation = request.hasGesture() || request.isRedirect();
// Activation window for script-initiated async jumps: custom scheme / intent:// and direct App Link with path.
// https App Link handlers that only declare host without path are landing pages and stay inside WebView, not using the activation window.
boolean gestureAllowed =
!requireUserGesture || byNavigation || activation.hasTransientActivation();

boolean handled;
if ("http".equals(scheme) || "https".equals(scheme)) {
            handled = isLandingPageHandler(view.getContext(), uri)
? false
: openAppLink(view, uri, gestureAllowed);
        } else if ("intent".equals(scheme)) {
            handled = openIntentUri(view, uri.toString(), gestureAllowed);
        } else {
            handled = openCustomScheme(view, uri, gestureAllowed);
        }
// If the launch relies on activation window, consume it after use: one user operation only triggers one external jump
if (handled && !byNavigation) {
activation.consumeTransientActivation();
        }
return handled;
    }

// 1) https: Let the system judge if there is a non-browser default handler
private boolean openAppLink(WebView view, Uri uri, boolean allowed) {
if (!allowed) return false;
Context context = view.getContext();

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// 1) User selected "Allow": only match default handler and launch directly
Intent strict = viewIntent(uri);
strict.setFlags(strict.getFlags()
| Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER
| Intent.FLAG_ACTIVITY_REQUIRE_DEFAULT);
if (startActivitySafely(context, strict)) return true;

// Ordinary web pages without non-browser handlers: leave to WebView, no prompt or query.
// This is the root cause of false positives for onOpenFailed: only links with an available App handler proceed to downgrade logic.
if (!hasNonBrowserHandler(context, uri)) return false;

// 2) User selected "Ask every time": REQUIRE_DEFAULT will throw an exception directly.
// Remove it to let the system show the selection dialog instead of silently staying inside WebView.
Intent askSystem = viewIntent(uri);
askSystem.setFlags(askSystem.getFlags() | Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER);
if (startActivitySafely(context, askSystem)) return true;

// 3) System still cannot handle: trigger host callback to avoid silent fallback inside WebView
if (onOpenFailed != null) onOpenFailed.onOpenFailed(uri);
return false;
        }

// API < 30: Query candidates and filter using browser heuristic rules
Intent intent = viewIntent(uri);
ResolveInfo best = findBestNonBrowserActivity(context.getPackageManager(), intent);
if (best == null) return false;
intent.setComponent(
new ComponentName(best.activityInfo.packageName, best.activityInfo.name));
return startActivitySafely(context, intent);
    }

    /** Check whether this URL has a non-browser handler (an App that can take over this link). */
private boolean hasNonBrowserHandler(Context context, Uri uri) {
List<ResolveInfo> candidates = context.getPackageManager()
                .queryIntentActivities(viewIntent(uri), PackageManager.GET_RESOLVED_FILTER);
for (ResolveInfo info : candidates) {
if (!isBrowserApp(info)) return true;
        }
return false;
    }

    /**
     * https App Link handlers that only declare host without path are treated as landing pages:
     * load the page inside WebView first, and the page Open action will generate the real custom scheme.
     * LINE Pay LIFF is this case; TNG cashier links with path are treated as direct deep links.
     */
private boolean isLandingPageHandler(Context context, Uri uri) {
List<ResolveInfo> candidates = context.getPackageManager()
                .queryIntentActivities(viewIntent(uri), PackageManager.GET_RESOLVED_FILTER);
boolean hasNonBrowser = false;
for (ResolveInfo info : candidates) {
if (!isBrowserApp(info)) {
                hasNonBrowser = true;
if (info.filter != null && info.filter.countDataPaths() > 0) {
return false;
                }
            }
        }
return hasNonBrowser;
    }

private ResolveInfo findBestNonBrowserActivity(PackageManager packageManager, Intent intent) {
List<ResolveInfo> candidates =
packageManager.queryIntentActivities(intent, PackageManager.GET_RESOLVED_FILTER);
ResolveInfo best;
if (candidates.size() == 1) {
            best = candidates.get(0);
        } else if (candidates.size() > 1) {
ResolveInfo first = candidates.get(0);
ResolveInfo second = candidates.get(1);
if (first.priority > second.priority
|| first.preferredOrder > second.preferredOrder
|| first.isDefault != second.isDefault) {
                best = first;
            } else {
                best = null;
            }
        } else {
            best = null;
        }
if (best == null) return null;
return isBrowserApp(best) ? null : best;
    }

private boolean isBrowserApp(ResolveInfo info) {
if (info.filter == null) return false;
return info.filter.hasCategory(Intent.CATEGORY_APP_BROWSER)
|| (info.filter.hasDataScheme("http")
&& info.filter.hasDataScheme("https")
&& info.filter.countDataAuthorities() == 0);
    }

// 2) custom scheme: launch directly; trigger host fallback on failure
private boolean openCustomScheme(WebView view, Uri uri, boolean gestureAllowed) {
String scheme = uri.getScheme();
        scheme = scheme == null ? "" : scheme.toLowerCase();
if (scheme.isEmpty() || !uri.isAbsolute()) return false;
if (BLOCKED_SCHEMES.contains(scheme)) return true;
if (NON_EXTERNAL_SCHEMES.contains(scheme)) return false;
if (!gestureAllowed) return true;

if (startActivitySafely(view.getContext(), viewIntent(uri))) return true;
if (onOpenFailed != null) onOpenFailed.onOpenFailed(uri);
return true;
    }

// 3) intent://: parse, sanitize and launch; read fallback on failure
private boolean openIntentUri(WebView view, String url, boolean gestureAllowed) {
if (!gestureAllowed) return true;

Intent intent;
try {
            intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME);
        } catch (Exception e) {
if (onOpenFailed != null) onOpenFailed.onOpenFailed(Uri.parse(url));
return true;
        }

String fallbackUrl = intent.getStringExtra(EXTRA_BROWSER_FALLBACK_URL);
intent.removeExtra(EXTRA_BROWSER_FALLBACK_URL);

// Retain only safe flags; only allow BROWSABLE components
intent.setFlags(intent.getFlags() & ALLOWED_INTENT_FLAGS);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.addCategory(Intent.CATEGORY_BROWSABLE);
intent.addCategory(Intent.CATEGORY_DEFAULT);
intent.setComponent(null);
intent.setSelector(null);

if (startActivitySafely(view.getContext(), intent)) return true;

if (fallbackUrl != null && isHttpUrl(fallbackUrl)) {
final String target = fallbackUrl;
view.post(new Runnable() {
                @Override
public void run() {
view.loadUrl(target);
                }
            });
return true;
        }
if (onOpenFailed != null) onOpenFailed.onOpenFailed(Uri.parse(url));
return true;
    }

private Intent viewIntent(Uri uri) {
Intent intent = new Intent(Intent.ACTION_VIEW, uri);
intent.addCategory(Intent.CATEGORY_BROWSABLE);
intent.addCategory(Intent.CATEGORY_DEFAULT);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
return intent;
    }

private boolean startActivitySafely(Context context, Intent intent) {
try {
context.startActivity(intent);
return true;
        } catch (ActivityNotFoundException | SecurityException e) {
return false;
        }
    }

private boolean isHttpUrl(String url) {
return url.regionMatches(true, 0, "https://", 0, 8)
|| url.regionMatches(true, 0, "http://", 0, 7);
    }

private static final String EXTRA_BROWSER_FALLBACK_URL = "browser_fallback_url";

    /** Schemes blocked directly to prevent WebView reading local files. */
private static final Set<String> BLOCKED_SCHEMES =
new HashSet<>(Arrays.asList("file", "content"));

    /** Internal schemes that will not be converted to external Intent. */
private static final Set<String> NON_EXTERNAL_SCHEMES =
new HashSet<>(Arrays.asList(
"javascript", "data", "blob", "about", "chrome", "resource", "view-source"));

    /** Matches Chromium AwContentsClient.ALLOWED_INTENT_FLAGS. */
private static final int ALLOWED_INTENT_FLAGS =
Intent.FLAG_EXCLUDE_STOPPED_PACKAGES
| Intent.FLAG_ACTIVITY_CLEAR_TOP
| Intent.FLAG_ACTIVITY_SINGLE_TOP
| Intent.FLAG_ACTIVITY_MATCH_EXTERNAL
| Intent.FLAG_ACTIVITY_NEW_TASK
| Intent.FLAG_ACTIVITY_MULTIPLE_TASK
| Intent.FLAG_ACTIVITY_NEW_DOCUMENT
| Intent.FLAG_ACTIVITY_RETAIN_IN_RECENTS
| Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT;

    /**
     * One-line integration: attach client and feed touch / key events to activation tracker.
     */
public static DeepLinkWebViewClient attach(WebView webView, OnOpenFailedListener onOpenFailed) {
UserActivationTracker activation = new UserActivationTracker();
DeepLinkWebViewClient client = new DeepLinkWebViewClient(true, activation, onOpenFailed);
client.webView = webView;
webView.setWebViewClient(client);
webView.setOnTouchListener((view, event) -> {
if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
activation.onUserInput();
            }
return false;
        });
webView.setOnKeyListener((view, keyCode, event) -> {
activation.onUserInput();
return false;
        });
return client;
    }

    /**
     * Host-side user activation state, approximating Chromium sticky / transient semantics.
     *
     * <p>Two layers consistent with browser behavior: sticky means whether this WebView has ever received user interaction (only set, never auto-reset);
     * transient means valid window after latest interaction, default 10s, can be consumed or expired.
     *
     * <p>WebView does not expose APIs to read internal browser activation state, only a boolean hasGesture() per navigation.
     * We record timestamps locally from host touch / key events as an approximation instead of replicating native browser state.
     * Window length set to 10s: Chromium native activation window is 5s. Measured "tap pay → script emit deep link"
     * takes 2.75–5.36s, which will miss slow cases under 5s.
     */
public static final class UserActivationTracker {

        /**
         * User intent window duration, shared for touch and host-initiated navigation.
         *
         * <p>Rationale: Measured 2.75–5.36s from tapping payment to script deep link emission on checkout pages;
         * redirect chain scenarios from address bar measured ~9s. 10s covers the slowest cases while minimizing
         * the window for automatic App launch without user interaction.
         */
public static final long USER_INTENT_WINDOW_MS = 10000L;

        /** Clock abstraction: use SystemClock in production, inject controllable clock for unit tests. */
public interface Clock {
long now();
        }

private final long windowMs;
private final Clock clock;
private long expiryAt;
private boolean everActivated;

public UserActivationTracker() {
this(USER_INTENT_WINDOW_MS, SystemClock::elapsedRealtime);
        }

public UserActivationTracker(long windowMs) {
this(windowMs, SystemClock::elapsedRealtime);
        }

public UserActivationTracker(long windowMs, Clock clock) {
this.windowMs = windowMs;
this.clock = clock;
        }

        /** User touches or presses keys inside WebView. */
public void onUserInput() {
            expiryAt = clock.now() + windowMs;
            everActivated = true;
        }

        /**
         * Navigation initiated by host on behalf of user (address bar enter, native button etc.)
         *
         * <p>Shares the same time limit as touch events: touches land on native controls and are invisible to WebView,
         * so the host must explicitly open the activation window.
         */
public void onUserNavigation() {
onUserInput();
        }

public boolean hasTransientActivation() {
return expiryAt != 0L && clock.now() < expiryAt;
        }

public boolean hasStickyActivation() {
return everActivated;
        }

        /** Invalidate immediately after one external launch to avoid launching multiple Apps inside one window. */
public void consumeTransientActivation() {
            expiryAt = 0L;
        }

public void reset() {
            expiryAt = 0L;
            everActivated = false;
        }
    }
}
  1. Integrate DeepLinkWebViewClient
Java
// ...
private DeepLinkWebViewClient deepLinkClient;

// ...

// All external App launch logic is encapsulated inside DeepLinkWebViewClient, only one-line integration here
deepLinkClient = DeepLinkWebViewClient.attach(webView,
        uri -> Toast.makeText(this, "Cannot open the App corresponding to this link", Toast.LENGTH_SHORT).show());

// ...

// Optional: Call this if you want the first loaded URL in WebView to support App launch
deepLinkClient.loadUrlByUser(startURL)
  1. Add http(s) handler visibility to AndroidManifest.xml (same as Kotlin)
XML
<!-- Android 11+ Package Visibility: Allow queryIntentActivities to see http(s) handlers -->
<queries>
    
    
</queries>

iOS ​

Principle ​


Swift Implementation ​

  1. Implement WKNavigationDelegate
Swift
//
//  WebViewDeepLink.swift
//  Single-file SDK for WKWebView to launch external Apps (iOS 15+)
//
//  Ordinary http(s) links are handled by WebKit; https URLs invoked via `loadUrlByUser` will attempt Universal Link first.
//  Custom schemes cancel navigation and then call UIApplication.open.
//  This SDK does not output logs. It only triggers `onOpenFailed` when opening fails.
//
//  Integration:
//  let client = DeepLinkWebViewClient.attach(to: webView)
//  client.loadUrlByUser(url)


import Foundation
import UIKit
import WebKit

// MARK: - Configuration

/// External jump strategy. All fields have default values.
struct DeepLinkOptions {
    /// Whether external jumps are allowed without user interaction.
    var requireUserGesture = true

    /// Whether automatic deep link jumps are allowed for iframes without user taps.
    var allowAutomaticSubframeDeepLinks = false

    /// Whether https URLs from `loadUrlByUser` should first use `universalLinksOnly` pre-check.
    var openUniversalLinksFromUserLoads = true

    /// Schemes that directly cancel navigation; `file` follows local page access rules.
    var blockedSchemes: Set<String> = ["file", "content", "intent"]

    /// Schemes that remain handled by the WebView.
    var webViewOwnedSchemes: Set<String> = [
        "about", "data", "blob", "javascript", "view-source",
    ]

    /// Business veto for external jumps; return false to cancel navigation.
    var shouldOpenExternalURL: ((URL) -> Bool)?

    /// Whether to automatically search the App Store on open failure; system schemes are excluded.
    var autoOpenAppStoreOnFailure = true

    /// Failure callback; invoked when auto App Store search also fails or auto search is disabled.
    var onOpenFailed: ((URL) -> Void)?
}

// MARK: - User Interaction State

/// WebView-level user interaction state: sticky flag, does not expire automatically.
final class UserInteractionState {
    private(set) var hasUserInteraction = false

    /// Whether `loadUrlByUser` has authorized subsequent navigation.
    private(set) var hasUserInitiatedLoad = false

    func markUserInteraction() {
        hasUserInteraction = true
    }

    func markUserInitiatedLoad() {
        hasUserInitiatedLoad = true
    }

    /// Call when loading a new built-in page to prevent interaction from the previous page from authorizing jumps on the new page.
    func reset() {
        hasUserInteraction = false
        hasUserInitiatedLoad = false
    }
}

// MARK: - Navigation Context

/// Snapshot of inputs for a single navigation.
struct DeepLinkNavigationContext {
    let url: URL

    /// New windows with `targetFrame == nil` are treated as main-frame navigation.
    let isMainFrame: Bool

    let navigationType: WKNavigationType

    /// `webView.url` before navigation; nil on the initial host load.
    let currentURL: URL?

    var hasNavigationGesture: Bool {
        navigationType == .linkActivated || navigationType == .formSubmitted
    }

    init(url: URL,
         isMainFrame: Bool = true,
         navigationType: WKNavigationType = .other,
         currentURL: URL? = nil) {
        self.url = url
        self.isMainFrame = isMainFrame
        self.navigationType = navigationType
        self.currentURL = currentURL
    }
}

// MARK: - Classification Result

/// Classification result: action for the decision engine to execute.
enum DeepLinkAction: Equatable {
    /// Allow, hand back to WebView.
    case allow

    /// Cancel navigation.
    case block

    /// Jump to external app via custom / system scheme.
    case openExternal(URL)

    /// Pre-check https once with `universalLinksOnly`: cancel navigation and hand to App on success; allow WebView to load on failure.
    case checkUniversalLink(URL)
}

// MARK: - Classifier

/// Pure logic classifier: only reads inputs and configuration, no I/O.
final class DeepLinkClassifier {
    private let options: DeepLinkOptions
    private let interactionState: UserInteractionState
    private let blockedSchemes: Set<String>
    private let webViewOwnedSchemes: Set<String>

    init(options: DeepLinkOptions, interactionState: UserInteractionState) {
        self.options = options
        self.interactionState = interactionState
        // Normalize schemes to lowercase to avoid missed matches caused by case differences.
        self.blockedSchemes = Set(options.blockedSchemes.map { $0.lowercased() })
        self.webViewOwnedSchemes = Set(options.webViewOwnedSchemes.map { $0.lowercased() })
    }

    func classify(_ context: DeepLinkNavigationContext) -> DeepLinkAction {
        guard let scheme = context.url.scheme?.lowercased(), !scheme.isEmpty else {
            return .allow
        }

        // File URLs follow local page access rules first.
        if scheme == "file", isLocalFileNavigation(context) {
            return .allow
        }

        if blockedSchemes.contains(scheme) {
            return .block
        }

        // HTTPS pre-check for Universal Link only on cross-host main-frame navigation triggered by user-initiated loads.
        if scheme == "http" {
            return .allow
        }
        if scheme == "https" {
            return shouldCheckUniversalLink(context) ? .checkUniversalLink(context.url) : .allow
        }

        if webViewOwnedSchemes.contains(scheme) {
            return .allow
        }

        if !context.isMainFrame,
           !context.hasNavigationGesture,
           !options.allowAutomaticSubframeDeepLinks {
            return .block
        }

        if options.requireUserGesture,
           !context.hasNavigationGesture,
           !interactionState.hasUserInteraction {
            return .block
        }

        if let shouldOpen = options.shouldOpenExternalURL, !shouldOpen(context.url) {
            return .block
        }

        return .openExternal(context.url)
    }

    /// Only process main-frame, .other, cross-host https navigation after user-initiated load;
    /// The initial URL is pre-checked by `loadUrlByUser` itself.
    private func shouldCheckUniversalLink(_ context: DeepLinkNavigationContext) -> Bool {
        guard options.openUniversalLinksFromUserLoads else { return false }
        guard context.isMainFrame else { return false }
        guard context.navigationType == .other else { return false }
        guard interactionState.hasUserInitiatedLoad else { return false }
        // Skip cross-host judgment when currentURL is nil or has no host.
        guard let currentHost = context.currentURL?.host, !currentHost.isEmpty,
              let targetHost = context.url.host, !targetHost.isEmpty else {
            return false
        }
        return currentHost.caseInsensitiveCompare(targetHost) != .orderedSame
    }

    /// Local page access rule: initial host load, or navigation within the same directory of the current file page.
    private func isLocalFileNavigation(_ context: DeepLinkNavigationContext) -> Bool {
        guard context.url.isFileURL else { return false }

        if context.currentURL == nil, context.navigationType == .other {
            return true
        }

        guard let currentURL = context.currentURL, currentURL.isFileURL else { return false }
        let currentPath = currentURL.standardizedFileURL.path
        let targetPath = context.url.standardizedFileURL.path
        // webView.url may already be the target URL during loadFileURL callback.
        if targetPath == currentPath {
            return true
        }
        // Append path separator to avoid false positives such as `/a/bc` matching `/a/b`.
        let currentDirectory = currentURL.standardizedFileURL.deletingLastPathComponent().path
        let directoryPrefix = currentDirectory.hasSuffix("/") ? currentDirectory : currentDirectory + "/"
        return targetPath.hasPrefix(directoryPrefix)
    }
}

// MARK: - URL Opening

/// Abstraction for opening URLs, convenient for mock injection in unit tests.
protocol URLOpening {
    /// When `universalLinksOnly` is true, only valid Universal Links for installed apps will be opened.
    /// `completion` indicates whether the system accepted the request.
    func open(_ url: URL,
              universalLinksOnly: Bool,
              completion: @escaping (Bool) -> Void)
}

/// Production implementation.
final class UIApplicationURLOpener: URLOpening {
    func open(_ url: URL,
              universalLinksOnly: Bool,
              completion: @escaping (Bool) -> Void) {
        let work = {
            let options: [UIApplication.OpenExternalURLOptionsKey: Any] =
                universalLinksOnly ? [.universalLinksOnly: true] : [:]
            UIApplication.shared.open(url, options: options, completionHandler: completion)
        }
        if Thread.isMainThread {
            work()
        } else {
            DispatchQueue.main.async(execute: work)
        }
    }
}

/// App Store search helper: itms-apps primary, https fallback.
final class AppStoreSearch {
    private let opener: URLOpening

    init(opener: URLOpening = UIApplicationURLOpener()) {
        self.opener = opener
    }

    func search(term: String, completion: ((Bool) -> Void)? = nil) {
        let urls = Self.searchURLs(term: term)
        guard !urls.isEmpty else {
            completion?(false)
            return
        }
        open(urls, index: 0, completion: completion)
    }

    static func searchURLs(term: String) -> [URL] {
        func makeURL(scheme: String, host: String) -> URL? {
            var components = URLComponents()
            components.scheme = scheme
            components.host = host
            components.path = "/search"
            components.queryItems = [URLQueryItem(name: "term", value: term)]
            return components.url
        }
        return [
            makeURL(scheme: "itms-apps", host: "itunes.apple.com"),
            makeURL(scheme: "https", host: "apps.apple.com"),
        ].compactMap { $0 }
    }

    private func open(_ urls: [URL], index: Int, completion: ((Bool) -> Void)?) {
        guard index < urls.count else {
            completion?(false)
            return
        }
        opener.open(urls[index], universalLinksOnly: false) { [weak self] success in
            if success {
                completion?(true)
            } else {
                self?.open(urls, index: index + 1, completion: completion)
            }
        }
    }
}

// MARK: - Decision Engine

/// Executes classification results: allow, cancel, or jump externally.
final class DeepLinkDecisionEngine {
    let options: DeepLinkOptions
    let classifier: DeepLinkClassifier
    let opener: URLOpening
    let interactionState: UserInteractionState
    private let appStoreSearch: AppStoreSearch

    init(options: DeepLinkOptions = DeepLinkOptions(),
         interactionState: UserInteractionState? = nil,
         opener: URLOpening = UIApplicationURLOpener(),
         classifier: DeepLinkClassifier? = nil) {
        let state = interactionState ?? UserInteractionState()
        self.options = options
        self.interactionState = state
        self.opener = opener
        self.appStoreSearch = AppStoreSearch(opener: opener)
        self.classifier = classifier ?? DeepLinkClassifier(options: options, interactionState: state)
    }

    /// Pre-check for the initial URL from `loadUrlByUser`; return true means the App has taken over and no further WebView load is needed.
    func checkUniversalLinkIfNeeded(_ url: URL, completion: @escaping (Bool) -> Void) {
        guard options.openUniversalLinksFromUserLoads,
              url.scheme?.lowercased() == "https" else {
            completion(false)
            return
        }
        opener.open(url, universalLinksOnly: true) { success in
            completion(success)
        }
    }

    /// True = allow navigation, false = cancel navigation; for external jumps, callback false first then open the app.
    func handle(_ context: DeepLinkNavigationContext,
                completion: @escaping (Bool) -> Void) {
        switch classifier.classify(context) {
        case .allow:
            completion(true)

        case .block:
            completion(false)

        case .openExternal(let url):
            // Cancel WebView navigation first, then hand to system to open.
            completion(false)
            opener.open(url, universalLinksOnly: false) { [weak self] success in
                guard let self, !success else { return }
                self.handleOpenFailure(url)
            }

        case .checkUniversalLink(let url):
            opener.open(url, universalLinksOnly: true) { success in
                completion(!success)
            }
        }
    }

    /// Handle open failure: default to search App Store; callback host for system schemes or when auto search is disabled.
    private func handleOpenFailure(_ url: URL) {
        let scheme = url.scheme?.lowercased() ?? ""
        let systemSchemes: Set<String> = [
            "mailto", "tel", "sms", "facetime", "maps", "itms", "itms-apps",
        ]
        guard options.autoOpenAppStoreOnFailure,
              !scheme.isEmpty,
              !systemSchemes.contains(scheme) else {
            options.onOpenFailed?(url)
            return
        }

        appStoreSearch.search(term: scheme) { [weak self] success in
            if !success {
                self?.options.onOpenFailed?(url)
            }
        }
    }
}

// MARK: - WKWebView Client

/// WKWebView external jump client: takes over delegate and installs interaction gesture recognizers.
final class DeepLinkWebViewClient: NSObject {
    private weak var webView: WKWebView?
    let interactionState: UserInteractionState
    let engine: DeepLinkDecisionEngine

    /// One-time permission for host-initiated file loading (`loadLocalFile`).
    private var pendingHostFileNavigation: URL?
    private var interactionRecognizers: [UIGestureRecognizer] = []

    /// The return value must be strongly retained by the host.
    @discardableResult
    static func attach(to webView: WKWebView,
                       options: DeepLinkOptions = DeepLinkOptions()) -> DeepLinkWebViewClient {
        let state = UserInteractionState()
        let engine = DeepLinkDecisionEngine(options: options, interactionState: state)
        let client = DeepLinkWebViewClient(webView: webView,
                                           interactionState: state,
                                           engine: engine)
        webView.navigationDelegate = client
        webView.uiDelegate = client
        client.installInteractionRecognizers(on: webView)
        return client
    }

    init(webView: WKWebView,
         interactionState: UserInteractionState,
         engine: DeepLinkDecisionEngine) {
        self.webView = webView
        self.interactionState = interactionState
        self.engine = engine
        super.init()
    }

    deinit {
        // Remove gesture recognizers before client deallocates to avoid callbacks to released objects.
        for recognizer in interactionRecognizers {
            webView?.removeGestureRecognizer(recognizer)
        }
    }

    // MARK: Host Entry Points

    /// Host-initiated navigation on behalf of user.
    ///
    /// https links attempt Universal Link first and load only on failure; custom schemes skip loading and jump directly.
    func loadUrlByUser(_ url: URL) {
        interactionState.markUserInteraction()
        interactionState.markUserInitiatedLoad()

        let scheme = url.scheme?.lowercased() ?? ""
        if scheme == "http" || scheme == "https" {
            engine.checkUniversalLinkIfNeeded(url) { [weak self] opened in
                guard !opened, let self, let webView = self.webView else { return }
                webView.load(URLRequest(url: url))
            }
            return
        }

        // Non http(s) URLs skip page loading and go through decision engine directly to avoid extra WebKit callbacks.
        let context = DeepLinkNavigationContext(url: url,
                                                isMainFrame: true,
                                                navigationType: .other,
                                                currentURL: webView?.url)
        engine.handle(context) { [weak self] allow in
            guard allow, let self, let webView = self.webView else { return }
            webView.load(URLRequest(url: url))
        }
    }

    /// Host loads local page; reset interaction state first, treated as a new browsing context.
    func loadLocalFile(_ url: URL, allowingReadAccessTo readAccessURL: URL) {
        interactionState.reset()
        pendingHostFileNavigation = url.standardizedFileURL
        webView?.loadFileURL(url, allowingReadAccessTo: readAccessURL)
    }

    // MARK: User Interaction Gestures

    private func installInteractionRecognizers(on webView: WKWebView) {
        let tap = UITapGestureRecognizer(target: self, action: #selector(handleUserTap))
        tap.cancelsTouchesInView = false
        tap.delegate = self
        webView.addGestureRecognizer(tap)

        // Mark interaction on touch start to cover paths such as JS location.href without linkActivated.
        let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleUserLongPress(_:)))
        longPress.minimumPressDuration = 0.01
        longPress.cancelsTouchesInView = false
        longPress.delegate = self
        webView.addGestureRecognizer(longPress)

        interactionRecognizers = [tap, longPress]
    }

    @objc private func handleUserTap() {
        interactionState.markUserInteraction()
    }

    @objc private func handleUserLongPress(_ recognizer: UILongPressGestureRecognizer) {
        // Only trigger on begin to avoid duplicate marking on touch up.
        guard recognizer.state == .began else { return }
        interactionState.markUserInteraction()
    }

    // MARK: Build Navigation Context

    private func makeContext(from navigationAction: WKNavigationAction,
                             in webView: WKWebView) -> DeepLinkNavigationContext? {
        guard let url = navigationAction.request.url else { return nil }
        // targetFrame == nil means window.open / target=_blank, treat as main frame.
        let isMainFrame = navigationAction.targetFrame?.isMainFrame ?? true
        return DeepLinkNavigationContext(url: url,
                                         isMainFrame: isMainFrame,
                                         navigationType: navigationAction.navigationType,
                                         currentURL: webView.url)
    }
}

// MARK: - WKNavigationDelegate

extension DeepLinkWebViewClient: WKNavigationDelegate {
    func webView(_ webView: WKWebView,
                 decidePolicyFor navigationAction: WKNavigationAction,
                 decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
        guard let url = navigationAction.request.url else {
            decisionHandler(.allow)
            return
        }

        // Consume pending mark only when URL matches host-initiated local file load.
        if let pending = pendingHostFileNavigation,
           pending == url.standardizedFileURL {
            pendingHostFileNavigation = nil
            decisionHandler(.allow)
            return
        }

        if navigationAction.navigationType == .linkActivated
            || navigationAction.navigationType == .formSubmitted {
            interactionState.markUserInteraction()
        }

        guard let context = makeContext(from: navigationAction, in: webView) else {
            decisionHandler(.allow)
            return
        }

        // UL pre-check is async; decisionHandler must be called exactly once.
        var hasDecided = false
        let decide: (WKNavigationActionPolicy) -> Void = { policy in
            guard !hasDecided else { return }
            hasDecided = true
            decisionHandler(policy)
        }

        engine.handle(context) { allow in
            decide(allow ? .allow : .cancel)
        }
    }
}

// MARK: - WKUIDelegate

extension DeepLinkWebViewClient: WKUIDelegate {
    func webView(_ webView: WKWebView,
                 createWebViewWith configuration: WKWebViewConfiguration,
                 for navigationAction: WKNavigationAction,
                 windowFeatures: WKWebViewFeatures) -> WKWebView? {
        // JS window.open only hits this callback, not decidePolicyFor.
        guard let url = navigationAction.request.url else { return nil }
        let scheme = url.scheme?.lowercased() ?? ""

        if scheme == "http" || scheme == "https" {
            webView.load(navigationAction.request)
            return nil
        }

        guard let context = makeContext(from: navigationAction, in: webView) else { return nil }
        engine.handle(context) { _ in }
        return nil
    }
}

// MARK: - UIGestureRecognizerDelegate

extension DeepLinkWebViewClient: UIGestureRecognizerDelegate {
    /// Recognize simultaneously with WebKit internal gestures, prevent gesture recognizer from intercepting taps.
    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
                           shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        true
    }
}
  1. Integrate in ViewController
Swift
// ...
private var deepLinkClient: DeepLinkWebViewClient?

// ...

deepLinkClient = DeepLinkWebViewClient.attach(to: webView)

// ...

// 如果需要让 WebView 载入的第一个链接就支持唤起 APP,可以调用【非必须】
deepLinkClient.loadUrlByUser(url)

// ...

Objective-C Implementation ​

  1. Implement WKNavigationDelegate
objc
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
#import <WebKit/WebKit.h>

NS_ASSUME_NONNULL_BEGIN

/// WebViewDeepLink — Single-file SDK for WKWebView to launch external Apps (iOS 15+, ARC).
///
/// ```objc
/// DeepLinkOptions *options = [DeepLinkOptions defaultOptions];
/// self.client = [DeepLinkWebViewClient attachToWebView:self.webView options:options];
/// [self.client loadURLByUser:url];
/// ```
///
/// Ordinary http(s) links are handled by WebKit; https URLs invoked via `loadURLByUser:` will attempt Universal Link first.
/// This SDK does not output logs. It only triggers `onOpenFailed` when opening fails. See README for behavior and configuration details.

#pragma mark - Configuration

/// Business hook to veto external jumps; return NO to cancel navigation and skip opening the external App.
typedef BOOL (^ShouldOpenExternalURLBlock)(NSURL *URL);

/// Host callback when the external App cannot be opened (no system handler available).
typedef void (^OpenFailedBlock)(NSURL *URL);

/// Configuration for external jump policies. All fields have default values and can be overridden as needed.
@interface DeepLinkOptions : NSObject

/// Whether external jumps are allowed without user interaction. Defaults to YES.
@property (nonatomic, assign) BOOL requireUserGesture;

/// Whether automatic deep link jumps are allowed for iframes without user taps. Defaults to NO.
@property (nonatomic, assign) BOOL allowAutomaticSubframeDeepLinks;

/// Whether subsequent cross-host https navigations after loadURLByUser should perform Universal Link checks. Defaults to YES.
@property (nonatomic, assign) BOOL openUniversalLinksFromUserLoads;

/// Schemes that directly cancel navigation. Defaults to file / content / intent.
@property (nonatomic, copy) NSSet<NSString *> *blockedSchemes;

/// Schemes handed back to WebView. Defaults to about / data / blob / javascript / view-source.
@property (nonatomic, copy) NSSet<NSString *> *webViewOwnedSchemes;

/// Business veto for external jumps. Defaults to nil.
@property (nonatomic, copy, nullable) ShouldOpenExternalURLBlock shouldOpenExternalURL;

/// Whether to automatically search the App Store on open failure. Defaults to YES; system schemes are excluded.
@property (nonatomic, assign) BOOL autoOpenAppStoreOnFailure;

/// Failure callback; invoked when auto App Store search also fails or auto search is disabled.
@property (nonatomic, copy, nullable) OpenFailedBlock onOpenFailed;

/// Create configuration with default values.
+ (instancetype)defaultOptions;

@end

#pragma mark - User Interaction State

/// WebView-level user interaction state: sticky flag, does not expire automatically.
@interface UserInteractionState : NSObject

@property (nonatomic, assign, readonly) BOOL hasUserInteraction;

/// Whether subsequent navigation is authorized by loadURLByUser.
@property (nonatomic, assign, readonly) BOOL hasUserInitiatedLoad;

- (void)markUserInteraction;

- (void)markUserInitiatedLoad;

/// Call when loading a new internal page.
- (void)reset;

@end

#pragma mark - Classification

/// Processing action corresponding to classification result.
typedef NS_ENUM(NSInteger, DeepLinkAction) {
    /// Hand back to WebView for continued loading.
    DeepLinkActionAllow = 0,
    /// Cancel navigation.
    DeepLinkActionCancel = 1,
    /// Cancel navigation and open external App.
    DeepLinkActionOpenExternal = 2,
    /// Use universalLinksOnly to check with system whether this https is an associated Universal Link.
    DeepLinkActionCheckUniversalLink = 3,
};

/// Input for classification of a single navigation.
@interface DeepLinkNavigationContext : NSObject

@property (nonatomic, copy, nullable) NSURL *URL;
/// New windows with targetFrame == nil are treated as main-frame navigation.
@property (nonatomic, assign) BOOL isMainFrame;
@property (nonatomic, assign) WKNavigationType navigationType;
/// webView.URL before navigation; nil on the initial host load.
@property (nonatomic, copy, nullable) NSURL *currentURL;

@property (nonatomic, assign, readonly) BOOL hasNavigationGesture;

+ (instancetype)contextWithURL:(nullable NSURL *)URL
                   isMainFrame:(BOOL)isMainFrame
                navigationType:(WKNavigationType)navigationType
                    currentURL:(nullable NSURL *)currentURL;

- (instancetype)initWithURL:(nullable NSURL *)URL
                isMainFrame:(BOOL)isMainFrame
             navigationType:(WKNavigationType)navigationType
                 currentURL:(nullable NSURL *)currentURL;

@end

/// Output from classifier. Decision engine executes according to action.
@interface DeepLinkOutcome : NSObject

@property (nonatomic, readonly) DeepLinkAction action;
@property (nonatomic, readonly, copy, nullable) NSURL *URL;

+ (instancetype)allowOutcomeWithURL:(nullable NSURL *)URL;

+ (instancetype)cancelOutcomeWithURL:(nullable NSURL *)URL;

+ (instancetype)openExternalOutcomeWithURL:(NSURL *)URL;

+ (instancetype)checkUniversalLinkOutcomeWithURL:(NSURL *)URL;

@end

/// Pure logic classifier: only reads inputs and configuration, no I/O.
@interface DeepLinkClassifier : NSObject

@property (nonatomic, strong, readonly) DeepLinkOptions *options;
@property (nonatomic, strong, readonly) UserInteractionState *interactionState;

- (instancetype)initWithOptions:(DeepLinkOptions *)options
               interactionState:(UserInteractionState *)interactionState;

- (instancetype)initWithOptions:(DeepLinkOptions *)options;

- (DeepLinkOutcome *)classifyContext:(DeepLinkNavigationContext *)context;

+ (BOOL)isNavigationGesture:(WKNavigationType)navigationType;

@end

#pragma mark - Decision Execution

/// Capability to open URLs; mock implementation can be injected for unit tests.
@protocol URLOpening <NSObject>

/// When universalLinksOnly is YES, only valid Universal Links for installed apps will be opened.
- (void)openURL:(NSURL *)URL
universalLinksOnly:(BOOL)universalLinksOnly
completionHandler:(void (^)(BOOL success))completionHandler;

@end

/// Default implementation based on UIApplication.
@interface UIApplicationURLOpener : NSObject <URLOpening>
@end

/// App Store search helper: itms-apps primary, https fallback.
/// Automatically invoked by engine on open failure; host may also call it manually.
@interface AppStoreSearch : NSObject

@property (nonatomic, strong, readonly) id<URLOpening> opener;

- (instancetype)initWithOpener:(id<URLOpening>)opener;

- (void)searchForScheme:(NSString *)scheme;

- (void)searchForScheme:(NSString *)scheme completion:(nullable void (^)(BOOL success))completion;

@end

/// Callback for decision result; engine only returns final allow / cancel.
typedef void (^DeepLinkDecisionCompletion)(DeepLinkOutcome *outcome);

/// Executes classification results: allow, cancel, external jump and failure notification.
@interface DeepLinkDecisionEngine : NSObject

@property (nonatomic, strong, readonly) DeepLinkOptions *options;
@property (nonatomic, strong, readonly) UserInteractionState *interactionState;
@property (nonatomic, strong, readonly) DeepLinkClassifier *classifier;
@property (nonatomic, strong, readonly) id<URLOpening> opener;
@property (nonatomic, strong, readonly) AppStoreSearch *appStoreSearch;

- (instancetype)initWithOptions:(DeepLinkOptions *)options
               interactionState:(UserInteractionState *)interactionState
                          opener:(id<URLOpening>)opener;

- (instancetype)initWithOptions:(DeepLinkOptions *)options
                         opener:(id<URLOpening>)opener;

/// For external jumps, cancel is callbacked first, then open the App; auto App Store search on failure by default.
- (void)handleNavigationContext:(DeepLinkNavigationContext *)context
                decisionHandler:(nullable DeepLinkDecisionCompletion)decisionHandler;

/// Pre-check for the initial URL from loadURLByUser.
/// Returns YES if check has started; NO means no check, caller should load directly.
- (BOOL)checkUniversalLinkIfNeeded:(NSURL *)URL
                        completion:(nullable void (^)(BOOL opened))completion;

@end

#pragma mark - WebView Integration

/// WKWebView deep link client: takes over delegate and installs interaction gesture recognizers.
/// Host must strongly retain the returned instance, otherwise delegate will stop working.
@interface DeepLinkWebViewClient : NSObject <WKNavigationDelegate, WKUIDelegate>

@property (nonatomic, strong, readonly) DeepLinkOptions *options;
@property (nonatomic, strong, readonly) UserInteractionState *interactionState;
@property (nonatomic, strong, readonly) DeepLinkDecisionEngine *engine;
/// Weak reference to avoid retain cycle.
@property (nonatomic, weak, readonly, nullable) WKWebView *webView;

+ (instancetype)attachToWebView:(WKWebView *)webView;

+ (instancetype)attachToWebView:(WKWebView *)webView
                        options:(DeepLinkOptions *)options;

/// Host-initiated navigation on behalf of user: https attempts Universal Link first and loads only on failure;
/// custom schemes skip loading and jump directly.
- (void)loadURLByUser:(NSURL *)URL;

/// Host loads local page; reset interaction state first and enable one-time file access for this navigation.
- (void)loadLocalFileURL:(NSURL *)URL allowingReadAccessToURL:(NSURL *)readAccessURL;

@end

NS_ASSUME_NONNULL_END
objc
#import "WebViewDeepLink.h"

static NSString *const InteractionRecognizerName = @"webview.deeplink.interaction";

#pragma mark - Configuration

@implementation DeepLinkOptions

- (instancetype)init {
    self = [super init];
    if (self) {
        _requireUserGesture = YES;
        _allowAutomaticSubframeDeepLinks = NO;
        _openUniversalLinksFromUserLoads = YES;
        _autoOpenAppStoreOnFailure = YES;
        _blockedSchemes = [NSSet setWithObjects:@"file", @"content", @"intent", nil];
        _webViewOwnedSchemes = [NSSet setWithObjects:@"about", @"data", @"blob",
                                @"javascript", @"view-source", nil];
    }
    return self;
}

+ (instancetype)defaultOptions {
    return [[self alloc] init];
}

@end

#pragma mark - User Interaction State

@implementation UserInteractionState {
    BOOL _hasUserInteraction;
    BOOL _hasUserInitiatedLoad;
}

- (BOOL)hasUserInteraction {
    return _hasUserInteraction;
}

- (BOOL)hasUserInitiatedLoad {
    return _hasUserInitiatedLoad;
}

- (void)markUserInteraction {
    _hasUserInteraction = YES;
}

- (void)markUserInitiatedLoad {
    _hasUserInitiatedLoad = YES;
}

- (void)reset {
    _hasUserInteraction = NO;
    _hasUserInitiatedLoad = NO;
}

@end

#pragma mark - Classification

@interface DeepLinkOutcome ()
@property (nonatomic, assign, readwrite) DeepLinkAction action;
@property (nonatomic, copy, readwrite, nullable) NSURL *URL;
@end

@implementation DeepLinkOutcome

+ (instancetype)allowOutcomeWithURL:(nullable NSURL *)URL {
    DeepLinkOutcome *outcome = [[self alloc] init];
    outcome.action = DeepLinkActionAllow;
    outcome.URL = URL;
    return outcome;
}

+ (instancetype)cancelOutcomeWithURL:(nullable NSURL *)URL {
    DeepLinkOutcome *outcome = [[self alloc] init];
    outcome.action = DeepLinkActionCancel;
    outcome.URL = URL;
    return outcome;
}

+ (instancetype)openExternalOutcomeWithURL:(NSURL *)URL {
    DeepLinkOutcome *outcome = [[self alloc] init];
    outcome.action = DeepLinkActionOpenExternal;
    outcome.URL = URL;
    return outcome;
}

+ (instancetype)checkUniversalLinkOutcomeWithURL:(NSURL *)URL {
    DeepLinkOutcome *outcome = [[self alloc] init];
    outcome.action = DeepLinkActionCheckUniversalLink;
    outcome.URL = URL;
    return outcome;
}

@end

@implementation DeepLinkNavigationContext

+ (instancetype)contextWithURL:(nullable NSURL *)URL
                   isMainFrame:(BOOL)isMainFrame
                navigationType:(WKNavigationType)navigationType
                    currentURL:(nullable NSURL *)currentURL {
    return [[self alloc] initWithURL:URL
                         isMainFrame:isMainFrame
                      navigationType:navigationType
                          currentURL:currentURL];
}

- (instancetype)initWithURL:(nullable NSURL *)URL
                isMainFrame:(BOOL)isMainFrame
             navigationType:(WKNavigationType)navigationType
                 currentURL:(nullable NSURL *)currentURL {
    self = [super init];
    if (self) {
        _URL = [URL copy];
        _isMainFrame = isMainFrame;
        _navigationType = navigationType;
        _currentURL = [currentURL copy];
    }
    return self;
}

- (BOOL)hasNavigationGesture {
    return [DeepLinkClassifier isNavigationGesture:self.navigationType];
}

@end

@implementation DeepLinkClassifier

- (instancetype)initWithOptions:(DeepLinkOptions *)options {
    return [self initWithOptions:options
               interactionState:[[UserInteractionState alloc] init]];
}

- (instancetype)initWithOptions:(DeepLinkOptions *)options
               interactionState:(UserInteractionState *)interactionState {
    self = [super init];
    if (self) {
        _options = options;
        _interactionState = interactionState;
    }
    return self;
}

- (DeepLinkOutcome *)classifyContext:(DeepLinkNavigationContext *)context {
    NSURL *URL = context.URL;
    NSString *scheme = URL.scheme.lowercaseString;

    // No URL or scheme: hand back to WebView, host cannot make a judgement.
    if (URL == nil || scheme.length == 0) {
        return [DeepLinkOutcome allowOutcomeWithURL:URL];
    }

    // File URLs follow local page access rules first.
    if ([scheme isEqualToString:@"file"] && [self isLocalFileNavigationForContext:context]) {
        return [DeepLinkOutcome allowOutcomeWithURL:URL];
    }

    if ([self.options.blockedSchemes containsObject:scheme]) {
        return [DeepLinkOutcome cancelOutcomeWithURL:URL];
    }

    // Universal Link check for https only on cross-host main-frame navigation after user-initiated load.
    if ([scheme isEqualToString:@"http"]) {
        return [DeepLinkOutcome allowOutcomeWithURL:URL];
    }
    if ([scheme isEqualToString:@"https"]) {
        if ([self shouldCheckUniversalLinkForContext:context]) {
            return [DeepLinkOutcome checkUniversalLinkOutcomeWithURL:URL];
        }
        return [DeepLinkOutcome allowOutcomeWithURL:URL];
    }

    if ([self.options.webViewOwnedSchemes containsObject:scheme]) {
        return [DeepLinkOutcome allowOutcomeWithURL:URL];
    }

    BOOL hasGesture = context.hasNavigationGesture;

    if (!context.isMainFrame && !hasGesture && !self.options.allowAutomaticSubframeDeepLinks) {
        return [DeepLinkOutcome cancelOutcomeWithURL:URL];
    }

    if (self.options.requireUserGesture
        && !hasGesture
        && !self.interactionState.hasUserInteraction) {
        return [DeepLinkOutcome cancelOutcomeWithURL:URL];
    }

    if (self.options.shouldOpenExternalURL != nil
        && !self.options.shouldOpenExternalURL(URL)) {
        return [DeepLinkOutcome cancelOutcomeWithURL:URL];
    }

    return [DeepLinkOutcome openExternalOutcomeWithURL:URL];
}

/// Only process main-frame, WKNavigationTypeOther, cross-host https navigation after user-initiated load.
- (BOOL)shouldCheckUniversalLinkForContext:(DeepLinkNavigationContext *)context {
    if (!self.options.openUniversalLinksFromUserLoads) {
        return NO;
    }
    if (!self.interactionState.hasUserInitiatedLoad) {
        return NO;
    }
    if (!context.isMainFrame) {
        return NO;
    }
    if (context.navigationType != WKNavigationTypeOther) {
        return NO;
    }
    NSString *targetHost = context.URL.host;
    if (targetHost.length == 0) {
        return NO;
    }
    NSString *currentHost = context.currentURL.host;
    return currentHost.length == 0
        || [currentHost caseInsensitiveCompare:targetHost] != NSOrderedSame;
}

+ (BOOL)isNavigationGesture:(WKNavigationType)navigationType {
    return navigationType == WKNavigationTypeLinkActivated
        || navigationType == WKNavigationTypeFormSubmitted;
}

#pragma mark - file:// Local Page

/// Local page access rule: initial host load, or navigation within the same directory of the current file page.
- (BOOL)isLocalFileNavigationForContext:(DeepLinkNavigationContext *)context {
    if (context.URL == nil || ![context.URL.scheme.lowercaseString isEqualToString:@"file"]) {
        return NO;
    }
    if (context.currentURL == nil) {
        return context.navigationType == WKNavigationTypeOther;
    }
    if (![context.currentURL.scheme.lowercaseString isEqualToString:@"file"]) {
        return NO;
    }

    NSString *currentPath = (context.currentURL.URLByStandardizingPath ?: context.currentURL).path;
    NSString *targetPath = (context.URL.URLByStandardizingPath ?: context.URL).path;
    if (currentPath.length == 0 || targetPath.length == 0) {
        return NO;
    }
    // In practice, webView.url is already the target URL when loadFileURL callback fires, so allow equal paths first.
    if ([targetPath isEqualToString:currentPath]) {
        return YES;
    }
    NSString *directory = [currentPath stringByDeletingLastPathComponent];
    if (directory.length == 0) {
        return NO;
    }
    // Append path separator to avoid false positives such as /a/bc matching /a/b.
    NSString *prefix = [directory hasSuffix:@"/"] ? directory
        : [directory stringByAppendingString:@"/"];
    return [targetPath hasPrefix:prefix];
}

@end

#pragma mark - Decision Execution

@implementation UIApplicationURLOpener

- (void)openURL:(NSURL *)URL
universalLinksOnly:(BOOL)universalLinksOnly
completionHandler:(void (^)(BOOL success))completionHandler {
    NSDictionary<UIApplicationOpenExternalURLOptionsKey, id> *options =
        universalLinksOnly ? @{UIApplicationOpenURLOptionUniversalLinksOnly: @YES} : @{};
    void (^openBlock)(void) = ^{
        [UIApplication.sharedApplication openURL:URL
                                         options:options
                               completionHandler:^(BOOL success) {
            if (completionHandler != nil) {
                completionHandler(success);
            }
        }];
    };
    if ([NSThread isMainThread]) {
        openBlock();
    } else {
        dispatch_async(dispatch_get_main_queue(), openBlock);
    }
}

@end

@implementation AppStoreSearch

- (instancetype)initWithOpener:(id<URLOpening>)opener {
    self = [super init];
    if (self) {
        _opener = opener;
    }
    return self;
}

- (void)searchForScheme:(NSString *)scheme {
    [self searchForScheme:scheme completion:nil];
}

- (void)searchForScheme:(NSString *)scheme completion:(nullable void (^)(BOOL success))completion {
    if (scheme.length == 0) {
        if (completion != nil) {
            completion(NO);
        }
        return;
    }
    NSURL *itmsURL = [self appStoreURLWithScheme:@"itms-apps"
                                            host:@"itunes.apple.com"
                                            term:scheme];
    NSURL *httpsURL = [self appStoreURLWithScheme:@"https"
                                             host:@"apps.apple.com"
                                             term:scheme];
    if (itmsURL == nil && httpsURL == nil) {
        if (completion != nil) {
            completion(NO);
        }
        return;
    }

    __weak typeof(self) weakSelf = self;
    void (^finish)(BOOL) = ^(BOOL success) {
        if (completion != nil) {
            completion(success);
        }
    };
    if (itmsURL != nil) {
        [self.opener openURL:itmsURL universalLinksOnly:NO completionHandler:^(BOOL success) {
            if (success) {
                finish(YES);
                return;
            }
            AppStoreSearch *strongSelf = weakSelf;
            if (strongSelf == nil || httpsURL == nil) {
                finish(NO);
                return;
            }
            [strongSelf.opener openURL:httpsURL universalLinksOnly:NO completionHandler:^(BOOL httpsSuccess) {
                finish(httpsSuccess);
            }];
        }];
    } else {
        [self.opener openURL:httpsURL universalLinksOnly:NO completionHandler:^(BOOL success) {
            finish(success);
        }];
    }
}

- (nullable NSURL *)appStoreURLWithScheme:(NSString *)scheme
                                     host:(NSString *)host
                                     term:(NSString *)term {
    NSURLComponents *components = [[NSURLComponents alloc] init];
    components.scheme = scheme;
    components.host = host;
    components.path = @"/search";
    components.queryItems = @[[NSURLQueryItem queryItemWithName:@"term" value:term]];
    return components.URL;
}

@end

@implementation DeepLinkDecisionEngine

- (instancetype)initWithOptions:(DeepLinkOptions *)options
                         opener:(id<URLOpening>)opener {
    UserInteractionState *interactionState = [[UserInteractionState alloc] init];
    return [self initWithOptions:options
               interactionState:interactionState
                          opener:opener];
}

- (instancetype)initWithOptions:(DeepLinkOptions *)options
               interactionState:(UserInteractionState *)interactionState
                          opener:(id<URLOpening>)opener {
    self = [super init];
    if (self) {
        _options = options;
        _interactionState = interactionState;
        _opener = opener;
        _classifier = [[DeepLinkClassifier alloc] initWithOptions:options
                                                   interactionState:interactionState];
        _appStoreSearch = [[AppStoreSearch alloc] initWithOpener:opener];
    }
    return self;
}

- (void)handleNavigationContext:(DeepLinkNavigationContext *)context
                decisionHandler:(nullable DeepLinkDecisionCompletion)decisionHandler {
    DeepLinkOutcome *outcome = [self.classifier classifyContext:context];

    switch (outcome.action) {
        case DeepLinkActionAllow:
        case DeepLinkActionCancel:
            if (decisionHandler != nil) {
                decisionHandler(outcome);
            }
            break;
        case DeepLinkActionOpenExternal:
            // Cancel WebView navigation first, then hand to system to open external App.
            if (decisionHandler != nil) {
                decisionHandler([DeepLinkOutcome cancelOutcomeWithURL:context.URL]);
            }
            [self openExternalURL:outcome.URL];
            break;
        case DeepLinkActionCheckUniversalLink:
            [self.opener openURL:outcome.URL
              universalLinksOnly:YES
              completionHandler:^(BOOL opened) {
                if (decisionHandler != nil) {
                    decisionHandler(opened
                        ? [DeepLinkOutcome cancelOutcomeWithURL:outcome.URL]
                        : [DeepLinkOutcome allowOutcomeWithURL:outcome.URL]);
                }
            }];
            break;
    }
}

#pragma mark - Universal Link Check

- (BOOL)checkUniversalLinkIfNeeded:(NSURL *)URL
                        completion:(nullable void (^)(BOOL opened))completion {
    if (URL == nil) {
        return NO;
    }
    if (![URL.scheme.lowercaseString isEqualToString:@"https"]) {
        return NO;
    }
    if (!self.options.openUniversalLinksFromUserLoads) {
        return NO;
    }
    [self.opener openURL:URL
      universalLinksOnly:YES
      completionHandler:^(BOOL success) {
        if (completion != nil) {
            completion(success);
        }
    }];
    return YES;
}

#pragma mark - External Jump

- (void)openExternalURL:(NSURL *)URL {
    if (URL == nil) {
        return;
    }
    __weak typeof(self) weakSelf = self;
    [self.opener openURL:URL universalLinksOnly:NO completionHandler:^(BOOL success) {
        if (success) {
            return;
        }
        DeepLinkDecisionEngine *strongSelf = weakSelf;
        if (strongSelf == nil) {
            return;
        }
        [strongSelf handleOpenFailureForURL:URL];
    }];
}

/// Handle open failure: default to search App Store; callback host for system schemes or when auto search is disabled.
- (void)handleOpenFailureForURL:(NSURL *)URL {
    NSString *scheme = URL.scheme.lowercaseString;
    NSSet<NSString *> *systemSchemes = [NSSet setWithObjects:
        @"mailto", @"tel", @"sms", @"facetime", @"maps", @"itms", @"itms-apps", nil];
    if (!self.options.autoOpenAppStoreOnFailure
        || scheme.length == 0
        || [systemSchemes containsObject:scheme]) {
        if (self.options.onOpenFailed != nil) {
            self.options.onOpenFailed(URL);
        }
        return;
    }

    __weak typeof(self) weakSelf = self;
    [self.appStoreSearch searchForScheme:scheme completion:^(BOOL success) {
        DeepLinkDecisionEngine *strongSelf = weakSelf;
        if (strongSelf != nil && !success && strongSelf.options.onOpenFailed != nil) {
            strongSelf.options.onOpenFailed(URL);
        }
    }];
}

@end

#pragma mark - WebView Integration

@interface DeepLinkWebViewClient () <UIGestureRecognizerDelegate>
@property (nonatomic, strong, readwrite) DeepLinkOptions *options;
@property (nonatomic, strong, readwrite) UserInteractionState *interactionState;
@property (nonatomic, strong, readwrite) DeepLinkDecisionEngine *engine;
@property (nonatomic, weak, readwrite, nullable) WKWebView *webView;
/// One-time permission target for host-initiated loadFileURL; cleared once consumed.
@property (nonatomic, copy, nullable) NSURL *pendingHostFileURL;
/// One-time permission target for initial URL of loadURLByUser; skip Universal Link pre-check for first navigation.
@property (nonatomic, copy, nullable) NSURL *pendingUserLoadURL;
@property (nonatomic, strong, nullable) UITapGestureRecognizer *tapRecognizer;
@property (nonatomic, strong, nullable) UILongPressGestureRecognizer *longPressRecognizer;
@end

@implementation DeepLinkWebViewClient

#pragma mark - Integration

+ (instancetype)attachToWebView:(WKWebView *)webView {
    return [self attachToWebView:webView options:[DeepLinkOptions defaultOptions]];
}

+ (instancetype)attachToWebView:(WKWebView *)webView
                        options:(DeepLinkOptions *)options {
    DeepLinkWebViewClient *client = [[self alloc] initWithWebView:webView options:options];
    webView.navigationDelegate = client;
    webView.UIDelegate = client;
    [client installInteractionRecognizers];
    return client;
}

- (instancetype)initWithWebView:(WKWebView *)webView
                        options:(DeepLinkOptions *)options {
    self = [super init];
    if (self) {
        _webView = webView;
        _options = options;
        _interactionState = [[UserInteractionState alloc] init];
        _engine = [[DeepLinkDecisionEngine alloc] initWithOptions:options
                                                   interactionState:_interactionState
                                                              opener:[[UIApplicationURLOpener alloc] init]];
    }
    return self;
}

- (void)dealloc {
    // Remove gesture recognizers before client deallocates to avoid callbacks to released objects.
    WKWebView *webView = self.webView;
    if (webView == nil) {
        return;
    }
    if (self.tapRecognizer != nil) {
        [webView removeGestureRecognizer:self.tapRecognizer];
    }
    if (self.longPressRecognizer != nil) {
        [webView removeGestureRecognizer:self.longPressRecognizer];
    }
}

/// Install interaction gestures: both tap and very short long press mark interaction, and coexist with WebKit gestures.
- (void)installInteractionRecognizers {
    if (self.webView == nil) {
        return;
    }

    // Remove previously installed gestures from this class before re-attaching to avoid duplication.
    for (UIGestureRecognizer *recognizer in [self.webView.gestureRecognizers copy]) {
        if ([recognizer.name isEqualToString:InteractionRecognizerName]) {
            [self.webView removeGestureRecognizer:recognizer];
        }
    }

    UITapGestureRecognizer *tapRecognizer =
        [[UITapGestureRecognizer alloc] initWithTarget:self
                                                action:@selector(handleInteractionGesture:)];
    tapRecognizer.cancelsTouchesInView = NO;
    tapRecognizer.delegate = self;
    tapRecognizer.name = InteractionRecognizerName;
    [self.webView addGestureRecognizer:tapRecognizer];
    self.tapRecognizer = tapRecognizer;

    UILongPressGestureRecognizer *longPressRecognizer =
        [[UILongPressGestureRecognizer alloc] initWithTarget:self
                                                      action:@selector(handleInteractionGesture:)];
    longPressRecognizer.minimumPressDuration = 0.01;
    longPressRecognizer.cancelsTouchesInView = NO;
    longPressRecognizer.delegate = self;
    longPressRecognizer.name = InteractionRecognizerName;
    [self.webView addGestureRecognizer:longPressRecognizer];
    self.longPressRecognizer = longPressRecognizer;
}

- (void)handleInteractionGesture:(UIGestureRecognizer *)recognizer {
    if ([recognizer isKindOfClass:[UILongPressGestureRecognizer class]]) {
        // Only mark interaction on began state to avoid duplicate marking on touch up.
        if (recognizer.state == UIGestureRecognizerStateBegan) {
            [self.interactionState markUserInteraction];
        }
        return;
    }
    if (recognizer.state == UIGestureRecognizerStateEnded
        || recognizer.state == UIGestureRecognizerStateRecognized) {
        [self.interactionState markUserInteraction];
    }
}

#pragma mark - Host Entry Points

- (void)loadURLByUser:(NSURL *)URL {
    if (URL == nil) {
        return;
    }
    [self.interactionState markUserInteraction];
    [self.interactionState markUserInitiatedLoad];
    self.pendingUserLoadURL = nil;

    NSString *scheme = URL.scheme.lowercaseString;
    BOOL isHTTP = [scheme isEqualToString:@"http"] || [scheme isEqualToString:@"https"];
    if (!isHTTP) {
        // Non-http(s) URLs go through decision engine directly; load only if allowed.
        DeepLinkNavigationContext *context =
            [DeepLinkNavigationContext contextWithURL:URL
                                             isMainFrame:YES
                                          navigationType:WKNavigationTypeOther
                                              currentURL:self.webView.URL];
        __weak typeof(self) weakSelf = self;
        [self.engine handleNavigationContext:context
                             decisionHandler:^(DeepLinkOutcome *outcome) {
            if (outcome.action != DeepLinkActionAllow) {
                return;
            }
            DeepLinkWebViewClient *strongSelf = weakSelf;
            if (strongSelf != nil) {
                [strongSelf.webView loadRequest:[NSURLRequest requestWithURL:URL]];
            }
        }];
        return;
    }

    // If initial URL is https, attempt Universal Link first; http loads directly.
    __weak typeof(self) weakSelf = self;
    BOOL checking = [self.engine checkUniversalLinkIfNeeded:URL completion:^(BOOL opened) {
        if (opened) {
            return;
        }
        DeepLinkWebViewClient *strongSelf = weakSelf;
        if (strongSelf == nil) {
            return;
        }
        // Fall back to WebView after UL check fails; skip duplicate check in decidePolicy.
        strongSelf.pendingUserLoadURL = [URL copy];
        [strongSelf.webView loadRequest:[NSURLRequest requestWithURL:URL]];
    }];
    if (!checking) {
        [self.webView loadRequest:[NSURLRequest requestWithURL:URL]];
    }
}

- (void)loadLocalFileURL:(NSURL *)URL allowingReadAccessToURL:(NSURL *)readAccessURL {
    if (URL == nil || self.webView == nil) {
        return;
    }
    // Enter new browsing context: clear user interaction flag and host load authorization from previous page.
    [self.interactionState reset];
    self.pendingUserLoadURL = nil;
    self.pendingHostFileURL = [URL copy];
    [self.webView loadFileURL:URL allowingReadAccessToURL:readAccessURL];
}

#pragma mark - WKNavigationDelegate

- (void)webView:(WKWebView *)webView
    decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction
                    decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
    NSURL *URL = navigationAction.request.URL;

    // Explicitly loaded local file by host: allow once per host intent.
    if ([self consumePendingHostFileURLIfNeeded:URL]) {
        decisionHandler(WKNavigationActionPolicyAllow);
        return;
    }

    if (URL == nil) {
        decisionHandler(WKNavigationActionPolicyAllow);
        return;
    }

    // Fallback load after UL check failure: skip duplicate check and load directly.
    if (self.pendingUserLoadURL != nil
        && [self.pendingUserLoadURL.absoluteString isEqualToString:URL.absoluteString]) {
        self.pendingUserLoadURL = nil;
        decisionHandler(WKNavigationActionPolicyAllow);
        return;
    }

    if ([DeepLinkClassifier isNavigationGesture:navigationAction.navigationType]) {
        [self.interactionState markUserInteraction];
    }

    // targetFrame == nil means window.open / target=_blank, treat as main frame.
    BOOL isMainFrame = navigationAction.targetFrame == nil
        ? YES
        : navigationAction.targetFrame.isMainFrame;
    DeepLinkNavigationContext *context =
        [DeepLinkNavigationContext contextWithURL:URL
                                         isMainFrame:isMainFrame
                                      navigationType:navigationAction.navigationType
                                          currentURL:webView.URL];

    // Universal Link check is async: decisionHandler must be called exactly once.
    __block BOOL hasDecided = NO;
    [self.engine handleNavigationContext:context
                         decisionHandler:^(DeepLinkOutcome *outcome) {
        if (hasDecided) {
            return;
        }
        hasDecided = YES;
        WKNavigationActionPolicy policy = outcome.action == DeepLinkActionAllow
            ? WKNavigationActionPolicyAllow
            : WKNavigationActionPolicyCancel;
        decisionHandler(policy);
    }];
}

- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation {
    self.pendingHostFileURL = nil;
}

- (void)webView:(WKWebView *)webView
    didFailNavigation:(WKNavigation *)navigation
            withError:(NSError *)error {
    self.pendingHostFileURL = nil;
}

- (BOOL)consumePendingHostFileURLIfNeeded:(nullable NSURL *)URL {
    NSURL *pendingURL = self.pendingHostFileURL;
    if (pendingURL == nil || URL == nil) {
        return NO;
    }
    if (![URL.scheme.lowercaseString isEqualToString:@"file"]) {
        return NO;
    }
    NSString *requestPath = (URL.URLByStandardizingPath ?: URL).path;
    NSString *pendingPath = (pendingURL.URLByStandardizingPath ?: pendingURL).path;
    if (requestPath.length == 0 || ![requestPath isEqualToString:pendingPath]) {
        return NO;
    }
    self.pendingHostFileURL = nil;
    return YES;
}

#pragma mark - WKUIDelegate

/// Fallback for new windows: no multi-tab capability, always return nil.
/// JS window.open only hits this callback, not decidePolicyFor.
- (nullable WKWebView *)webView:(WKWebView *)webView
    createWebViewWithConfiguration:(WKWebViewConfiguration *)configuration
               forNavigationAction:(WKNavigationAction *)navigationAction
                    windowFeatures:(WKWindowFeatures *)windowFeatures {
    NSURL *URL = navigationAction.request.URL;
    NSString *scheme = URL.scheme.lowercaseString;
    if ([scheme isEqualToString:@"http"] || [scheme isEqualToString:@"https"]) {
        [webView loadRequest:navigationAction.request];
        return nil;
    }

    if ([DeepLinkClassifier isNavigationGesture:navigationAction.navigationType]) {
        [self.interactionState markUserInteraction];
    }

    DeepLinkNavigationContext *context =
        [DeepLinkNavigationContext contextWithURL:URL
                                         isMainFrame:YES
                                      navigationType:navigationAction.navigationType
                                          currentURL:webView.URL];
    [self.engine handleNavigationContext:context decisionHandler:nil];
    return nil;
}

#pragma mark - UIGestureRecognizerDelegate

- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
    // Coexist with WebKit internal gestures, otherwise link taps and scrolling will be consumed by interaction gestures.
    return YES;
}

@end
  1. Integrate in ViewController
objc
// ...

DeepLinkWebViewClient *_deepLinkClient;

// ...

_deepLinkClient = [DeepLinkWebViewClient attachToWebView:_webView options:options];

// ...

// If you need the first link loaded by the WebView to support app awakening, you can call this【Optional】
[_deepLinkClient loadURLByUser:URL];

// ...

Was this page helpful?

Thank you for your help in improving PayerMax Product Docs!

Released under the MIT License.