Accéder aux API natives avec le pont JavaScript

Cette page présente les différentes méthodes et bonnes pratiques pour établir un pont natif, également appelé pont JavaScript, afin de faciliter la communication entre le contenu Web d'une WebView et une application Android hôte.

Cela permet aux développeurs Web d'utiliser JavaScript pour accéder aux fonctionnalités de la plate-forme native (telles que l'appareil photo, le système de fichiers ou les capteurs matériels avancés) que les API Web standards ne fournissent normalement pas.

Cas d'utilisation

L'implémentation d'un pont JavaScript permet différents scénarios d'intégration dans lesquels le contenu Web nécessite un accès plus approfondi au système d'exploitation Android. Voici quelques exemples :

  • Intégration de la plate-forme : déclenchement de composants d'interface utilisateur Android natifs (par exemple, des invites biométriques, BottomSheetDialog) à partir d'une page Web.
  • Performances : déchargement des tâches de calcul lourdes vers du code Java ou Kotlin natif.
  • Persistance des données : accès aux bases de données chiffrées locales ou aux préférences partagées.
  • Transferts de données volumineux : transmission de fichiers multimédias ou de structures de données complexes entre l'application et le moteur de rendu Web.

Mécanismes de communication

Android propose trois générations principales d'API pour établir un pont natif. Bien qu'elles soient toutes encore disponibles, elles diffèrent considérablement en termes de sécurité, d'usabilité et de performances.

Utiliser addWebMessageListener (recommandé)

addWebMessageListener est l'approche la plus moderne et recommandée pour la communication entre le contenu Web et le code de l'application native. Elle combine la facilité d'utilisation de l'interface JavaScript avec la sécurité du système de messagerie.

Fonctionnement : l'application ajoute un écouteur avec un nom spécifique et un ensemble de règles d'origine autorisées. La WebView s'assure ensuite que l'objet JavaScript est présent dans le champ d'application global (window.objectName) à partir du moment où la page commence à se charger.

Initialisation : pour vous assurer que la WebView injecte l'objet JavaScript avant l'exécution d'un script, vous devez appeler addWebMessageListener avant d'accéder à la page (par exemple, en appelant WebViewCompat.navigate ou loadUrl).

Caractéristiques principales :

  • Sécurité et confiance : contrairement aux API héritées, cette méthode nécessite un Set<String> de allowedOriginRules lors de l'initialisation. Il s'agit du principal mécanisme d'établissement de la confiance.

    Lorsque vous spécifiez une origine approuvée, telle que https://example.com, la WebView garantit qu'elle n'expose les objets JavaScript injectés qu'aux pages Web chargées à partir de cette origine exacte.

    Le rappel de l'écouteur natif reçoit un paramètre sourceOrigin avec chaque message. Vous pouvez l'utiliser pour vérifier l'origine exacte de l'expéditeur si votre pont accepte plusieurs origines autorisées.

    Étant donné que la WebView applique strictement ces vérifications d'origine au niveau de la plate-forme, votre application peut généralement s'appuyer sur les messages reçus d'une sourceOrigin approuvée comme étant véridiques, ce qui élimine le besoin d'une validation rigoureuse de la charge utile dans la plupart des implémentations standards.

    • La WebView fait correspondre les règles au schéma (HTTP/HTTPS), à l'hôte et au port.
    • La WebView ignore les chemins d'accès. Par exemple, https://example.com autorise https://example.com/login et https://example.com/home.
    • La WebView limite strictement les caractères génériques au début de l'hôte pour les sous-domaines. Par exemple, https://*.example.com correspond à https://foo.example.com, mais pas à https://example.com. Si vous devez faire correspondre à la fois https://example.com et ses sous-domaines, vous devez ajouter chaque règle d'origine séparément à la liste d'autorisation (par exemple, "https://example.com", "https://*.example.com"). Vous ne pouvez pas utiliser de caractères génériques pour le schéma ni au milieu d'un domaine.

    Cela limite le pont aux domaines validés, empêchant ainsi l'exécution de code natif par du contenu tiers non autorisé ou des iFrames injectés.

  • Prise en charge de plusieurs frames : fonctionne sur tous les frames qui correspondent aux règles d'origine.

  • Threading : le rappel de l'écouteur s'exécute sur le thread principal (UI) de l'application. Si votre pont doit gérer un traitement de données complexe, une analyse JSON ou des recherches dans une base de données, vous devez décharger ce travail sur un thread en arrière-plan pour éviter de bloquer l'interface utilisateur de l'application avec une erreur "L'application ne répond pas" (ANR).

  • Bidirectionnel : lorsque la page Web envoie un message, l’application reçoit un JavaScriptReplyProxy qu’elle peut utiliser pour renvoyer des messages à ce frame spécifique. Vous pouvez conserver cet objet replyProxy et l'utiliser à tout moment pour envoyer n'importe quel nombre de messages à la page, et pas seulement pour répondre à chaque message individuel envoyé par la page. Si le frame d'origine quitte la page ou est détruit, les messages envoyés à l'aide de postMessage() sur le proxy sont ignorés en mode silencieux.

  • Initiation côté application : bien que la page Web doive toujours lancer le canal de communication avec l'application, l'application native peut inviter unilatéralement la page Web à démarrer ce processus. L'application native peut communiquer avec la page Web à l'aide de addDocumentStartJavaScript() (pour évaluer JavaScript avant le chargement de la page) ou de evaluateJavaScript() (pour évaluer JavaScript une fois la page chargée).

