Accede a las APIs nativas con el puente de JavaScript

En esta página, se analizan los diversos métodos y las prácticas recomendadas para establecer un puente nativo, también conocido como puente de JavaScript, que facilite la comunicación entre el contenido web en un WebView y una aplicación para Android host.

Esto permite que los desarrolladores web usen JavaScript para acceder a funciones nativas de la plataforma, como la cámara, el sistema de archivos o los sensores de hardware avanzados, que las APIs web estándar no suelen proporcionar.

Casos de uso

Una implementación de puente de JavaScript permite varias situaciones de integración en las que el contenido web requiere un acceso más profundo al sistema operativo Android. Estos son algunos ejemplos:

  • Integración en la plataforma: Activación de componentes de IU nativos de Android (por ejemplo, mensajes biométricos, BottomSheetDialog) desde una página web
  • Rendimiento: Descarga tareas de procesamiento pesadas en código nativo de Java o Kotlin.
  • Persistencia de datos: Acceder a bases de datos locales encriptadas o a preferencias compartidas
  • Transferencias de datos grandes: Pasar archivos multimedia o estructuras de datos complejas entre la app y el renderizador web

Mecanismos de comunicación

Android ofrece tres generaciones principales de APIs para establecer un puente nativo. Si bien todos siguen disponibles, difieren significativamente en cuanto a seguridad, usabilidad y rendimiento.

Usar addWebMessageListener (recomendado)

addWebMessageListener es el enfoque más moderno y recomendado para la comunicación entre el contenido web y el código de la app nativa. Combina la facilidad de uso de la interfaz de JavaScript con la seguridad del sistema de mensajería.

Cómo funciona: La app agrega un objeto de escucha con un nombre específico y un conjunto de reglas de origen permitidas. Luego, WebView se asegura de que el objeto JavaScript esté presente en el alcance global (window.objectName) desde el momento en que comienza a cargarse la página.

Inicialización: Para asegurarte de que WebView inserte el objeto JavaScript antes de que se ejecute cualquier secuencia de comandos, debes llamar a addWebMessageListener antes de llamar a loadUrl().

Funciones principales:

  • Seguridad y confianza: A diferencia de las APIs heredadas, este método requiere un Set<String> de allowedOriginRules durante la inicialización. Este es el mecanismo principal para establecer la confianza.

    Cuando especificas un origen de confianza, como https://example.com, WebView garantiza que solo expone los objetos de JavaScript insertados a las páginas web cargadas desde ese origen exacto.

    La devolución de llamada del objeto de escucha nativo recibe un parámetro sourceOrigin con cada mensaje. Puedes usarlo para verificar el origen exacto del remitente si tu puente admite varios orígenes permitidos.

    Dado que WebView aplica estrictamente estas verificaciones de origen a nivel de la plataforma, tu app puede confiar en general en los mensajes recibidos de un sourceOrigin de confianza como veraces, lo que elimina la necesidad de una validación rigurosa de la carga útil en la mayoría de las implementaciones estándar.

    • WebView compara las reglas con el esquema (HTTP/HTTPS), el host y el puerto.
    • WebView ignora las rutas de acceso. Por ejemplo, https://example.com permite https://example.com/login y https://example.com/home.
    • WebView limita estrictamente los comodines al inicio del host para los subdominios. Por ejemplo, https://*.example.com coincide con https://foo.example.com, pero no con https://example.com. Si necesitas hacer coincidir tanto https://example.com como sus subdominios, debes agregar cada regla de origen por separado a la lista de entidades permitidas (por ejemplo, "https://example.com", "https://*.example.com"). No puedes usar comodines para el esquema ni en el medio de un dominio.

    Esto restringe el puente a dominios verificados, lo que evita que se ejecute código nativo desde contenido de terceros no autorizado o iframes insertados.

  • Compatibilidad con varios marcos: Funciona en todos los marcos que coinciden con las reglas de origen.

  • Subprocesos: La devolución de llamada del objeto de escucha se ejecuta en el subproceso principal (IU) de la aplicación. Si tu puente necesita controlar el procesamiento de datos complejos, el análisis de JSON o las búsquedas en bases de datos, debes descargar ese trabajo en un subproceso en segundo plano para evitar que la IU de la aplicación se bloquee con un error de "app no responde" (ANR).

  • Bidireccional: Cuando la página web envía un mensaje, la app recibe un objeto JavaScriptReplyProxy que puede usar para enviar mensajes a ese marco específico. Puedes conservar este objeto replyProxy y usarlo en cualquier momento para enviar la cantidad de mensajes que quieras a la página, no solo para responder a cada mensaje individual que envíe la página. Si el frame de origen se desplaza o se destruye, los mensajes enviados con postMessage() en el proxy se ignoran de forma silenciosa.

  • Inicio del lado de la app: Si bien la página web siempre debe iniciar el canal de comunicación con la app, la app nativa puede solicitar unilateralmente a la página web que comience este proceso. La app nativa puede comunicarse con la página web con addDocumentStartJavaScript() (para evaluar JavaScript antes de que se cargue la página) o evaluateJavaScript() (para evaluar JavaScript después de que se cargue la página).

