wandres.dev
NOTIFICACIONES · canales y push

Construirla bien: estilos, acciones y PendingIntent

Una notificación no es un texto con un icono: es una superficie interactiva que vive fuera de tu proceso, en la interfaz de otro, y que debe seguir funcionando cuando tu aplicación ya no existe. Esta lección recorre la construcción real de una notificación moderna: el contenido mínimo obligatorio y por qué existe, los estilos enriquecidos de texto largo, imagen grande, conversación y progreso, el diseño de acciones que evitan abrir la app, y sobre todo la mecánica del `PendingIntent`, ese objeto que cede a un proceso ajeno el permiso de actuar en tu nombre. Detalla los indicadores de mutabilidad e inmutabilidad, el sistema de coincidencia que hace que dos intenciones distintas se pisen entre sí, la actualización frente a la sustitución, y la reconstrucción de la pila de navegación al abrir una pantalla profunda.

⏱ 20 min

Todo lo que construyes en Compose vive dentro de tu proceso, se dibuja con tu código y desaparece cuando el sistema decide reciclarte. Una notificación es la excepción radical a esa regla: la construyes tú, pero la dibuja, la almacena y la sirve otro proceso, el de la interfaz del sistema, que seguirá mostrándola cuando tu aplicación lleve horas muerta. Esa frontera de procesos explica prácticamente todas las decisiones extrañas de la API. Explica por qué existe una clase intermedia para describir la acción de tocar la notificación en lugar de pasar simplemente una función. Explica por qué los estilos son un catálogo cerrado y no un árbol de composición libre. Y explica por qué los errores más frecuentes de este tema no son fallos de compilación sino comportamientos silenciosamente equivocados: una notificación que abre la pantalla del usuario anterior, una barra de progreso que reaparece desde cero, un botón que no hace nada. Construir bien una notificación es, en el fondo, un ejercicio de comunicación entre procesos disfrazado de maquetación.

🎯 Al terminar esta lección sabrás
  • Componer la anatomía mínima de una notificación correcta y entender qué exige el sistema y por qué.
  • Elegir con criterio entre los estilos de texto largo, imagen grande, conversación y progreso.
  • Diseñar acciones que resuelvan la tarea sin obligar al usuario a abrir la aplicación.
  • Dominar el PendingIntent, sus indicadores de mutabilidad y su sistema de coincidencia entre peticiones.

La anatomía mínima y por qué es obligatoria

Una notificación válida necesita cuatro cosas: un canal, un icono pequeño, un título y un texto. El canal ya lo conoces de la lección anterior. El icono pequeño es obligatorio porque el sistema lo pinta sobre fondos que tú no controlas, en la barra de estado y en la interfaz de otros dispositivos vinculados, y por eso debe ser una silueta monocroma con transparencia. Un icono a todo color pasado por ese filtro se convierte en un cuadrado blanco, que es el defecto visual más frecuente y más fácil de evitar de toda la plataforma.

El constructor es acumulativo y el orden de las llamadas no importa. Lo que sí importa es la distinción entre lo que el usuario ve en estado contraído y lo que ve al expandir, porque el estado contraído es el que decide si la notificación merece atención.

val notificacion = NotificationCompat.Builder(contexto, "estado_pedido")
    .setSmallIcon(R.drawable.ic_paquete)
    .setContentTitle("Tu pedido va en camino")
    .setContentText("Llega hoy entre las 17 y las 19 horas")
    .setPriority(NotificationCompat.PRIORITY_DEFAULT)
    .setCategory(NotificationCompat.CATEGORY_STATUS)
    .setAutoCancel(true)
    .setContentIntent(intencionDeApertura)
    .build()

NotificationManagerCompat.from(contexto).notify(ID_PEDIDO, notificacion)

Hay tres detalles que se pasan por alto con frecuencia. El primero es que la prioridad heredada sigue siendo necesaria: en versiones anteriores a los canales es la única señal disponible, y la biblioteca de compatibilidad la usa para decidir el comportamiento allí. El segundo es la categoría, que no afecta a la apariencia pero sí a cómo tratan tu notificación el modo de no molestar, los modos de conducción y los dispositivos vinculados. El tercero es que el identificador numérico que pasas al emitir es la clave de identidad: emitir dos veces con el mismo identificador reemplaza en lugar de acumular, y ese comportamiento es la herramienta principal para no inundar el cajón.

⚠️
Actualizar no es lo mismo que volver a emitir

Emitir con el mismo identificador sustituye la notificación existente, pero por defecto vuelve a activar sonido, vibración e irrupción, lo que convierte una actualización de progreso en una tortura. El método para actualizaciones frecuentes consiste en marcar la notificación como silenciosa para que reemplace el contenido sin repetir la alerta. La regla práctica: la primera emisión alerta, todas las siguientes actualizan en silencio, y solo un cambio de significado real vuelve a merecer sonido.

Estilos: cuándo cada uno y por qué el catálogo es cerrado