Limitation : cette API envoie des données sous forme de chaînes ou de tableaux byte[]. Pour les structures de données plus complexes, telles que les objets JSON, vous devez les sérialiser dans l'un de ces formats, puis les désérialiser de l'autre côté pour reconstruire la structure de données.

Exemple d'utilisation :

Pour comprendre la séquence complète d'un échange de messages bidirectionnel, les événements se déroulent dans l'ordre suivant :

  1. Initiation (application) : l'application native enregistre l'écouteur avec addWebMessageListener et lance la navigation sur les pages (par exemple, avec WebViewCompat.navigate ou loadUrl).
  2. Envoi de message (Web) : le code JavaScript de la page Web appelle myObject.postMessage(message) pour lancer la communication.
  3. Réception et réponse au message (application) : l'application reçoit le message dans le rappel de l'écouteur et répond à l'aide de replyProxy.postMessage() fourni.
  4. Réception de la réponse (Web) : la page Web reçoit la réponse asynchrone dans la myObject.onmessage() fonction de rappel.

Kotlin

val myListener = WebViewCompat.WebMessageListener { _, _, _, _, replyProxy ->
    // Handle the message from JS
    replyProxy.postMessage("Acknowledged!")
}

// Check whether the WebView version supports the feature.
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
    val allowedOrigins = setOf("https://www.example.com")
    WebViewCompat.addWebMessageListener(webView, "myObject", allowedOrigins, myListener)
}

Java

WebMessageListener myListener = (view, message, sourceOrigin, isMainFrame, replyProxy) -> {
    // Handle the message from JS
    replyProxy.postMessage("Acknowledged!");
};

// Check whether the WebView version supports the feature.
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
    Set<String> allowedOrigins = Set.of("https://www.example.com");
    WebViewCompat.addWebMessageListener(webView, "myObject", allowedOrigins, myListener);
}

Le code JavaScript suivant illustre l'implémentation côté client de addWebMessageListener, ce qui permet au contenu Web de recevoir des messages de l'application native et d'envoyer ses propres messages via le proxy myObject.

myObject.onmessage = function(event) {
    console.log("App says: " + event.data);
};
myObject.postMessage("Hello world!");

Utiliser postWebMessage (alternative)

Android a introduit cette fonctionnalité pour fournir une alternative asynchrone basée sur la messagerie, semblable à window.postMessage du Web.

Fonctionnement : l'application utilise WebViewCompat.postWebMessage pour envoyer une charge utile au frame principal de la page Web. Pour établir un canal de communication bidirectionnel, vous pouvez créer un WebMessageChannel et transmettre l'un de ses ports avec le message au contenu Web.

Caractéristiques :

  • Asynchrone : comme addWebMessageListener, cette méthode utilise la messagerie asynchrone, ce qui garantit que la page Web reste réactive aux interactions de l'utilisateur pendant que l'application traite les données en arrière-plan.
  • Connaissance de l'origine : vous pouvez spécifier un targetOrigin pour vous assurer que la WebView ne fournit des données qu'à un site Web approuvé.

