Comment intercepter les schémas d'URL personnalisés dans les redirections WebView Android

opoinstall
2026-09-21
5 min read

Comment gérer le deep linking et les schémas personnalisés dans une WebView Android ? La gestion des liens profonds (deep links) dans une WebView Android nécessite d'étendre WebViewClient, de surcharger shouldOverrideUrlLoading pour vérifier isForMainFrame, de valider les schémas personnalisés via une liste autorisée, et de lancer en toute sécurité les intentions externes autorisées afin d'empêcher les schémas non-web de rester dans le pipeline de chargement de la WebView.

L'interception des deep links dans une WebView Android est une pratique de programmation consistant à remplacer les rappels de navigation au sein d'un WebViewClient personnalisé pour capturer les protocoles non-HTTP, tels que les schémas d'URI personnalisés et les URI Chrome Intent. En évaluant les métadonnées de la requête et en validant les charges utiles de destination, l'application hôte exécute les lancements d'intentions natives tout en évitant les plantages liés aux protocoles non gérés.

Terme Définition Entité associée Rôle de l'intention de recherche
WebView Composant d'affichage basé sur Chromium qui rend le contenu web au sein des applications Android. Android SDK Informationnel / Commercial
Schéma d'URL personnalisé Protocole d'URI propriétaire permettant aux liens web de cibler des applications natives. Routage de deep link Technique / Informationnel
Web to App Processus de transition des utilisateurs depuis des pages web vers des interfaces d'applications natives. Deep Linking Mobile Informationnel

Les schémas personnalisés doivent quitter le pipeline de chargement de la WebView avant toute défaillance de Chromium.

Pourquoi les WebView Android génèrent des erreurs net ERR UNKNOWN URL SCHEME

Limites du protocole Chromium : Pourquoi les WebView ne reconnaissent nativement que les schémas web standards

Le composant WebView d'Android repose sur un moteur de rendu Chromium configuré principalement pour analyser et afficher les protocoles de ressources web standards : http://, https://, file://, data://, about: et javascript:.

