Chat notifications (production-ready) - ChatDeeplinkService: resolves client chat ID from incoming push/WS payloads using the (senderId, chatId) matrix and switches selectedDeviceProvider when the payload references a different watch (multi-device deeplink) - IncomingChatResolver in domain layer with full unit coverage of the 4-case matrix - ChatContext provider (sealed: outsideChat / list / conversation) wired into ChatListScreen and ChatConversationScreen via initState/dispose to enable WhatsApp-style suppression - notifications_init refactor: foreground CHAT_MESSAGE notifications are suppressed on chat list and matching conversation; tap navigation goes through ChatDeeplinkService - ChatSyncService.subscribeToReconnect: reconciles from REST when the WebSocket comes back from a disconnect (recovers messages missed in background) - 5s message-id dedup window in the WS listener (mitigates server-side duplicate chat-message-received events) - Reconcile from remote on conversation mount (covers cached controller from background) - AppBar refresh button + inverted pull-to-refresh in the conversation (overscroll either edge of the reverse list) WS event parser fix - chat-message-received normalises to chat_message_received; parser now accepts both that and chat_message so the conversation reactively refreshes when a watch sends a message Chat application layer - Split the conversation controller into services: chat_send_service, chat_sync_service, chat_participants_service, chat_permission_flow_service, chat_media_cleanup_service - chat_conversation_config centralises page size, polling interval, dedup window - chat_bubble_shell extracted; input bar split into smaller widgets - emoji picker sheet + emoji blocking input formatter + watch_emoji_catalog - Multipart upload header race fixed via synchronized.Lock around the shared Dio instance Videocall (carryover from earlier work in this branch) - Application services: incoming, outgoing, session - Domain entities: VideocallIncomingArgs, VideocallRoom, VideocallUserId, parseDeviceIdFromRoom helper - Views split: idle, incoming, active call, group call - Widgets: picture_in_picture_video, remote_or_fallback_video, video_call_header - videocall_config centralises timeouts, ringing duration, battery threshold - Incoming via push (channel mode) with full-screen notification + ringtone - Hangup-on-remote-left moved to controller; redirect on participant update documented - treezor_token_interceptor: distinguish session expiry from operation-denied 401s Localization & misc - New keys for chat conversation, refresh, errors across en/es/de/fr/it/pt - Location map: dispose ref-after-unmount fix; route history layer cleanup - Legacy device view model: position update event handling - AndroidManifest: notification channel + permissions for incoming-call full-screen intent
5.3 KiB
Videocall — Migración a CallKit / Notification.CallStyle nativo
Contexto
Hoy (2026-05-08) las videollamadas entrantes con la app killed se manejan via
flutter_local_notifications con fullScreenIntent: true y
AndroidNotificationCategory.call. El usuario ve dos botones en la
notificación (Aceptar / Rechazar) construidos como
AndroidNotificationAction. Ver apps/mobile_app/lib/core/notifications_init.dart.
Problema conocido
Doble tap para aceptar.
Cuando el usuario toca "Aceptar" en la notificación, el flujo es:
notif "Aceptar" → app abre → IncomingView (otro Aceptar) → llamada
tap 1 tap 2
WhatsApp / Telegram / Zoom entran directo a la llamada activa con un solo tap. Eso es lo esperado por el usuario.
La causa estructural: flutter_local_notifications solo nos pasa el
actionId al handler. No hay integración con la API de telefonía del sistema
operativo. Para tener tap único nativo necesitamos:
- iOS: CallKit + PushKit + VoIP cert (cert es el bloqueador hoy).
- Android:
Notification.CallStyle(API 31+) +Telecom/ConnectionService.
Opciones evaluadas
| Solución | iOS | Android | Costo | Doble-tap |
|---|---|---|---|---|
flutter_local_notifications (actual) |
banner básico | full-screen intent OK | hecho | sí |
flutter_local_notifications + flag autoAnswer |
banner + auto answer | full-screen + auto answer | ~20 LOC | no |
flutter_callkit_incoming plugin |
CallKit nativo | CallStyle nativo |
~3–4 días + VoIP cert | no |
| Bare metal (Swift+CallKit / Kotlin+ConnectionService) | máximo control | máximo control | varios sprints | no |
Bloqueador actual
VoIP cert de Apple no obtenido. Sin él, no hay PushKit / CallKit en iOS,
y por lo tanto la migración a flutter_callkit_incoming no entrega valor en
iOS. Movernos solo en Android dejaría una asimetría en la UX entre plataformas.
Account Holder Apple Developer: Jorge Alvarez (Team KQ73NP6L4Q). El cert se
gestiona desde su cuenta.
Plan recomendado
Fase 1 — mientras no haya VoIP cert (hoy)
No implementar ahora. Conscientemente decidimos vivir con el doble-tap
hasta tener el cert. Si la presión de UX aumenta antes de obtener el cert,
implementar el flag autoAnswer como mitigación temporal:
- Añadir campo
autoAnswer: boolaVideocallIncomingArgs. - En
notifications_init._onLocalNotificationTapped, distinguiractionId == actionAccept(autoAnswer=true) vs body tap (autoAnswer=false). - En
videocall_controller._consumePendingIncoming, siautoAnswer, llamaranswerCall()automáticamente después depresentIncomingChannelCall(...).
Caveat: durante el SDK login (~1–3s) el usuario verá brevemente la
IncomingView con el ringtone antes de entrar a la llamada activa. Para una
v1 está OK. Reemplazable por un loading "Conectando…".
Fase 2 — cuando llegue VoIP cert
Migrar a flutter_callkit_incoming.
iOS:
- Generar VoIP cert en Apple Developer (Account Holder).
- Subir cert al backend (FCM no envía VoIP pushes; necesitamos APNs VoIP directo o un proxy en el backend).
- Configurar PushKit en
AppDelegate.swift. - Implementar
CXProviderDelegatecallbacks que disparanjoinChannel(Juphoon) cuando CallKit reporta accept.
Android:
- Permiso
MANAGE_OWN_CALLSenAndroidManifest.xml. - Foreground service para mantener la llamada en background.
flutter_callkit_incomingya wrappeaNotification.CallStyleinternamente — solo configurar.
Flutter:
- Reemplazar
_showIncomingCallNotification(data)ennotifications_init.dartporFlutterCallkitIncoming.showCallkitIncoming(params). - Suscribirse a
FlutterCallkitIncoming.onEventpara manejaractionCallAccept/actionCallDecline/actionCallEnded. - En
actionCallAcceptcallback, leer roomNumber/sessionId del payload y llamar alvideocallController.presentIncomingChannelCall(...)+answerCall()directo. - Eliminar el flag
autoAnswersi se hubiera implementado en Fase 1.
Backend:
- La push payload de VIDEO_CALL_FROM puede mantenerse igual (los campos
roomNumber,appAccount,sessionId,chatTypesiguen siendo los que usa el handler). - Para iOS: el backend necesita enviar push VoIP via APNs (canal aparte de FCM). Esto es trabajo coordinado con backend.
Estimación
| Item | Esfuerzo |
|---|---|
| Obtener VoIP cert (gestión Apple) | semanas (no nuestro) |
| iOS CallKit + PushKit nativo | 1–2 días |
Android CallStyle (via plugin) |
1 día |
| Backend APNs VoIP push | depende backend |
| Flutter wiring + cleanup | 1 día |
| QA cross-platform | 1–2 días |
| Total dev | ~4–6 días una vez desbloqueado el cert |
Referencias
- flutter_callkit_incoming pub.dev
- Apple — Reporting Incoming Calls with CallKit
- Android Notification.CallStyle
- TODO en código:
apps/mobile_app/lib/core/notifications_init.dart— buscarTODO(videocall-callkit-migration). - Limitación iOS ya documentada en
apps/mobile_app/lib/core/notifications_init.dartconTODO(videocall-ios-callkit).