Limites :

  • Champ d'application : cette API limite la communication au frame principal. Elle n'est pas compatible avec l'adressage direct ni l'envoi de messages aux iFrames.
  • Restrictions URI : vous ne pouvez pas utiliser cette méthode pour le contenu chargé à l'aide des URI data:, des URIfile: ou deloadData(), sauf si vous spécifiez « * » comme origine cible. Cela permet à n'importe quelle page de recevoir le message.
  • Risque d'identité : il n'existe aucun moyen clair pour le contenu Web de vérifier l' identité de l'expéditeur. Un message reçu par la page Web peut provenir de votre application native ou d'un autre iFrame.

Utilisez cette méthode lorsque vous avez besoin d'un canal asynchrone simple pour les données basées sur des chaînes dans les versions antérieures d'Android qui ne sont pas compatibles avec addWebMessageListener.

Utiliser addJavascriptInterface (hérité)

La méthode la plus ancienne consiste à injecter directement une instance d'objet natif dans la WebView.

Fonctionnement : vous définissez une classe Kotlin ou Java, annotez les méthodes autorisées avec @JavascriptInterface et ajoutez une instance de la classe à la WebView à l'aide de addJavascriptInterface(Object, String).

Caractéristiques :

  • Synchrone : l'environnement d'exécution JavaScript se bloque jusqu'à ce que la méthode de votre code Android renvoie une valeur.
  • Sécurité des threads : le système appelle des méthodes sur un thread en arrière-plan, ce qui nécessite une synchronisation minutieuse côté Kotlin ou Java.
  • Risque de sécurité : par défaut, addJavascriptInterface est disponible pour chaque frame de la WebView, y compris les iFrames. Il ne dispose pas de contrôle d'accès basé sur l'origine. En raison du comportement asynchrone de WebView, il n'est pas possible de déterminer en toute sécurité l'URL du frame qui appelle votre interface. Vous ne devez pas vous appuyer sur des méthodes telles que WebView.getUrl() pour la vérification de la sécurité, car elles ne sont pas garanties d'être précises et n'indiquent pas quel frame spécifique a effectué la requête.

Résumé des mécanismes

Le tableau suivant fournit une comparaison rapide des trois principaux mécanismes d'implémentation de pont natif :

Méthode addWebMessageListener postWebMessage addJavascriptInterface
Implémentation Asynchrone (écouteur sur le thread principal) Asynchrone Synchrone
Sécurité La plus élevée (basée sur une liste d'autorisation) Élevée (connaissance de l'origine) Faible (aucune vérification d'origine)
Complexité Modérée Modérée Simple
Direction Bidirectionnelle Bidirectionnelle Web vers application
Version minimale de WebView Version 82 (et Jetpack Webkit 1.3.0) Version 45 (et Jetpack Webkit 1.1.0) Toutes les versions
Recommandé Oui Non Non

Gérer les transferts de données volumineux

Vous devez gérer la mémoire avec soin lorsque vous transférez des charges utiles volumineuses, telles que des chaînes de plusieurs mégaoctets ou des fichiers binaires, pour éviter les erreurs "L'application ne répond pas" (ANR) ou les plantages sur les appareils 32 bits. Cette section présente les différentes techniques et limites associées au transfert de quantités importantes de données entre l'application hôte et le contenu Web.

Transférer des données binaires avec des tableaux d'octets

Avec la classe WebMessageCompat, vous pouvez envoyer directement des tableaux byte[] au lieu de sérialiser des données binaires dans des chaînes Base64. Étant donné que Base64 ajoute environ 33% de surcharge à la taille des données, cette méthode est beaucoup plus efficace en termes de mémoire et plus rapide.

  • Avantage binaire : transférez des données binaires telles que des fichiers image ou audio entre votre application native et le contenu Web.
  • Limitation : même avec des tableaux d'octets, le système copie les données sur la limite de communication interprocessus (IPC) entre l'application et le processus isolé que WebView utilise pour afficher le contenu Web. Cela consomme toujours beaucoup de mémoire pour les fichiers très volumineux.

Les exemples de code suivants montrent comment configurer addWebMessageListener côté application native pour recevoir les messages marqués avec WebMessageCompat.TYPE_ARRAY_BUFFER et éventuellement répondre avec des données binaires en recherchant WebViewFeature.MESSAGE_ARRAY_BUFFER.

Kotlin

fun setupWebView(webView: WebView) {
  if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
      val listener = WebViewCompat.WebMessageListener { view, message, sourceOrigin, isMainFrame, replyProxy ->

          // Check if the received message is an ArrayBuffer
          if (message.type == WebMessageCompat.TYPE_ARRAY_BUFFER) {
              val binaryData: ByteArray = message.arrayBuffer
              // Process your binary data (image, audio, etc.)
              println("Received bytes: ${binaryData.size}")

              // Optional: Send a binary reply back to JavaScript.
              // This example sends a 3-byte array for simplicity.
              if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_ARRAY_BUFFER)) {
                  val replyBytes = byteArrayOf(0x01, 0x02, 0x03)
                  replyProxy.postMessage(replyBytes)
              }
          }
      }

      // "myBridge" matches the window.myBridge in JavaScript
      WebViewCompat.addWebMessageListener(
          webView,
          "myBridge",
          setOf("https://example.com"), // Security: restrict origins
          listener
      )
  }
}