Limitación: Esta API envía datos como cadenas o arreglos de byte[]. Para estructuras de datos más complicadas, como objetos JSON, debes serializar esto en uno de esos formatos y, luego, deserializarlo en el otro extremo para reconstruir la estructura de datos.

Ejemplo de uso:

Para comprender la secuencia completa de un intercambio de mensajes bidireccional, los eventos se suceden en este orden:

  1. Inicio (app): La app nativa registra el objeto de escucha con addWebMessageListener y carga la página web con loadUrl().
  2. Envío de mensajes (Web): El JavaScript de la página web llama a myObject.postMessage(message) para iniciar la comunicación.
  3. Recepción y respuesta de mensajes (app): La app recibe el mensaje en la devolución de llamada del objeto de escucha y responde con el objeto replyProxy.postMessage() proporcionado.
  4. Recepción de respuesta (web): La página web recibe la respuesta asíncrona en la función de devolución de llamada myObject.onmessage().

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);
}

El siguiente código JavaScript muestra la implementación del cliente de addWebMessageListener, lo que permite que el contenido web reciba mensajes de la app nativa y envíe sus propios mensajes a través del proxy myObject.

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

Usa postWebMessage (alternativa)

Android introdujo esto para proporcionar una alternativa asíncrona basada en mensajes similar a window.postMessage de la Web.

Cómo funciona: La app usa WebViewCompat.postWebMessage para enviar una carga útil al marco principal de la página web. Para establecer un canal de comunicación bidireccional, puedes crear un WebMessageChannel y pasar uno de sus puertos con el mensaje al contenido web.

Características:

  • Asíncrono: Al igual que addWebMessageListener, este método usa mensajería asíncrona, lo que garantiza que la página web siga respondiendo a las interacciones del usuario mientras la app procesa datos en segundo plano.
  • Conocimiento del origen: Puedes especificar un targetOrigin para garantizar que WebView entregue datos solo a un sitio web de confianza.

Limitaciones:

  • Alcance: Esta API limita la comunicación al marco principal. No admite el envío de mensajes ni la dirección directa a los elementos iframe.
  • Restricciones de URI: No puedes usar este método para el contenido cargado con URIs data:, URIs file: o loadData(), a menos que especifiques "*" como el origen de destino. Si lo haces, cualquier página podrá recibir el mensaje.
  • Riesgo de identidad: No hay una forma clara para que el contenido web verifique la identidad del remitente. Un mensaje que recibe la página web podría haberse originado en tu app nativa o en otro iframe.

Usa este método cuando necesites un canal asíncrono simple para datos basados en cadenas en versiones anteriores de Android que no admiten addWebMessageListener.

Usa addJavascriptInterface (heredado)

El método más antiguo consiste en insertar una instancia de objeto nativo directamente en WebView.

Cómo funciona: Defines una clase de Kotlin o Java, anotas los métodos permitidos con @JavascriptInterface y agregas una instancia de la clase al WebView con addJavascriptInterface(Object, String).

Características:

  • Síncrono: El entorno de ejecución de JavaScript se bloquea hasta que el método en tu código de Android devuelve un valor.
  • Seguridad de subprocesos: El sistema llama a métodos en un subproceso en segundo plano, lo que requiere una sincronización cuidadosa en el lado de Kotlin o Java.
  • Riesgo de seguridad: De forma predeterminada, addJavascriptInterface está disponible para cada frame dentro de WebView, incluidos los iframes. No tiene control de acceso basado en el origen. Debido al comportamiento asíncrono de WebView, no es posible determinar de forma segura la URL del iframe que llama a tu interfaz. No debes depender de métodos como WebView.getUrl() para la verificación de seguridad, ya que no se garantiza que sean precisos y no indican qué fotograma específico realizó la solicitud.

Resumen de los mecanismos

En la siguiente tabla, se proporciona una comparación rápida de los tres mecanismos principales de implementación del puente nativo:

Método addWebMessageListener postWebMessage addJavascriptInterface
Implementación Asíncrono (Listener en el subproceso principal) Asincrónico Síncrono
Seguridad Más alta (basada en la lista de entidades permitidas) Alto (tiene en cuenta el origen) Baja (sin verificaciones de origen)
Complejidad Moderada Moderada Simple
Dirección Bidireccional Bidireccional De la Web a la app
Versión mínima de WebView Versión 82 (y Jetpack Webkit 1.3.0) Versión 45 (y Jetpack Webkit 1.1.0) Todas las versiones
Recomendado No No