Par défaut, lorsqu'aucun WebViewClient personnalisé n'est assigné, le framework Android délègue les URL non gérées au gestionnaire d'activités du système. Cependant, dès qu'une application fournit un WebViewClient personnalisé, les schémas non-HTTP(S) (tels que myapp://product/detail/1024, alipay:// ou whatsapp://) sont routés directement vers les rappels de navigation du client. Si l'application hôte laisse un schéma personnalisé dans le pipeline de chargement de la WebView au lieu de l'intercepter, le moteur Chromium tente de le résoudre comme une ressource web ordinaire. Comme les protocoles non standards ne peuvent être résolus via DNS ou des couches de transport web, le moteur renvoie une erreur de navigation de bas niveau.

Anatomie du plantage net::ERR_UNKNOWN_URL_SCHEME : Du clic utilisateur à l'échec de chargement

La transition d'une interaction utilisateur à une page d'erreur suit une séquence déterministe et rapide :

  1. Action utilisateur : L'utilisateur appuie sur un bouton d'appel à l'action ou un lien hypertexte dans une page de destination H5 intégrée.
  2. Requête de navigation : Le moteur web construit une requête de navigation contenant la chaîne d'URI cible (ex : myapp://promo/summer).
  3. Évaluation du pipeline : Si shouldOverrideUrlLoading() retourne false ou ne parvient pas à intercepter l'URI non-HTTP(S), le moteur Chromium traite l'URL comme un chargement de ressource web standard.
  4. Envoi de l'erreur : Ne trouvant aucun gestionnaire interne pour ce protocole, le moteur déclenche onReceivedError() et remplace le contenu de la WebView par le modèle d'erreur Chromium par défaut : net::ERR_UNKNOWN_URL_SCHEME.

Cette page d'erreur interrompt le parcours utilisateur, empêche le suivi de conversion et dégrade la stabilité perçue de l'application hôte.

Le risque de plantages ActivityNotFoundException en l'absence de l'application cible

Lorsque les développeurs tentent de résoudre net::ERR_UNKNOWN_URL_SCHEME, une erreur courante consiste à analyser la chaîne d'URI et à appeler context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) sans gestion d'exception adéquate.

Si l'utilisateur n'a pas l'application cible installée sur son appareil, le système d'exploitation Android ne peut trouver aucune Activity éligible pour résoudre l'Intent. Dans une exécution Android standard, appeler startActivity() sans gestionnaire correspondant provoque une android.content.ActivityNotFoundException non gérée. Si cette exception n'est pas interceptée dans un bloc try-catch, le processus de l'application hôte se termine brutalement, entraînant un plantage fatal.

Comment shouldOverrideUrlLoading intercepte les protocoles de deep linking

Évolution de l'API : Comparaison des surcharges WebResourceRequest (API 24+) avec les rappels de chaîne hérités

Le rappel shouldOverrideUrlLoading de WebViewClient permet aux applications hôtes d'inspecter et d'intercepter les requêtes de navigation avant leur exécution par le moteur web. La signature et les capacités de cette méthode ont évolué selon les niveaux d'API Android :

  • API 24+ (Android 7.0 et supérieur) : Remplace shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean. L'interface WebResourceRequest fournit des métadonnées contextuelles riches, incluant l'URL cible (request.url), la méthode de requête HTTP (request.method), les en-têtes et les indicateurs de hiérarchie de trame (request.isForMainFrame, request.hasGesture()).
  • Surcharge héritée (Dépréciée à partir de l'API 24) : Remplace shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean. Ce rappel hérité ne reçoit que la chaîne d'URL brute sans métadonnées concernant la trame requérante ou l'état de l'interaction utilisateur.

Conformément à la documentation de référence Android pour WebViewClient shouldOverrideUrlLoading, les applications modernes doivent implémenter la surcharge WebResourceRequest. Pour le support des anciennes versions d'Android, le rappel hérité doit être configuré de manière conservatrice pour éviter les failles de sécurité.

Criticité de la sécurité avec request.isForMainFrame : Bloquer le détournement d'iframe

Les contrôles au niveau de la trame principale empêchent la navigation via iframe de lancer des routes d'application externes.

Conformément au guide Android Developers sur WebResourceRequest, le rappel shouldOverrideUrlLoading peut être déclenché par des sous-trames, des iframes et des redirections de ressources en arrière-plan, ainsi que par les navigations de documents de premier niveau.

L'application forcée de request.isForMainFrame est essentielle pour la sécurité de l'application :

  • Isolation des limites de trame : Vérifier request.isForMainFrame garantit que les sous-trames et les iframes ne peuvent pas déclencher de lancements d'applications externes ou d'intentions de schémas personnalisés. Si une sous-trame tente une navigation, les chargements HTTP(S) standards doivent retourner false pour continuer le rendu dans la trame, tandis que les schémas non-HTTP(S) doivent retourner true pour supprimer l'exécution sans erreur.
  • Distinction des gestes utilisateur : Bien que isForMainFrame confirme que la requête s'applique au document primaire, elle ne prouve pas l'activation par l'utilisateur. Si votre architecture exige une vérification des gestes utilisateur, évaluez request.hasGesture() ou imposez des modales de confirmation au niveau de l'application, en notant que la documentation Android souligne certains flux utilisateur valides où hasGesture() peut renvoyer faux.

Différencier la navigation web normale des transferts vers des deep links : Quand retourner false vs true

La valeur booléenne retournée par shouldOverrideUrlLoading contrôle le cycle de vie du rendu de la WebView :

  • Retourner false : Informe le framework Android que l'application hôte n'a pas géré l'URL. La WebView poursuit la navigation web standard, chargeant l'URL dans son conteneur Chromium interne. Cette valeur est obligatoire pour les pages web standard http:// et https:// devant s'afficher dans le navigateur intégré.
  • Retourner true : Informe le framework que l'application hôte a intercepté et géré l'URL. La WebView annule immédiatement son processus de chargement interne. Cette valeur est obligatoire lors du lancement d'une Intent externe, du routage vers une activité native ou de la suppression d'un schéma non autorisé.

Gestion des App Links vérifiés dans le conteneur intégré

Lorsqu'un lien dans la WebView pointe vers le domaine HTTPS vérifié de l'application hôte (ex : https://app.example.com/product/1024), l'exécution de la navigation par défaut conduit la WebView à charger la version web de la page produit.

Pour offrir une expérience native, le WebViewClient doit inspecter les URL HTTPS entrantes. Si l'hôte correspond au domaine associé de l'application, le client intercepte la requête, valide le chemin et les paramètres de requête via un validateur de route interne (AppLinkRouteValidator), et envoie les données directement au routeur natif (internalRouter(validatedRoute)), en retournant true pour annuler le rendu web redondant.

Structurer des gestionnaires d'intentions sécurisés pour les schémas personnalisés et les Intent URIs

Analyse de la syntaxe des URI Chrome Intent : Schémas, Packages et S.browser_fallback_url

Sur Android, les pages web utilisent fréquemment des URI Chrome Intent pour structurer des deep links natifs avec des mécanismes de repli (fallback) intégrés. Une URI Intent utilise le schéma intent:// et encode les paramètres de routage, les cibles de package et les URL de repli dans une chaîne sérialisée :

intent://product/detail/1024#Intent;scheme=myapp;package=com.example.app;S.browser_fallback_url=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Dcom.example.app;end

Comme documenté dans le guide Chrome for Developers sur les URI Android Intent, l'URI se décompose en composants distincts :

  • Hôte et chemin (product/detail/1024) : Définit la destination interne de l'application.
  • scheme=myapp : Spécifie le protocole personnalisé sous-jacent.
  • package=com.example.app : Restreint la résolution de l'intention exclusivement au package Android spécifié.
  • S.browser_fallback_url=... : Un paramètre optionnel String définissant une URL web ou de boutique explicite à charger si l'application cible n'est pas installée.

Le danger de l'exécution d'intentions analysées brutes : Atténuation des vulnérabilités par injection

Un anti-patron d'implémentation courant consiste à analyser une URI entrante avec Intent.parseUri(url, Intent.URI_INTENT_SCHEME) et à transmettre l'Intent résultante directement à context.startActivity(intent).

Conformément aux conseils du guide OWASP Mobile Application Security Testing concernant les deep links non sécurisés, l'exécution d'intentions analysées brutes expose l'application à des attaques par injection d'intention. Un attaquant contrôlant la page web pourrait créer une URI Intent contenant des cibles de composants explicites malveillantes (component=com.example.app/.InternalAdminActivity), des actions sensibles ou des extras privilégiés (S.is_admin=true). La WebView pourrait désérialiser ces paramètres et lancer des activités internes ou non exportées, contournant ainsi les barrières d'authentification.

Les URI d'intention analysées doivent être validées et reconstruites avant l'envoi Android.

Reconstruction sécurisée d'intentions : Suppression des composants et restriction des actions à ACTION_VIEW

Pour éliminer les vulnérabilités d'injection, les applications hôtes ne doivent jamais lancer directement une Intent désérialisée brute. Au lieu de cela, le WebViewClient doit analyser l'URI, extraire uniquement la charge utile de données vérifiée, et reconstruire une nouvelle Intent dédiée et propre :

  1. Extraction des données : Analyser l'URI et extraire le package cible vérifié et l'URI de données.
  2. Validation du package : Vérifier que le package déclaré correspond à une liste autorisée explicite de partenaires ou de packages internes.
  3. Reconstruction : Instancier une nouvelle Intent(Intent.ACTION_VIEW, validatedDataUri).
  4. Isolation : Appliquer intent.addCategory(Intent.CATEGORY_BROWSABLE) pour garantir que seules les activités browsables peuvent résoudre la requête, définir des drapeaux explicites (FLAG_ACTIVITY_NEW_TASK), et s'assurer que intent.component et intent.selector restent nuls.

Application de listes autorisées strictes pour les schémas, hôtes, chemins et paramètres

Les schémas personnalisés (myapp://) et les URI Intent reconstruites doivent passer par un validateur strict avant exécution.

Cette implémentation de référence gère spécifiquement le schéma myapp:// de l'application hôte, en ancrant explicitement l'exécution de l'intention à com.example.app. Pour les écosystèmes multi-partenaires, les développeurs doivent maintenir un mappage schéma-vers-package strict ou privilégier les App Links HTTPS vérifiés.

Le validateur vérifie :

  • Schéma : Correspond aux schémas personnalisés autorisés (myapp).
  • Hôte : Correspond aux catégories de routage prédéfinies (open, product, event).
  • Préfixe de chemin : Conforme aux structures de route attendues (/detail/, /view/, /main/).
  • Paramètres de requête : Applique des clés autorisées (target, id, promo, utm_source) et valide que les valeurs respectent les limites de longueur (ex : ≤128\le 128 caractères) et les jeux de caractères alphanumériques.
[L'utilisateur appuie sur un lien dans la WebView Android]
                   │
                   ▼
[WebViewClient.shouldOverrideUrlLoading]
                   │
     ┌─────────────┴─────────────┐
     ▼                           ▼
[!request.isForMainFrame]   [request.isForMainFrame]
     │                           │
     ▼                           ▼
[Vérifier schéma sous-trame] [Inspecter le schéma URL]
├─ HTTP(S) -> retourner false    │
└─ Non-Web -> retourner true  ┌────┴───────────────────────────┐
   (Supprimer erreur)        ▼                                ▼
                     [HTTP / HTTPS]                 [Schéma non-HTTP(S)]
                            │                                │
                            ▼                                ▼
                   [Vérifier domaine hôte]          [CustomSchemeValidator]
                   ├─ Lien App interne              ├─ Valide -> Lancer intention ancrée
                   │  -> Routeur interne            └─ Intention -> Reconstruire & Repli
                   └─ Web externe
                      -> Retourner false

Comment gérer la visibilité des packages Android 11 et les replis vers l'App Store

La visibilité des packages affecte les requêtes de vérification tandis que les échecs de lancement utilisent des replis validés.

Le changement de visibilité des packages sous Android 11

À partir d'Android 11 (niveau API 30), Google a introduit le filtrage de la visibilité des packages pour renforcer la confidentialité des utilisateurs. Dans ce cadre, la vision d'une application sur les autres applications installées est limitée par défaut.

Il est crucial de comprendre la distinction entre le lancement d'intention et la requête d'intention :

  • Lancement d'intention direct : Les appels standards à context.startActivity(intent) ne requièrent pas de déclaration de visibilité de package. Si l'application cible est installée, le système d'exploitation la route et la lance avec succès, qu'elle soit visible ou non pour les requêtes de package.
  • Requêtes d'intention de vérification (Preflight) : Si votre architecture WebViewClient effectue une inspection préalable (comme appeler packageManager.resolveActivity(intent, 0) ou packageManager.queryIntentActivities(intent, 0) pour vérifier la présence de l'application), la requête renvoie null ou une liste vide à moins que le package ou le schéma cible ne soit déclaré dans AndroidManifest.xml.

Déclarer les éléments <queries> dans AndroidManifest.xml

Conformément au guide Android Developers sur la visibilité des packages sous Android 11, les applications effectuant des vérifications préalables doivent déclarer leurs packages cibles ou signatures d'intention dans le manifeste :

<manifest package="com.example.app">
    <!-- Déclarations optionnelles pour la découverte explicite de packages -->
    <queries>
        <package android:name="com.example.partner" />
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="myapp" />
        </intent>
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="https" android:host="app.example.com" />
        </intent>
    </queries>
</manifest>

Si votre architecture préfère les modèles de lancement standard, exécuter startActivity() dans un bloc try-catch interceptant ActivityNotFoundException fournit une implémentation résiliente, indépendante des requêtes de visibilité.

Chaînes de repli : Routage vers la boutique en cas d'absence de l'application

Lorsqu'un schéma personnalisé ou une URI Intent intercepté ne peut être lancé parce que l'application cible n'est pas installée, le WebViewClient doit exécuter une chaîne de repli explicite :

  1. Extraire et lier l'URL de repli : Pour les URI intent://, extraire le paramètre S.browser_fallback_url et vérifier qu'il pointe vers une destination autorisée. Pour les URL Google Play, vérifier que le paramètre id correspond strictement au nom du package cible (id=targetPackage) pour prévenir les exploits de redirection ouverte.
  2. Génération d'URI Market : Si aucune URL de repli n'est présente, construire une intention market : market://details?id=com.example.app.
  3. Repli vers le Web Play Store : Si Google Play est désactivé ou absent, intercepter ActivityNotFoundException sur l'intention market et charger https://play.google.com/store/apps/details?id=com.example.app.

Lier les paramètres contextuels à travers les redirections avec OpoInstall

Lorsque les utilisateurs sans l'application sont redirigés vers Google Play, les liens de boutique statiques perdent les paramètres dynamiques. OpoInstall, plateforme d'attribution et de deep linking mobile, comble ce fossé en capturant les jetons de parrainage et le contexte de campagne sur l'infrastructure d'attribution avant la redirection. Vérifiez le comportement de récupération des paramètres différés et les API d'initialisation par rapport à la version du SDK Android OpoInstall déployée en production.

Après l'installation et le premier lancement, le SDK Android OpoInstall récupère les paramètres mis en cache, permettant à l'application de restaurer la scène cible de l'utilisateur. Consultez la documentation d'intégration du SDK pour les instructions sur la réception des paramètres différés après installation.

Implémentation d'un WebViewClient de production pour l'interception sécurisée

Structurer l'architecture du WebViewClient personnalisé en Kotlin

Un WebViewClient de production coordonne la vérification des trames, la liste blanche de schémas, la reconstruction d'intentions, l'extraction de replis et le routage interne. L'implémentation encapsule les règles de validation dans des objets validateurs dédiés (AppLinkRouteValidator et CustomSchemeValidator) pour maintenir les rappels de navigation propres et maintenables.

Implémentation des modèles AppLinkRouteValidator et CustomSchemeValidator

Les objets validateurs appliquent une sécurité stricte :

  • AppLinkRouteValidator valide les domaines HTTPS internes, les préfixes de chemin autorisés et les clés de paramètres autorisées (target, id, promo, utm_source), retournant une classe de données ValidatedAppRoute.
  • CustomSchemeValidator vérifie les schémas personnalisés autorisés (myapp), valide les hôtes autorisés (open, product, event), impose des préfixes de chemin et nettoie les valeurs des paramètres de requête par rapport aux jeux de caractères alphanumériques et contraintes de longueur (≤128\le 128 chars).

Le code ci-dessous démontre comment configurer les déclarations optionnelles AndroidManifest.xml et implémenter un CustomWebViewClient thread-safe en Kotlin avec une gestion automatisée des replis. Les binaires SDK certifiés et wrappers d'intégration peuvent être obtenus depuis le centre de téléchargement SDK OpoInstall.

<!-- AndroidManifest.xml: Déclarations de visibilité de package optionnelles pour les requêtes préalables (Android 11+) -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.app">

    <!-- Déclarer les packages externes et schémas uniquement si vous utilisez des requêtes preflight -->
    <queries>
        <!-- Autoriser la découverte preflight pour des partenaires spécifiques -->
        <package android:name="com.example.partner" />
        
        <!-- Autoriser la résolution preflight de schémas de deep linking personnalisés -->
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="myapp" />
        </intent>
        
        <!-- Autoriser la résolution preflight de domaines App Link HTTPS vérifiés -->
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="https" android:host="app.example.com" />
        </intent>
    </queries>

    <application
        android:allowBackup="true"
        android:label="@string/app_name"
        android:theme="@style/Theme.AppCompat.Light.NoActionBar">
    </application>
</manifest>
// Android: WebViewClient personnalisé avec sécurité des limites de trame et validation
// Exemple d'intégration ; vérifiez les signatures de méthodes avec votre architecture.
package com.example.app.webview

import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient

data class ValidatedAppRoute(val path: String, val queryParams: Map<String, String>)

object AppLinkRouteValidator {
    private val allowedPrefixes = setOf("/open/", "/product/", "/promo/")
    private val allowedQueryKeys = setOf("target", "id", "promo", "utm_source")

    fun validate(uri: Uri): ValidatedAppRoute? {
        val path = uri.path ?: ""
        if (allowedPrefixes.none { path.startsWith(it) }) {
            return null
        }

        val sanitizedParams = mutableMapOf<String, String>()
        for (name in uri.queryParameterNames) {
            if (name !in allowedQueryKeys) {
                return null
            }
            val value = uri.getQueryParameter(name) ?: ""
            if (value.length <= 64 && value.matches(Regex("^[A-Za-z0-9_-]+$"))) {
                sanitizedParams[name] = value
            } else {
                return null
            }
        }

        return ValidatedAppRoute(path, sanitizedParams)
    }
}

object CustomSchemeValidator {
    private val allowedSchemes = setOf("myapp")
    private val allowedHosts = setOf("open", "product", "event")
    private val allowedPathPrefixes = setOf("/detail/", "/view/", "/main/")
    private val allowedQueryKeys = setOf("target", "id", "promo", "utm_source")

    fun validate(uri: Uri): Uri? {
        val scheme = uri.scheme?.lowercase() ?: return null
        val host = uri.host?.lowercase() ?: return null
        val path = uri.path ?: ""

        if (!allowedSchemes.contains(scheme) || !allowedHosts.contains(host)) {
            return null
        }

        if (allowedPathPrefixes.none { path.startsWith(it) }) {
            return null
        }

        for (name in uri.queryParameterNames) {
            if (name !in allowedQueryKeys) return null
            val value = uri.getQueryParameter(name) ?: ""
            if (value.length > 128 || !value.matches(Regex("^[A-Za-z0-9_-]+$"))) {
                return null
            }
        }

        return uri
    }
}

class CustomWebViewClient(
    private val context: Context,
    private val internalRouter: (ValidatedAppRoute) -> Unit
) : WebViewClient() {

    override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
        request ?: return false

        // Exigence de sécurité : Restreindre le routage externe aux navigations de trame principale
        if (!request.isForMainFrame) {
            val scheme = request.url.scheme?.lowercase()
            // Autoriser le chargement HTTP(S) standard en sous-trame
            return if (scheme == "http" || scheme == "https") {
                false
            } else {
                Log.w("WebViewClient", "Schéma non-web supprimé dans la sous-trame: $scheme")
                true 
            }
        }

        val url = request.url.toString()
        return handleUrlNavigation(view, url)
    }

    @Deprecated("Deprecated in Java")
    override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
        if (url == null) return false

        val uri = Uri.parse(url)
        val scheme = uri.scheme?.lowercase()

        return if (scheme == "http" || scheme == "https") {
            false
        } else {
            Log.w("WebViewClient", "Schéma externe bloqué sur rappel hérité: $scheme")
            true
        }
    }

    private fun handleUrlNavigation(view: WebView?, url: String): Boolean {
        val uri = Uri.parse(url)
        val scheme = uri.scheme?.lowercase() ?: return false

        if (scheme == "http" || scheme == "https") {
            if (scheme == "https" && uri.host?.lowercase() == "app.example.com") {
                val validatedRoute = AppLinkRouteValidator.validate(uri)
                if (validatedRoute != null) {
                    Log.i("WebViewClient", "Route HTTPS interne validée.")
                    internalRouter(validatedRoute)
                } else {
                    Log.w("WebViewClient", "App Link interne non autorisé: $uri")
                }
                return true
            }
            return false
        }

        val validatedUri = CustomSchemeValidator.validate(uri)
        if (validatedUri != null) {
            val pinnedIntent = Intent(Intent.ACTION_VIEW, validatedUri).apply {
                `package` = "com.example.app"
                addCategory(Intent.CATEGORY_BROWSABLE)
                flags = Intent.FLAG_ACTIVITY_NEW_TASK
            }
            return launchSafeIntent(pinnedIntent, fallbackAction = {
                handleStoreFallback(view, "com.example.app")
            })
        }

        if (scheme == "intent") {
            return try {
                val parsed = Intent.parseUri(url, Intent.URI_INTENT_SCHEME)
                val targetPackage = parsed.`package`
                val allowedPackages = setOf("com.example.app", "com.example.partner")
                val fallbackUrl = parsed.getStringExtra("browser_fallback_url")

                if (targetPackage != null && allowedPackages.contains(targetPackage)) {
                    val rawDataUri = parsed.data
                    if (rawDataUri != null) {
                        val validatedDataUri = CustomSchemeValidator.validate(rawDataUri)
                        if (validatedDataUri != null) {
                            val safeIntent = Intent(Intent.ACTION_VIEW, validatedDataUri).apply {
                                `package` = targetPackage
                                addCategory(Intent.CATEGORY_BROWSABLE)
                                flags = Intent.FLAG_ACTIVITY_NEW_TASK
                            }
                            launchSafeIntent(safeIntent, fallbackAction = {
                                handleIntentFallback(view, fallbackUrl, targetPackage)
                            })
                        } else {
                            Log.w("WebViewClient", "Intent avec URI invalide rejeté: $rawDataUri")
                            true
                        }
                    } else {
                        Log.w("WebViewClient", "Intent sans charge utile rejeté")
                        true
                    }
                } else {
                    Log.w("WebViewClient", "Intent ciblant un package non vérifié: $targetPackage")
                    true
                }
            } catch (e: Exception) {
                Log.e("WebViewClient", "Analyse d'intention échouée: $url", e)
                true
            }
        }

        Log.w("WebViewClient", "Navigation de schéma non vérifié bloquée: $scheme")
        return true
    }

    private fun launchSafeIntent(intent: Intent, fallbackAction: () -> Unit): Boolean {
        return try {
            context.startActivity(intent)
            true
        } catch (e: ActivityNotFoundException) {
            Log.w("WebViewClient", "Application non installée. Repli activé.")
            fallbackAction()
            true
        } catch (e: SecurityException) {
            Log.e("WebViewClient", "Exception de sécurité lors du lancement: ${intent.data}", e)
            true
        }
    }

    private fun handleIntentFallback(view: WebView?, fallbackUrl: String?, targetPackage: String) {
        if (!fallbackUrl.isNullOrBlank() && isAuthorizedFallbackUrl(fallbackUrl, targetPackage)) {
            view?.loadUrl(fallbackUrl)
        } else {
            handleStoreFallback(view, targetPackage)
        }
    }

    private fun handleStoreFallback(view: WebView?, packageName: String) {
        val marketUri = Uri.parse("market://details?id=$packageName")
        val marketIntent = Intent(Intent.ACTION_VIEW, marketUri).apply {
            flags = Intent.FLAG_ACTIVITY_NEW_TASK
        }
        try {
            context.startActivity(marketIntent)
        } catch (e: ActivityNotFoundException) {
            val webStoreUrl = "https://play.google.com/store/apps/details?id=$packageName"
            view?.loadUrl(webStoreUrl)
        }
    }

    private fun isAuthorizedFallbackUrl(url: String, targetPackage: String): Boolean {
        val uri = Uri.parse(url)
        val scheme = uri.scheme?.lowercase()
        val host = uri.host?.lowercase()

        if (scheme != "https" || host == null) return false

        return when (host) {
            "play.google.com" -> {
                uri.path == "/store/apps/details" && uri.getQueryParameter("id") == targetPackage
            }
            "app.example.com", "www.example.com" -> {
                AppLinkRouteValidator.validate(uri) != null
            }
            else -> false
        }
    }
}

Exécution thread-safe : Routage interne et cycle de vie

Lorsque AppLinkRouteValidator confirme une route interne, les transitions d'UI doivent s'exécuter sur le thread UI principal d'Android. Si internalRouter déclenche des fragments ou des transitions d'activité, encapsulez l'appel dans runOnUiThread pour éviter les plantages liés aux threads.

Matrice de diagnostic et solutions aux erreurs de redirection WebView Android

Guide de dépannage complet du deep linking dans les WebView

Symptôme / Erreur Cause première Niveaux API Point de contrôle Remédiation
net::ERR_UNKNOWN_URL_SCHEME Schéma non-web non géré Tous shouldOverrideUrlLoading manquant Intercepter le schéma et retourner true
ActivityNotFoundException App absente sur l'appareil Tous startActivity() non géré Utiliser try-catch et gérer le repli
Détournement d'intent iframe Sous-trame lançant une intention API 24+ isForMainFrame non vérifié Bloquer les schémas non-web en iframe
Requête Preflight renvoie Null Visibilité package Android 11+ API 30+ resolveActivity sans déclaration Ajouter <queries> dans le manifeste
Boucle de rechargement App Link App Link routé vers WebView Tous Domaine non intercepté Valider et router en interne

Questions fréquemment posées (FAQ)

Pourquoi ma WebView Android plante-t-elle lors de l'ouverture de schémas d'URL personnalisés ?
Un schéma personnalisé produit <code>net::ERR_UNKNOWN_URL_SCHEME</code> lorsque l'URL reste dans le pipeline de chargement de la WebView au lieu d'être interceptée. Avec un <code>WebViewClient</code>, retournez <code>true</code> après avoir géré ou supprimé le schéma non-web ; retournez <code>false</code> uniquement pour poursuivre la navigation normale. Si l'application cible est absente, appelez <code>startActivity()</code> dans un bloc <code>try-catch</code> pour éviter l'exception <code>ActivityNotFoundException</code>.
Comment gérer les URL intent:// en toute sécurité dans une WebView ?
Pour gérer les URI <code>intent://</code>, analysez l'URI avec <code>Intent.parseUri</code>, validez que le package cible et l'URI correspondent à une liste autorisée, et construisez une nouvelle <code>Intent(Intent.ACTION_VIEW, safeDataUri)</code>. Ajoutez explicitement <code>Intent.CATEGORY_BROWSABLE</code>, supprimez les composants dangereux et implémentez un repli validé lié au package cible.
Comment la visibilité des packages sous Android 11 affecte-t-elle l'interception des deep links ?
La visibilité des packages ne bloque pas <code>startActivity()</code>. Toutefois, si votre <code>WebViewClient</code> effectue des vérifications préalables comme <code>resolveActivity()</code>, vous devez déclarer le package ou le schéma dans l'élément <code>&lt;queries&gt;</code> de votre <code>AndroidManifest.xml</code>.

Résumé et cadre de décision

La gestion du deep linking dans les WebView Android nécessite de créer un pont entre les environnements de rendu web et le routage natif. S'en remettre au comportement par défaut de la WebView conduit inévitablement à des erreurs de protocole, une expérience utilisateur médiocre et des failles de sécurité.

En implémentant un WebViewClient robuste qui vérifie les limites de trame, valide les schémas, reconstruit des intentions ACTION_VIEW sécurisées et gère les replis vers les boutiques, les équipes d'ingénierie réduisent les échecs de routage. Intégrer un routage côté client fiable avec des frameworks d'attribution mobile améliore la cohérence des parcours utilisateur.

Pour explorer les architectures de deep linking natif Android, consultez la documentation d'intégration SDK, téléchargez les bibliothèques depuis le centre de téléchargement SDK OpoInstall, ou enregistrez votre application sur la console développeur OpoInstall.

Matériels connexes

Share this article