Java

public void setupWebView(WebView webView) {
  if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
      WebViewCompat.WebMessageListener listener = (view, message, sourceOrigin, isMainFrame, replyProxy) -> {

          // Check if the received message is an ArrayBuffer
          if (message.getType() == WebMessageCompat.TYPE_ARRAY_BUFFER) {
              byte[] binaryData = message.getArrayBuffer();
              // Process your binary data (image, audio, etc.)
              System.out.println("Received bytes: " + binaryData.length);

              // Optional: Send a binary reply back to JavaScript.
              // This example sends a 3-byte array for simplicity.
              if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_ARRAY_BUFFER)) {
                  byte[] replyBytes = new byte[]{0x01, 0x02, 0x03};
                  replyProxy.postMessage(replyBytes);
              }
          }
      };

      // "myBridge" matches the window.myBridge in JavaScript
      WebViewCompat.addWebMessageListener(
          webView,
          "myBridge",
          Set.of("https://example.com"), // Security: restrict origins
          listener
      );
  }
}

Le code JavaScript suivant illustre l'implémentation côté client de addWebMessageListener, ce qui permet au contenu Web d'envoyer et de recevoir des données binaires (ArrayBuffer) vers et depuis l'application native à l'aide du proxy window.myBridge injecté dans l'exemple précédent.

// Function to send an image or binary buffer to the app
async function sendBinaryToApp() {
    const response = await fetch('image.jpg');
    const buffer = await response.arrayBuffer();

    // Check if the injected bridge object exists
    if (window.myBridge) {
        // You can send the ArrayBuffer directly
        window.myBridge.postMessage(buffer);
    }
}

// Receiving binary data from the app
if (window.myBridge) {
    window.myBridge.onmessage = function(event) {
        if (event.data instanceof ArrayBuffer) {
            console.log('Received binary data from App, length:', event.data.byteLength);
            // Process the binary data (for example, as a Uint8Array)
            const bytes = new Uint8Array(event.data);
            console.log('First byte:', bytes[0]);
        }
    };
}

Chargement de données à grande échelle efficace

Pour les fichiers très volumineux (> 10 Mo), utilisez la méthode shouldInterceptRequest pour diffuser les données en streaming :

  1. La page Web lance un appel fetch() à une URL personnalisée et réservée. Par exemple, https://app.local/large-file.
  2. L'application Android intercepte cette requête dans WebViewClient.shouldInterceptRequest.
  3. L'application renvoie les données sous forme d'InputStream.

Cela permet de diffuser les données en streaming par blocs plutôt que de charger l'intégralité de la charge utile en mémoire en même temps.

La fonction JavaScript suivante illustre le code côté client pour charger efficacement un fichier binaire volumineux à partir de l'application native à l'aide d'un appel fetch() standard à une URL personnalisée et réservée.

async function fetchBinaryFromApp() {
    try {
        // This URL doesn't need to exist on the internet
        const response = await fetch('https://app.local/data/large-file.bin');

        if (!response.ok) throw new Error('Network response was not okay');

        // For raw binary data:
        const arrayBuffer = await response.arrayBuffer();
        console.log('Received binary data, size:', arrayBuffer.byteLength);
        // Process buffer (for example, new Uint8Array(arrayBuffer))

        /*
        // OR for an image:
        const blob = await response.blob();
        const imageUrl = URL.createObjectURL(blob);
        document.getElementById('myImage').src = imageUrl;
        */

    } catch (error) {
        console.error('Fetch error:', error);
    }
}

