Push Notifications
Las notificaciones push pueden llegar a los usuarios incluso cuando una aplicación no está abierta. El navegador se suscribe una vez, la aplicación almacena la suscripción y el servidor la usa para entregar notificaciones cuando ocurre un evento. Push gestiona la suscripción y la cancelación en el navegador. En el servidor, PushSender envía un PushMessage a una suscripción almacenada.
Configuración y requisitos previos
Las notificaciones push se proporcionan mediante un módulo separado. Agregalo a tu aplicación:
- Maven
- Gradle
<dependency>
<groupId>com.webforj</groupId>
<artifactId>webforj-push</artifactId>
</dependency>
dependencies {
implementation 'com.webforj:webforj-push'
}
Las notificaciones push requieren:
- Un despliegue de servlet, como Jetty, Spring Boot o un archivo WAR.
- Un par de claves, generadas a continuación, que el despliegue utiliza para firmar las notificaciones.
- Un origen seguro. Los navegadores rechazan las suscripciones servidas a través de cualquier cosa que no sea
https, excepto desdelocalhostdurante el desarrollo.
Para obtener más información sobre los contextos seguros y por qué son importantes, consulta la documentación de contextos seguros de MDN.
Generar las claves
Los servicios de push solo aceptan notificaciones firmadas por el despliegue al que se ha suscrito el navegador. Ejecuta el plugin de construcción una vez para cada despliegue para generar su par de claves:
- Maven
- Gradle
mvn webforj:push-keys
./gradlew webforjPushKeys
El comando genera tres líneas de configuración. Pégalos en application.properties sin las comillas o cópialos tal como se imprimen en webforj.conf. Reemplaza el sujeto con la dirección de contacto del despliegue. Debe ser una dirección mailto: o https:// que los servicios de push puedan usar para contactar al operador.
webforj.push.public-key=...
webforj.push.private-key=...
webforj.push.subject=mailto:ops@example.com
| Propiedad | Explicación |
|---|---|
webforj.push.public-key | La mitad pública del par de claves utilizado por el despliegue para firmar notificaciones |
webforj.push.private-key | La mitad privada del par de claves. Como cualquier otro secreto, mantenlo fuera del control de versiones |
webforj.push.subject | La dirección de contacto del despliegue. Debe ser una dirección mailto: o https:// a través de la cual los servicios de push puedan contactar al operador |
La aplicación lee estas propiedades al inicio. Si la configuración solo incluye algunas de ellas, el inicio falla y se informa qué propiedades faltan.
Cada navegador se suscribe a un par de claves. Si las claves cambian, el servicio de push rechaza las suscripciones existentes. La siguiente llamada a subscribe() en cada navegador reemplaza su suscripción.
Cómo funciona
El proceso tiene tres pasos:
- Suscribirse. Desde una vista,
Push.getCurrent().subscribe()solicita permiso al usuario y devuelve unaPushSubscriptionque identifica la dirección del navegador. - Almacenar. La aplicación guarda la suscripción con sus datos y la asocia con el usuario correspondiente.
- Enviar. Más tarde, desde cualquier hilo,
PushSender.send(subscription, message)pasa el mensaje al servicio de push del proveedor del navegador. El servicio muestra la notificación, ya sea que la aplicación esté abierta o no.
Push.getCurrent().subscribe().thenAccept(subscriptions::save);
sender.send(subscription,
PushMessage.create("Orden enviada").setUrl("/orders/42").build());
Las siguientes secciones explican lo que el navegador muestra y cómo manejar las fallas en cada paso.
Instancia
Recupera la instancia de push para el entorno actual:
import com.webforj.push.Push;
Push push = Push.getCurrent();
if (Push.isPresent()) {
// ...
}
Push.ifPresent(p -> {
// ...
});
Suscribiendo el navegador
Llama a subscribe() en respuesta a una acción del usuario, como hacer clic en un botón de "Habilitar notificaciones". El PendingResult devuelto se completa con la PushSubscription del navegador. Si el navegador no puede suscribirse, se completa de manera excepcional con una WebforjPushException.
PendingResult<PushSubscription> request = Push.getCurrent().subscribe();
request.thenAccept(subscription -> {
subscriptions.save(subscription);
});
request.exceptionally(throwable -> {
WebforjPushException error = (WebforjPushException) throwable.getCause();
PushStatus status = error.getStatus();
String message = error.getMessage();
return null;
});
Si el navegador ya está suscrito, llamar a subscribe() de nuevo devuelve la suscripción existente. Puedes llamarlo de manera segura en cada visita.
La primera llamada a subscribe() solicita permiso al usuario. El navegador muestra este aviso, no es parte de la interfaz de usuario de la aplicación. Debido a que los navegadores muestran el aviso solo en respuesta a una acción del usuario, llama a subscribe() desde un oyente de clic en lugar del constructor de vista.
Si el usuario bloquea el aviso, la aplicación no puede solicitarlo nuevamente para ese origen.
Almacenando suscripciones
Una suscripción representa la dirección de un navegador y pertenece al servidor. Almacénala con los datos de la aplicación, utilizando su punto final como clave. Incluye cualquier información que la aplicación necesite para seleccionar los navegadores apropiados más tarde, como el usuario asociado. Cada suscripción contiene tres valores de texto:
| Valor | Significado |
|---|---|
getEndpoint() | La URL de entrega asignada por el servicio de push del proveedor del navegador |
getP256dh() | La clave pública del navegador |
getAuth() | El secreto de autenticación del navegador |
Un usuario que se suscribe desde dos navegadores tiene dos suscripciones. Elimina una suscripción cuando su navegador se da de baja o cuando un envío informa que ha expirado. Consulta Estado de falla.
Restaurando una suscripción
getSubscription() devuelve la suscripción actual del navegador, o un resultado vacío si no existe. Úsalo para sincronizar la copia del servidor, por ejemplo, después de que el almacenamiento de la aplicación ha sido restablecido:
Push.getCurrent().getSubscription().thenAccept(existing -> {
existing.ifPresent(subscriptions::save);
});
A través de PushPermission, getPermission() informa si el usuario ha concedido, negado o no ha respondido aún al aviso de notificación. Usa este resultado para ocultar el botón de "Habilitar notificaciones" cuando hacer clic en él no tendría efecto.
Darse de baja
unsubscribe() cancela la suscripción del navegador. Se completa con la suscripción eliminada para que la aplicación pueda eliminar su copia almacenada, o con un resultado vacío si el navegador no tenía suscripción.
Push.getCurrent().unsubscribe().thenAccept(removed -> {
removed.ifPresent(subscriptions::delete);
});
Enviando notificaciones
PushSender envía un PushMessage a una suscripción almacenada. Firma el mensaje con las claves del despliegue y lo pasa al servicio de push del proveedor del navegador. Ese servicio despierta el navegador y muestra la notificación. Debido a que la operación nunca bloquea el hilo que llama, puedes invocarla desde un oyente de clic, un trabajo programado o un manejador de solicitudes.
Después de que se configuren las propiedades, el remitente está disponible como un bean que puedes inyectar en vistas, servicios y trabajos programados. Para reemplazarlo, define tu propio bean PushSender.
@Route("/orders")
public class OrdersView extends Composite<FlexLayout> {
public OrdersView(PushSender sender, PushSubscriptions subscriptions) {
// ...
}
}
Sin Spring, new PushSender() lee las claves de la configuración de la aplicación. Crea el remitente en un hilo de la aplicación, ya sea en una vista o en App.run(), y luego úsalo desde cualquier hilo. Todos los remitentes comparten un solo grupo de conexiones con los servicios de push, por lo que no hay costo por crear uno donde sea necesario.
Para notificaciones que deben enviarse más tarde o después de que el usuario se vaya, utiliza un temporizador en el servidor como TaskScheduler de Spring. No uses un temporizador de página como Interval, porque se detiene cuando se cierra la pestaña.
Componiendo un mensaje
Crea un mensaje con su título, luego configura cada otra opción en el constructor:
PushMessage message = PushMessage.create("Orden enviada")
.setBody("La orden #42 está en camino")
.setIcon("icons://icon-192x192.png")
.setUrl("/orders/42")
.setActions(List.of(new PushAction("track", "Rastrear", "/orders/42/tracking")))
.build();
PendingResult<Void> sent = sender.send(subscription, message);
sent.thenAccept(v -> status.setText("Enviado"));
sent.exceptionally(throwable -> {
WebforjPushException error = (WebforjPushException) throwable;
status.setText(error.getStatus() + ": " + error.getMessage());
return null;
});
send() devuelve inmediatamente. El PendingResult se completa cuando el servicio de push acepta el mensaje, o se completa excepcionalmente si el servicio no lo acepta. Si send() se llama en un hilo de la aplicación, como desde un oyente, sus callbacks se ejecutan en ese hilo y pueden actualizar componentes. Si la sesión que llamó a send() finaliza antes de que llegue la respuesta, los callbacks no se ejecutan, pero la notificación aún se entrega.
Un envío espera hasta 30 segundos por el servicio de push antes de fallar con UNREACHABLE. Utiliza setTimeout(Duration) para cambiar el tiempo de espera para cada remitente.
| Opción | Efecto |
|---|---|
setBody | Establece el texto que se muestra debajo del título |
setIcon | Establece la imagen que se muestra con la notificación. Acepta URLs absolutas y los protocolos icons:// y ws://. Consulta Recursos. No acepta el protocolo context:// porque los servicios de push limitan un mensaje a 4 KB |
setUrl | Establece la página que se abre cuando el usuario hace clic en la notificación. Las URLs relativas se resuelven contra la raíz de la aplicación. Si no se establece URL, se abre la raíz de la aplicación |
setActions | Establece los botones mostrados en la notificación, con una URL separada para cada botón. Consulta Soporte de navegador |
setTag | Establece una etiqueta identificativa. Si una notificación mostrada tiene la misma etiqueta, la nueva notificación la reemplaza |
setSilent | Muestra la notificación sin sonido ni vibración |
setTimeToLive | Establece cuánto tiempo el servicio de push retiene el mensaje para un dispositivo fuera de línea, hasta cuatro semanas |
setUrgency | Utiliza PushUrgency para permitir que el dispositivo retrase los mensajes de baja urgencia y ahorre batería |
setTopic | Reemplaza un mensaje que aún está esperando en el servicio de push cuando ambos mensajes tienen el mismo tema. Los temas pueden contener como máximo 32 caracteres que son seguros en una URL |
Cuando una pestaña ya muestra la página, hacer clic en la notificación enfoca la aplicación. De lo contrario, la página se abre en una nueva pestaña. Hacer clic en un botón de notificación abre su URL de la misma manera.
Cada mensaje muestra una notificación. Debido a que los navegadores no despiertan una página para un mensaje que no muestra nada, el push no se puede utilizar para actualizaciones de datos silenciosas.
Estado de falla
Cuando subscribe() o send() falla, su PendingResult informa una WebforjPushException. PushStatus identifica la razón:
| Estado | Cuándo | Qué hacer |
|---|---|---|
PERMISSION_DENIED | El usuario ha bloqueado las notificaciones para la aplicación | Explica dónde el usuario puede permitir las notificaciones en la configuración del navegador |
UNSUPPORTED | El push no es compatible con el navegador, la página no está en un contexto seguro, o la aplicación no está desplegada como un servlet | Oculta la característica |
NOT_CONFIGURED | Al menos una propiedad webforj.push.* falta o está incompleta | Genera las claves y configura las tres propiedades |
SUBSCRIPTION_EXPIRED | El servicio de push ya no reconoce la suscripción porque el usuario se desuscribió o reinstaló el navegador | Elimina la suscripción almacenada |
REJECTED | El servicio de push rechazó el mensaje; getStatusCode() contiene su respuesta | Verifica las claves y el tamaño del mensaje |
UNREACHABLE | El servicio de push no respondió antes del tiempo de espera | Intenta de nuevo más tarde |
UNKNOWN | El punto final almacenado no es una URL válida, o la suscripción o el mensaje no pudieron ser codificados | Verifica la suscripción almacenada |
Elimina suscripciones expirada durante cada envío:
sender.send(subscription, message).exceptionally(throwable -> {
WebforjPushException error = (WebforjPushException) throwable;
if (error.getStatus() == PushStatus.SUBSCRIPTION_EXPIRED) {
subscriptions.delete(subscription);
}
return null;
});
Los servicios de push desregistran suscripciones de manera perezosa. Aún aceptan el primer mensaje después de que un usuario se desuscribe, pero no va a ninguna parte. El siguiente mensaje informa SUBSCRIPTION_EXPIRED. Un envío aceptado significa que el mensaje llegó al servicio de push, no que el usuario lo vio.
Soporte de navegador
Todos los principales navegadores de escritorio y móviles muestran notificaciones push después de suscribirse. Ten en cuenta estas limitaciones:
- En iPhone y iPad, el push funciona solo para aplicaciones web añadidas a la Pantalla de Inicio en iOS 16.4 o posterior. En una pestaña de Safari,
subscribe()informaUNSUPPORTED. Consulta Aplicaciones instalables para el manifiesto de aplicación requerido. - Safari no muestra botones de notificación. Muestra mensajes con acciones sin sus botones, pero hacer clic en la notificación aún abre la URL del mensaje.
- Las WebViews de Android e iOS no muestran notificaciones.
Para detalles por navegador, consulta la tabla de compatibilidad de showNotification de MDN.
Ejemplo completo
La siguiente vista suscribe y se desuscribe el navegador, almacena suscripciones en memoria y envía un mensaje a cada suscripción almacenada. Puede enviar de inmediato o esperar ocho segundos utilizando el TaskScheduler de Spring, lo que permite que la pestaña se cierre antes de que llegue la notificación. La clase de la aplicación utiliza @EnableScheduling para hacer que el programador esté disponible.
package com.example;
import com.webforj.push.PushSubscription;
import java.util.Collection;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Service;
@Service
public class PushSubscriptions {
private final Map<String, PushSubscription> byEndpoint = new ConcurrentHashMap<>();
public void save(PushSubscription subscription) {
byEndpoint.put(subscription.getEndpoint(), subscription);
}
public void delete(PushSubscription subscription) {
byEndpoint.remove(subscription.getEndpoint());
}
public Collection<PushSubscription> findAll() {
return byEndpoint.values();
}
}