Los estilos son plantillas que el sistema rellena con tus datos, no vistas que tú dibujas. La razón es de seguridad y de coherencia: si cada aplicación pudiera pintar libremente en el cajón, el resultado sería inconsistente y explotable. El precio es que solo tienes cuatro herramientas útiles, y elegir mal entre ellas es más caro que no usar ninguna.

📄

Texto largo

Para un cuerpo que no cabe en una línea. Al expandir muestra el texto completo. Es el estilo por defecto de cualquier contenido textual sustancial.

🖼️

Imagen grande

Para cuando la imagen es el contenido y no un adorno: una foto recibida, un gráfico, una captura. Nunca para logotipos.

💬

Conversación

Para mensajes entre personas. Muestra el historial reciente con autor y hora, y desbloquea burbujas y respuesta en línea.

📊

Progreso

No es un estilo sino una barra sobre la notificación base. Determinada cuando conoces el total, indeterminada cuando no.

El estilo de conversación merece un tratamiento aparte porque es el único que el sistema privilegia activamente. Al declarar autor, hora y contenido de cada mensaje, permites que la plataforma agrupe la conversación, la muestre en la sección de conversaciones del cajón, la convierta en burbuja flotante y la exponga en dispositivos vinculados con formato correcto. Sustituirlo por un texto largo con los nombres concatenados a mano funciona visualmente y renuncia a todo lo demás.

val yo = Person.Builder().setName("Tu").setKey("usuario_local").build()
val ana = Person.Builder().setName("Ana").setKey("u_881").build()

val estilo = NotificationCompat.MessagingStyle(yo)
    .setConversationTitle("Equipo de diseno")
    .addMessage("Subo la propuesta esta tarde", 1738000000000L, ana)
    .addMessage("Perfecto, la reviso", 1738000090000L, yo)

val chat = NotificationCompat.Builder(contexto, "mensajes_directos")
    .setSmallIcon(R.drawable.ic_chat)
    .setStyle(estilo)
    .setShortcutId("conversacion_881")
    .build()

El progreso tiene una regla que se incumple casi siempre: hay que retirarlo. Una barra determinada que llega al final debe sustituirse por un texto de resultado, y una operación cancelada o fallida debe reflejarlo. Dejar una notificación de progreso congelada al ochenta por ciento porque el proceso murió es un fallo visible que erosiona la confianza en todas las demás notificaciones de la aplicación.

Acciones: resolver sin abrir

Una acción es un botón que aparece bajo el contenido y que ejecuta una intención sin necesidad de que el usuario entre en la aplicación. El sistema muestra hasta tres, y el número óptimo es casi siempre uno o dos, porque cada botón adicional reduce la probabilidad de que se use cualquiera de ellos.

El criterio de selección es sencillo de enunciar y difícil de aplicar: una acción merece existir si resuelve la intención completa del usuario en ese contexto. Marcar como leído, posponer, archivar, responder, aceptar o rechazar una llamada cumplen la prueba. Ver detalles no la cumple, porque duplica lo que ya hace tocar la notificación.

La respuesta en línea es el caso más valioso y el más olvidado. Permite que el usuario escriba directamente sobre la notificación, con lo que la tarea entera ocurre sin cambio de contexto. Requiere declarar una entrada remota y recoger su resultado en el receptor.

val entrada = RemoteInput.Builder("clave_respuesta")
    .setLabel("Responder a Ana")
    .build()

val accionResponder = NotificationCompat.Action.Builder(
    R.drawable.ic_responder,
    "Responder",
    intencionDeRespuesta,
).addRemoteInput(entrada)
    .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
    .setAllowGeneratedReplies(true)
    .build()

Las acciones que no abren interfaz deben apuntar a un receptor de difusión o a un servicio, nunca a una actividad invisible, y deben además retirar o actualizar la notificación al terminar. Una acción que se pulsa y deja la notificación intacta le comunica al usuario que no ha pasado nada, y volverá a pulsarla.

El PendingIntent y su semántica de delegación

Aquí está el concepto central de la lección. Un PendingIntent no es una intención: es un permiso. Cuando lo creas, el sistema registra que otro proceso —la interfaz del sistema, el reloj vinculado, el altavoz inteligente— puede ejecutar esa intención concreta con tu identidad, tus permisos y tu contexto, en un momento futuro y aunque tú ya no existas. Todo lo raro de su API se sigue de esa frase.

De ahí viene la exigencia de declarar mutabilidad. Un PendingIntent inmutable congela el contenido de la intención: quien lo recibe puede dispararlo pero no modificarlo. Uno mutable permite que el receptor complete campos, lo que es imprescindible para la respuesta en línea porque el sistema debe inyectar el texto escrito. Desde Android 12 declararlo es obligatorio, y la razón histórica es contundente: un PendingIntent mutable entregado a un tercero era una escalada de privilegios directa, porque el atacante podía redirigir la intención hacia un componente privado tuyo y hacer que se ejecutase con tu autoridad.

val abrirPedido = Intent(contexto, DetallePedidoActivity::class.java)
    .putExtra("pedido", idPedido)

