Guía para desarrolladores de aplicaciones de pago Android

Aprende a adaptar tu app de pagos de Android para que funcione con Pagos web y brinde una mejor experiencia del usuario a los clientes.

La API de Payment Request ofrece a la web una interfaz integrada basada en el navegador que permite a los usuarios ingresar la información de pago requerida de forma más fácil que nunca. La API también puede invocar apps de pago específicas de la plataforma.

Navegadores compatibles

  • 60
  • 15
  • 11.1

Origen

Flujo de confirmación de la compra con la app de Google Pay específica para la plataforma que usa Pagos Web

En comparación con el uso solo de intents de Android, Web Payments permite una mejor integración con el navegador, la seguridad y la experiencia del usuario:

  • La aplicación de pagos se inicia como modal en el contexto del sitio web del comercio.
  • La implementación es complementaria a la app de pagos existente, lo que te permite aprovechar la base de usuarios.
  • Se verifica la firma de la app de pagos para evitar la transferencia.
  • Las apps de pago admiten varias formas de pago.
  • Se puede integrar cualquier forma de pago, como criptomonedas o transferencias bancarias, entre otras. Las apps de pago en dispositivos Android incluso pueden integrar métodos que requieren acceso al chip de hardware del dispositivo.

Para implementar los pagos web en una app de pagos de Android, debes seguir cuatro pasos:

  1. Permite que los comercios descubran tu app de pagos.
  2. Informa al comercio si el cliente tiene un instrumento inscrito (como una tarjeta de crédito) listo para pagar.
  3. Permite que un cliente realice un pago.
  4. Verifica el certificado de firma del emisor.

Para ver los pagos web en acción, consulta la demostración de android-web-payment.

Paso 1: Permite que los comercios descubran tu app de pagos

Para que un comercio pueda usar tu app de pagos, debe usar la API de Payment Request y especificar la forma de pago que admites con el identificador de forma de pago.

Si tienes un identificador de forma de pago único para tu app, puedes configurar tu propio manifiesto de forma de pago para que los navegadores puedan descubrirla.

Paso 2: Informa al comercio si el cliente tiene un instrumento inscrito que esté listo para pagar

El comercio puede llamar a hasEnrolledInstrument() para consultar si el cliente puede realizar un pago. Puedes implementar IS_READY_TO_PAY como servicio de Android para responder esta consulta.

AndroidManifest.xml

Declara tu servicio con un filtro de intents con la acción org.chromium.intent.action.IS_READY_TO_PAY.

<service
  android:name=".SampleIsReadyToPayService"
  android:exported="true">
  <intent-filter>
    <action android:name="org.chromium.intent.action.IS_READY_TO_PAY" />
  </intent-filter>
</service>

El servicio IS_READY_TO_PAY es opcional. Si no existe ese controlador de intents en la app de pagos, el navegador web supone que la app siempre puede realizar pagos.

AIDL

La API para el servicio IS_READY_TO_PAY se define en AIDL. Crea dos archivos AIDL con el siguiente contenido:

app/src/main/aidl/org/chromium/IsReadyToPayServiceCallback.aidl

package org.chromium;
interface IsReadyToPayServiceCallback {
    oneway void handleIsReadyToPay(boolean isReadyToPay);
}

app/src/main/aidl/org/chromium/IsReadyToPayService.aidl

package org.chromium;
import org.chromium.IsReadyToPayServiceCallback;

interface IsReadyToPayService {
    oneway void isReadyToPay(IsReadyToPayServiceCallback callback);
}

Cómo implementar IsReadyToPayService

La implementación más simple de IsReadyToPayService se muestra en el siguiente ejemplo:

class SampleIsReadyToPayService : Service() {
  private val binder = object : IsReadyToPayService.Stub() {
    override fun isReadyToPay(callback: IsReadyToPayServiceCallback?) {
      callback?.handleIsReadyToPay(true)
    }
  }

  override fun onBind(intent: Intent?): IBinder? {
    return binder
  }
}

Respuesta