Controla transferencias de datos grandes

Debes administrar la memoria con cuidado cuando transfieras cargas útiles grandes, como cadenas de varios megabytes o archivos binarios, para evitar errores de Aplicación no responde (ANR) o fallas en dispositivos de 32 bits. En esta sección, se analizan las diversas técnicas y limitaciones asociadas con la transferencia de cantidades significativas de datos entre la aplicación host y el contenido web.

Cómo transferir datos binarios con arrays de bytes

Con la clase WebMessageCompat, puedes enviar arrays de byte[] directamente en lugar de serializar datos binarios en cadenas de Base64. Dado que Base64 agrega aproximadamente un 33% de sobrecarga al tamaño de los datos, este método es significativamente más eficiente en cuanto a la memoria y más rápido.

  • Ventaja binaria: Transfiere datos binarios, como archivos de imagen o audio, entre tu app nativa y el contenido web.
  • Limitación: Incluso con los arrays de bytes, el sistema copia datos a través del límite de comunicación entre procesos (IPC) entre la app y el proceso aislado que WebView usa para renderizar el contenido web. Esto aún consume una cantidad significativa de memoria para archivos muy grandes.

En los siguientes ejemplos de código, se muestra cómo configurar addWebMessageListener en el lado de la app nativa para recibir mensajes marcados con WebMessageCompat.TYPE_ARRAY_BUFFER y, de manera opcional, responder con datos binarios verificando 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
      );
  }
}

El siguiente código JavaScript muestra la implementación del cliente de addWebMessageListener, lo que permite que el contenido web envíe y reciba datos binarios (ArrayBuffer) desde y hacia la app nativa con el proxy window.myBridge insertado en el ejemplo anterior.

// 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]);
        }
    };
}

Carga eficiente de datos a gran escala

Para archivos muy grandes (más de 10 MB), usa el método shouldInterceptRequest para transmitir datos:

  1. La página web inicia una llamada fetch() a una URL personalizada de marcador de posición. Por ejemplo, https://app.local/large-file.
  2. La app para Android intercepta esta solicitud en WebViewClient.shouldInterceptRequest.
  3. La app devuelve los datos como un InputStream.

Esto permite transmitir datos en fragmentos en lugar de cargar toda la carga útil en la memoria de una sola vez.

La siguiente función de JavaScript muestra el código del cliente para cargar de manera eficiente un archivo binario grande desde la aplicación nativa con una llamada fetch() estándar a una URL personalizada de marcador de posición.

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);
    }
}

En los siguientes ejemplos de código, se muestra el lado de la app nativa, con el método WebViewClient.shouldInterceptRequest en Kotlin y Java, para transmitir un archivo binario grande interceptando una URL de marcador de posición personalizada solicitada por el contenido 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);
  }
});

Sigue las recomendaciones de seguridad

Para proteger tu aplicación y los datos del usuario, sigue estos lineamientos cuando implementes un puente:

  • Aplicar HTTPS: Para garantizar que el contenido malicioso de terceros no pueda invocar la lógica nativa de tu aplicación, solo permite la comunicación con orígenes seguros.

  • Confía en las reglas de origen: La mejor manera de abordar la confianza es definir estrictamente tu allowedOriginRules y verificar el sourceOrigin proporcionado en la devolución de llamada del mensaje. Evita usar el comodín completo (*), que coincide con todos los orígenes, como tu única regla de origen, a menos que sea absolutamente necesario. El uso de comodines para subdominios (por ejemplo, *.example.com) sigue siendo válido y seguro para hacer coincidir varios subdominios (por ejemplo, foo.example.com, bar.example.com).

    Nota: Si bien las reglas de origen protegen contra sitios web maliciosos de terceros y los elementos iframe ocultos, no pueden proteger contra las vulnerabilidades de secuencias de comandos entre sitios (XSS) dentro de tu propio dominio de confianza. Por ejemplo, si tu página web muestra contenido generado por usuarios y es vulnerable a XSS almacenado, un atacante podría ejecutar una secuencia de comandos que actúe como tu origen de confianza. Considera aplicar la validación a las cargas útiles de los mensajes antes de ejecutar operaciones sensibles de la plataforma nativa.

  • Minimiza la superficie: Expón solo los métodos o datos específicos que requiere la página web.

  • Verifica las funciones en el tiempo de ejecución: Las APIs de puente recientes, incluida addWebMessageListener, forman parte de la biblioteca de Jetpack Webkit. Por lo tanto, siempre verifica si hay asistencia disponible con WebViewFeature.isFeatureSupported() antes de llamarlos.