val intencionDeApertura = PendingIntent.getActivity(
    contexto,
    idPedido.hashCode(),                       // codigo de peticion distintivo
    abrirPedido,
    PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)

El segundo mecanismo que hay que entender es la coincidencia. El sistema considera equivalentes dos PendingIntent si comparten contexto de origen, código de petición, acción, datos, categorías, componente y tipo. Los extras no cuentan. Esa exclusión es la causa del error más costoso de todo el tema: cinco notificaciones de cinco pedidos distintos, creadas con el mismo código de petición y diferenciadas solo por un extra, colapsan en una sola, y las cinco abren el mismo pedido. El síntoma llega como un fallo de navegación aparentemente aleatorio y el diagnóstico está a tres capas de distancia.

flowchart TD
A[La app crea un PendingIntent] --> B{Existe uno equivalente}
B -->|no| C[Se registra uno nuevo en el sistema]
B -->|si| D{Indicador declarado}
D -->|UPDATE CURRENT| E[Se reutiliza y se reemplazan los extras]
D -->|CANCEL CURRENT| F[Se anula el anterior y se crea otro]
D -->|ninguno| G[Se reutiliza tal cual con los extras viejos]
C --> H[El sistema lo dispara con la identidad de la app]
E --> H
F --> H
G --> I[Bug silencioso de destino equivocado]
style I fill:#f38ba8,color:#11111b
style H fill:#a6e3a1,color:#11111b

La regla operativa que elimina la familia entera de errores es dar a cada notificación un código de petición derivado de su identidad de dominio y usar el indicador de actualización. El identificador del pedido, el de la conversación o el del mensaje sirven; un contador global incremental no, porque no es estable entre ejecuciones del proceso.

Queda un último asunto, el de la pila de retroceso. Si la notificación abre una pantalla profunda, el usuario que pulse atrás debería aterrizar en la jerarquía natural de tu aplicación y no ser expulsado al escritorio. Construir esa pila a mano es un ejercicio de frustración; existe un constructor específico que la sintetiza a partir de las relaciones de padre declaradas en el manifiesto, y su equivalente en el componente de navegación produce el mismo efecto con enlaces profundos.

val intencionConPila = TaskStackBuilder.create(contexto)
    .addNextIntentWithParentStack(abrirPedido)
    .getPendingIntent(
        idPedido.hashCode(),
        PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
    )
Una capacidad transferible es una decisión de seguridad, no de comodidad

Conviene detenerse en lo que realmente representa un PendingIntent, porque es uno de los pocos lugares de Android donde un desarrollador toca de forma cotidiana un concepto de sistemas operativos que la mayoría de plataformas mantiene oculto: la capacidad transferible. En un modelo de seguridad basado en listas de control, cada intento de acceso se comprueba contra la identidad de quien lo intenta; en un modelo basado en capacidades, se posee un objeto que ya lleva incorporado el derecho, y poseerlo equivale a poder ejercerlo. Un PendingIntent es exactamente eso: un testigo que encapsula tu autoridad y que funciona en manos de cualquiera que lo tenga, sin que se vuelva a preguntar quién lo está usando. Esa propiedad es precisamente lo que resuelve el problema imposible de origen —cómo consigue el proceso de la interfaz del sistema, que no tiene tus permisos ni tu identidad, ejecutar una acción tuya horas después de que hayas muerto— y es también, sin ninguna contradicción, lo que lo convierte en una superficie de ataque. Durante años la combinación de una intención implícita con un permiso mutable fue una de las escaladas de privilegios más productivas del ecosistema, no por un fallo de implementación sino por la lógica misma del modelo: entregar una capacidad que el receptor puede reescribir es entregar una autoridad en blanco. Que Android 12 hiciera obligatorio declarar la mutabilidad no fue endurecimiento cosmético, fue forzar a que una decisión de seguridad que siempre estuvo ahí dejara de tomarse por omisión. Y de ahí sale el hábito que conviene llevarse más allá de las notificaciones: cada vez que un objeto tuyo cruza la frontera del proceso, no estás pasando datos, estás delegando poder. La pregunta correcta no es qué necesita ver el otro lado, sino qué es lo mínimo que podría hacer con lo que le doy si resultara ser hostil. Aplicada con constancia, esa pregunta convierte la seguridad de una revisión posterior en una propiedad del diseño.

⚔️ Construye la notificación completa de una conversación
  1. Implementa una notificación con estilo de conversación que acumule mensajes reales, con autores distintos y marcas de tiempo correctas, y comprueba cómo la agrupa el sistema.
  2. Añade respuesta en línea con entrada remota y verifica que el texto llega a tu receptor. Explica por qué ese PendingIntent no puede ser inmutable.
  3. Provoca deliberadamente el error de coincidencia: emite tres notificaciones con el mismo código de petición y extras distintos, y documenta a dónde navega cada una.
  4. Corrígelo derivando el código de petición de la identidad de dominio y demuestra que las tres abren su destino correcto.
  5. Implementa un progreso determinado que se actualice en silencio, se sustituya por un resultado al terminar y se retire correctamente si la operación falla.