Les exemples de code suivants illustrent le côté application native, à l'aide de la méthode WebViewClient.shouldInterceptRequest en Kotlin et en Java, pour diffuser un fichier binaire volumineux en interceptant une URL réservée personnalisée demandée par le contenu Web.

Kotlin

webView.webViewClient = object : WebViewClient() {
  override fun shouldInterceptRequest(
      view: WebView?,
      request: WebResourceRequest?
  ): WebResourceResponse? {
      val url = request?.url ?: return null

      // Check if this is our custom placeholder URL
      if (url.host == "app.local" && url.path == "/data/large-file.bin") {
          try {
              // 1. Get your data as an InputStream
              // (from Assets, Files, or a generated byte stream)
              val inputStream: InputStream = context.assets.open("my_data.pb")

              // 2. Define Response Headers (Crucial for CORS/Fetch)
              val headers = mutableMapOf<String, String>()
              headers["Access-Control-Allow-Origin"] = "*" // Allow fetch from any origin

              // 3. Return the response
              return WebResourceResponse(
                  "application/octet-stream", // MIME type (for example, image/jpeg)
                  "UTF-8",                   // Encoding
                  200,                       // Status Code
                  "OK",                      // Reason Phrase
                  headers,                   // Custom Headers
                  inputStream                // The actual data stream
              )
          } catch (e: Exception) {
              // Handle exception
          }
      }
      return super.shouldInterceptRequest(view, request)
  }
}

Java

webView.setWebViewClient(new WebViewClient() {
  @Override
  public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
      String urlPath = request.getUrl().getPath();
      String host = request.getUrl().getHost();

      // Check if this is our custom placeholder URL
      if ("app.local".equals(host) && "/data/large-file.bin".equals(urlPath)) {
          try {
              // 1. Get your data as an InputStream
              // (from Assets, Files, or a generated byte stream)
              InputStream inputStream = getContext().getAssets().open("my_data.pb");

              // 2. Define Response Headers (Crucial for CORS/Fetch)
              Map<String, String> headers = new HashMap<>();
              headers.put("Access-Control-Allow-Origin", "*"); // Allow fetch from any origin

              // 3. Return the response
              return new WebResourceResponse(
                  "application/octet-stream", // MIME type (for example, image/jpeg)
                  "UTF-8",                   // Encoding
                  200,                       // Status Code
                  "OK",                      // Reason Phrase
                  headers,                   // Custom Headers
                  inputStream                // The actual data stream
              );
          } catch (Exception e) {
              // Handle exception
          }
      }
      return super.shouldInterceptRequest(view, request);
  }
});

Suivre les recommandations de sécurité

Pour protéger votre application et les données utilisateur, suivez ces consignes lors de l'implémentation d'un pont :

  • Appliquer le protocole HTTPS : pour vous assurer qu'un contenu tiers malveillant ne peut pas appeler la logique native de votre application, n'autorisez la communication qu'avec des origines sécurisées.

  • S'appuyer sur les règles d'origine : la meilleure façon de gérer la confiance consiste à définir strictement vos allowedOriginRules et à vérifier la sourceOrigin fournie dans le rappel de message. Évitez d'utiliser le caractère générique complet (*), qui correspond à toutes les origines, comme seule règle d'origine, sauf en cas d'absolue nécessité. L'utilisation de caractères génériques pour les sous-domaines (par exemple, *.example.com) reste valide et sécurisée pour la mise en correspondance de plusieurs sous-domaines (par exemple, foo.example.com, bar.example.com).

    Remarque : Bien que les règles d'origine protègent contre les sites Web tiers malveillants et les iFrames masqués, elles ne peuvent pas protéger contre les failles de script intersite (XSS) au sein de votre propre domaine approuvé. Par exemple, si votre page Web affiche du contenu généré par l'utilisateur et est vulnérable au XSS stocké, un pirate informatique peut exécuter un script agissant comme votre origine approuvée. Envisagez d'appliquer une validation aux charges utiles des messages avant d'exécuter des opérations sensibles sur la plate-forme native.

  • Minimiser la surface d'attaque : n'exposez que les méthodes ou les données spécifiques dont la page Web a besoin.

  • Vérifier les fonctionnalités au moment de l'exécution : les API de pont récentes, y compris addWebMessageListener, font partie de la bibliothèque Jetpack Webkit. Par conséquent, vérifiez toujours la compatibilité à l'aide de WebViewFeature.isFeatureSupported() avant de les appeler.