El servicio puede enviar su respuesta a través del método handleIsReadyToPay(Boolean).

callback?.handleIsReadyToPay(true)

Permisos

Puedes usar Binder.getCallingUid() para verificar quién es el emisor. Ten en cuenta que debes hacerlo en el método isReadyToPay, no en onBind.

override fun isReadyToPay(callback: IsReadyToPayServiceCallback?) {
  try {
    val callingPackage = packageManager.getNameForUid(Binder.getCallingUid())
    // …

Consulta Verifica el certificado de firma del emisor para saber cómo verificar que el paquete de llamada tenga la firma correcta.

Paso 3: Permite que un cliente realice un pago

El comercio llama a show() para iniciar la app de pagos para que el cliente pueda realizar un pago. La app de pagos se invoca a través de un intent PAY de Android con información de la transacción en los parámetros del intent.

La app de pagos responde con methodName y details, que son específicos para cada app y son opacos para el navegador. El navegador convierte la string details en un objeto JavaScript para el comercio a través de la deserialización JSON, pero no aplica ninguna validez más allá de eso. El navegador no modifica el details; el valor de ese parámetro se pasa directamente al comercio.

AndroidManifest.xml

La actividad con el filtro de intents PAY debe tener una etiqueta <meta-data> que identifique el identificador de forma de pago predeterminado de la app.

Para admitir varias formas de pago, agrega una etiqueta <meta-data> con un recurso <string-array>.

<activity
  android:name=".PaymentActivity"
  android:theme="@style/Theme.SamplePay.Dialog">
  <intent-filter>
    <action android:name="org.chromium.intent.action.PAY" />
  </intent-filter>

  <meta-data
    android:name="org.chromium.default_payment_method_name"
    android:value="https://bobbucks.dev/pay" />
  <meta-data
    android:name="org.chromium.payment_method_names"
    android:resource="@array/method_names" />
</activity>

El elemento resource debe ser una lista de cadenas, cada una de las cuales debe ser una URL absoluta y válida con un esquema HTTPS, como se muestra aquí.

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string-array name="method_names">
        <item>https://alicepay.com/put/optional/path/here</item>
        <item>https://charliepay.com/put/optional/path/here</item>
    </string-array>
</resources>

Parámetros

Los siguientes parámetros se pasan a la actividad como extras de intent:

  • methodNames
  • methodData
  • topLevelOrigin
  • topLevelCertificateChain
  • paymentRequestOrigin
  • total
  • modifiers
  • paymentRequestId
val extras: Bundle? = intent?.extras

methodNames

Los nombres de los métodos que se usan. Los elementos son las claves del diccionario methodData. Estos son los métodos que admite la app de pagos.

val methodNames: List<String>? = extras.getStringArrayList("methodNames")

methodData

Una asignación de cada methodNames al methodData.

val methodData: Bundle? = extras.getBundle("methodData")

merchantName

El contenido de la etiqueta HTML <title> de la página de confirmación de compras del comercio (el contexto de navegación de nivel superior del navegador)

val merchantName: String? = extras.getString("merchantName")

topLevelOrigin

El origen del comercio sin el esquema (el origen sin esquema del contexto de navegación de nivel superior). Por ejemplo, https://mystore.com/checkout se pasa como mystore.com.

val topLevelOrigin: String? = extras.getString("topLevelOrigin")

topLevelCertificateChain

La cadena de certificados del comercio (la cadena de certificados del contexto de navegación de nivel superior) Nulo para localhost y archivo en el disco, que son contextos seguros sin certificados SSL. Cada Parcelable es un paquete con una clave certificate y un valor de array de bytes.

val topLevelCertificateChain: Array<Parcelable>? =
    extras.getParcelableArray("topLevelCertificateChain")
val list: List<ByteArray>? = topLevelCertificateChain?.mapNotNull { p ->
  (p as Bundle).getByteArray("certificate")
}

paymentRequestOrigin

Origen sin esquema del contexto de navegación de iframe que invocó el constructor new PaymentRequest(methodData, details, options) en JavaScript Si el constructor se invocó desde el contexto de nivel superior, el valor de este parámetro es igual al valor del parámetro topLevelOrigin.

val paymentRequestOrigin: String? = extras.getString("paymentRequestOrigin")

total

Es la cadena JSON que representa el importe total de la transacción.

val total: String? = extras.getString("total")

Este es un ejemplo de contenido de la cadena:

{"currency":"USD","value":"25.00"}

modifiers

El resultado de JSON.stringify(details.modifiers), en el que details.modifiers solo contiene supportedMethods y total.

paymentRequestId

El campo PaymentRequest.id que las apps de "pago push" deben asociar con el estado de la transacción. Los sitios web de comercios usarán este campo para consultar las apps de “pagos push” para conocer el estado de la transacción fuera de banda.

val paymentRequestId: String? = extras.getString("paymentRequestId")

Respuesta

La actividad puede enviar su respuesta a través de setResult con RESULT_OK.

setResult(Activity.RESULT_OK, Intent().apply {
  putExtra("methodName", "https://bobbucks.dev/pay")
  putExtra("details", "{\"token\": \"put-some-data-here\"}")
})
finish()

Debes especificar dos parámetros como extras de intent:

  • methodName: Es el nombre del método que se usa.
  • details: Es una cadena JSON que contiene la información necesaria para que el comercio complete la transacción. Si el resultado es true, details debe construirse de manera que JSON.parse(details) tenga éxito.

Puedes pasar RESULT_CANCELED si no se completó la transacción en la app de pagos, por ejemplo, si el usuario no ingresó el código PIN correcto de su cuenta en la app de pagos. El navegador puede permitirle elegir otra app de pagos.

setResult(RESULT_CANCELED)
finish()

Si el resultado de la actividad de una respuesta de pago recibida de la app de pagos invocada se establece en RESULT_OK, Chrome verificará que no haya methodName y details que no estén vacíos en sus extras. Si la validación falla, Chrome mostrará una promesa rechazada de request.show() con uno de los siguientes mensajes de error para el desarrollador:

'Payment app returned invalid response. Missing field "details".'
'Payment app returned invalid response. Missing field "methodName".'

Permisos

La actividad puede verificar el llamador con su método getCallingPackage().

val caller: String? = callingPackage

El paso final es verificar el certificado de firma del emisor para confirmar que el paquete de llamada tenga la firma correcta.

Paso 4: Verifica el certificado de firma del emisor

Puedes verificar el nombre del paquete del llamador con Binder.getCallingUid() en IS_READY_TO_PAY y con Activity.getCallingPackage() en PAY. Para verificar que el llamador sea el navegador que tienes en mente, debes verificar su certificado de firma y asegurarte de que coincida con el valor correcto.

Si te orientas al nivel de API 28 o versiones posteriores, y realizas la integración con un navegador que tiene un certificado de firma único, puedes usar PackageManager.hasSigningCertificate().

val packageName: String = … // The caller's package name
val certificate: ByteArray = … // The correct signing certificate.
val verified = packageManager.hasSigningCertificate(
  callingPackage,
  certificate,
  PackageManager.CERT_INPUT_SHA256
)

Se prefiere PackageManager.hasSigningCertificate() para navegadores de certificados únicos, ya que controla correctamente la rotación de certificados. (Chrome tiene un único certificado de firma). Las apps que tienen varios certificados de firma no pueden rotarlos.

Si necesitas admitir niveles de API anteriores a 27, o si necesitas controlar navegadores con varios certificados de firma, puedes usar PackageManager.GET_SIGNATURES.

val packageName: String = … // The caller's package name
val certificates: Set<ByteArray> = … // The correct set of signing certificates

val packageInfo = getPackageInfo(packageName, PackageManager.GET_SIGNATURES)
val sha256 = MessageDigest.getInstance("SHA-256")
val signatures = packageInfo.signatures.map { sha256.digest(it.toByteArray()) }
val verified = signatures.size == certificates.size &&
    signatures.all { s -> certificates.any { it.contentEquals(s) } }