Système de notification Android de Spotifone

Documentation technique — cadrage vérifié et honnête

Mise à jour : 2026-06-25 · Vérité terrain prouvée en live confrontée au code source

État vérifié Le cœur fonctionne

Le cœur du système de notification — l'appel entrant, y compris APP TUÉE — FONCTIONNE et est prouvé E2E en live le 2026-06-09 (vrai device Redmi Note 9 Pro sous MIUI, app 2.0.62 : vrai appel entrant, app force-tuée → le téléphone sonne en plein écran d'appel, sans aucune permission MIUI requise pour sonner).

Sources clés : spotifone-flutter/lib/main.dart:30-53 (isolate background FCM → CallKit) et FLAG-0620 RESOLVED (correctif infra du producteur de webhook).

Comment ça marche — le vrai chemin du réveil app tuée

Le point essentiel à ne pas confondre

Le service foreground SIP n'est PAS le mécanisme du réveil quand l'app est tuée. Le réveil passe par un chemin totalement indépendant : un push FCM data-only qui réveille un isolate Flutter en arrière-plan (firebaseMessagingBackgroundHandler), lequel affiche directement la CallKit plein écran. Que le service foreground soit un TaskHandler no-op n'a aucun impact sur la sonnerie app tuée.

La chaîne complète, de l'INVITE SIP jusqu'à l'écran d'appel :

flowchart TD A["Appelant → INVITE SIP\nFreeSWITCH (DID entrant)"] --> B["incoming_call_notify.lua\nPOST /api/fusionpbx/incoming-call\n(envoi via system, corrigé)"] B --> C["Backend — webhook entrant\nIncomingCallWebhookController"] C --> D["FcmPushService\nService Account Firebase f76ea (OAuth2)\npayload FCM data-only"] D --> E["FCM — priorité high\ndata-only (aucun bloc notification)"] E --> F["App force-tuée :\nisolate background Flutter\nfirebaseMessagingBackgroundHandler\n@pragma vm:entry-point"] F --> G["IncomingCallService\n.showIncomingCallFromPayload"] G --> H["CallKit plein écran\nshowCallkitIncoming\n(sonnerie + vibration)"] H --> I["Utilisateur accepte"] I --> J["Cold-start / SIP REGISTER\n(re)enregistrement extension"] J --> K["INVITE rejoué → answerCall\nnégociation SDP → bridge RTP\n(audio bidirectionnel)"] style A fill:#1f4e79,color:#fff style E fill:#2f6fb0,color:#fff style H fill:#1f7a3d,color:#fff style K fill:#1f7a3d,color:#fff

Étapes détaillées

  1. INVITE → producteur de webhook (infra). Le DID entrant déclenche incoming_call_notify.lua qui POST le webhook vers le backend. Ce hook est injecté dans les routes DID (DestinationRepository.php:166 / :702). Le bug historique « app tuée ne sonne pas » était une seule ligne d'infra : l'envoi passait par bgsystem alors que mod_system est absent → push jamais émis. Corrigé (passage à system + sous-shell) — FLAG-0620 RESOLVED.
  2. Backend → push FCM data-only. Le webhook dispatch un job qui appelle FcmPushService. Le payload est délibérément data-only (FcmPushService.php:38-56 : bloc data + android.priority=high, aucun bloc notification). C'est le choix REQUIS pour réveiller l'app force-tuée et router vers le handler de background. Service Account Firebase spotifone-f76ea, OAuth2, FCM HTTP 200 prouvé live.
  3. FCM → isolate background. FirebaseMessaging.onBackgroundMessage (main.dart:177-180) enregistre firebaseMessagingBackgroundHandler (main.dart:30-53, @pragma('vm:entry-point')). Ce handler s'exécute même app tuée et appelle IncomingCallService.showIncomingCallFromPayload (main.dart:51).
  4. Affichage CallKit plein écran. IncomingCallService.showIncomingCallFromPayload (incoming_call_service.dart:39-138) appelle FlutterCallkitIncoming.showCallkitIncoming avec isShowFullLockedScreen:true, sonnerie système et vibration. Au démarrage à froid (cold-start), le push initial est rejoué via getInitialMessage (push_notification_service.dart:327).
  5. Acceptation → SIP + RTP. Le push ne porte jamais l'audio (architecture normale d'un softphone). À l'acceptation, la pile SIP se (ré)enregistre (sip_initializer.dart:44-62), FreeSWITCH route l'INVITE, et answerCall négocie le SDP/RTP (sip_controller.dart:46-100).
Pourquoi le service foreground SIP n'intervient pas ici

SipForegroundService utilise un _NoOpTaskHandler (sip_foreground_service.dart:19-22, :57-73) avec le SIP qui tourne dans le main isolate. Ce service ne sert qu'à maintenir le process SIP vivant quand l'app est ouverte / en arrière-plan (démarré sip_initializer.dart:55, stoppé quand l'app perd le focus app_lifecycle_service.dart:159). Il meurt avec l'app force-tuée et n'est pas censé survivre ni auto-redémarrer — ce qui est un choix de design correct, puisque le réveil app tuée emprunte le chemin FCM ci-dessus, totalement indépendant.

Tableau des cas de notification

Statut vérifié par cas d'usage. Vert = fonctionne, Orange = partiel/mineur, Gris = non câblé (feature non construite).
CasStatutDétail
Appel entrant — app au premier plan OK CallKit affichée via onMessage / data (push_notification_service.dart:141).
Appel entrant — app en arrière-plan OK Isolate background FCM → CallKit (main.dart:30-53).
Appel entrant — app FORCE-TUÉE OK Le téléphone sonne — prouvé E2E live 2026-06-09. FCM data-only → handler background → CallKit. Aucune perm MIUI requise pour sonner.
Plein écran sur écran VERROUILLÉ PARTIEL Câblé (full-screen-intent CallkitNotificationManager.kt:192, setShowWhenLocked CallkitIncomingActivity.kt:91-92, perm demandée push_notification_service.dart:94-106). Dépend de la permission « afficher pop-up en arrière-plan » / full-screen-intent — comportement Android 14+/OEM normal, pas un défaut.
Son & vibration de la sonnerie OK Sonnerie système + vibration câblées (CallkitSoundPlayerManager.kt:36-101), canal IMPORTANCE_HIGH/CATEGORY_CALL. Qualité audible non re-mesurée séparément (résidu mineur).
Annulation d'appel (appelant raccroche) — Android OK call_canceled émis via FCM, ferme la CallKit orpheline (SendCallCanceledPushJob).
Annulation d'appel — iOS VoIP NON CÂBLÉ L'annulation n'est émise que côté FCM/Android (CallCanceledWebhookController.php:57-61, « APNs VoIP cancel = TODO #154 »). iOS pas encore sur main.
2ᵉ appel simultané (call waiting) hors foreground PARTIEL App au 1ᵉʳ plan : bandeau + bip/vibration OK. Hors foreground : la présentation native multi-appel n'est pas construite (FLAG-0630, statique seulement). Pas un « appel manqué garanti » prouvé.
Notification d'appel manqué NON CÂBLÉ Notif missed-call désactivée (incoming_call_service.dart:128). Manqués visibles dans l'Historique (onglet « Manqués », history_provider.dart:71).
Notification de messagerie vocale NON CÂBLÉ Dépôt OK (prouvé E2E 2026-06-03) mais aucune notif/MWI émise (post_bridge_dispatcher.lua:204-242).
SMS entrant (inbound) NON CÂBLÉ Aucune réception SMS construite. Le seul SMS est sortant et transactionnel (SmsService.php:26-63).
Push KYC / restriction / suspension NON CÂBLÉ Aucune notif hors app sauf échec de paiement (email, FailedPaymentObserver.php:103). Statut visible in-app via le bandeau d'état.
Notifications spot Secrétariat NON CÂBLÉ DB-only, consultées par polling (ClientNotificationService.php:12-36). Pas de push.

Ce qui n'est pas (encore) construit

Ces points sont des fonctionnalités de notification secondaires non encore construites, et non des éléments cassés. Rien n'a été câblé puis brisé : ces chemins n'ont simplement jamais été implémentés. Aucun n'affecte la sonnerie de l'appel entrant app tuée, prouvée live.

Mineur Notification de messagerie vocale

Aucune notification (push / MWI / locale) n'est émise quand un message vocal est déposé. Le dépôt fonctionne (prouvé E2E live 2026-06-03) mais le dispatcher n'effectue aucun curl/webhook/notify après le dépôt, et le backend n'a aucune route/job/push voicemail. L'utilisateur doit aller chercher le message lui-même.

Sources : post_bridge_dispatcher.lua:204-242 · routes/api/did.php:27-30 · notification_sink.dart:28-33

Mineur Notification de SMS entrant

La réception de SMS entrant n'a jamais été construite (produit VoIP). Le seul code SMS est sortant et transactionnel (code de vérification via Octopush). Aucune route / webhook / callback de réception SMS côté backend, aucun inbox côté app.

Sources : SmsService.php:26-63 · routes/api/connection.php:14-19 · config/services.php:97-101

Mineur Notification proactive KYC / restriction / suspension hors app

Le seul événement métier notifié hors app est l'échec de paiement (email). La modération KYC, les actions de restriction/suspension et l'ajout de carte n'émettent aucune notification hors app. Le statut KYC/restriction reste exposé in-app via le bandeau (display_state_key, EntitlementEngine).

Sources : FailedPaymentObserver.php:103 · DocumentModerationController.php:173 · RestrictionActionController.php:141 · UserCardObserver.php:15

Mineur Notification d'appel manqué (tray dédiée)

La notif missed-call native est explicitement désactivée (incoming_call_service.dart:128) et aucun type de push missed_call n'existe. Les appels manqués restent visibles dans l'Historique avec compteur (onglet « Manqués », history_provider.dart:71). Manque uniquement la notification tray dédiée.

Sources : incoming_call_service.dart:128 · push_notification_service.dart:152-178 · history_provider.dart:71

Mineur Notifications spot Secrétariat (push)

Les notifications du spot Secrétariat (nouveau prospect, appel terminé) sont écrites en base et consultées par polling ; aucune n'est poussée. Aucune perte de donnée (consultation possible à l'ouverture de l'app), mais pas de temps réel / arrière-plan.

Sources : ClientNotificationService.php:12-36 · Notification.php:24-51 · secretariat/client.php:39-42

Mineur Annulation d'appel non câblée pour iOS VoIP

Le push call_canceled (ferme la CallKit orpheline) n'est émis que côté FCM/Android. Le contrôleur s'arrête sur l'absence de PushToken FCM sans consulter le token APNs VoIP (« APNs VoIP cancel = TODO #154 »). iOS n'est pas encore sur main. Impact limité : une CallKit pourrait rester affichée si l'appelant raccroche avant décroché — uniquement sur iOS.

Sources : CallCanceledWebhookController.php:57-61 · SendCallCanceledPushJob.php:17-19

Mineur Push KYC/paiement déjà couvert par email (rappel)

Pour mémoire : le cas paiement est couvert par email ; KYC/restriction sont visibles in-app. Ces gaps sont des manques produit/UX, pas des dysfonctionnements.

Points de durcissement (mineurs)

Améliorations de robustesse / hygiène, sans impact prouvé sur la sonnerie. Aucun n'est un bug runtime.

  • Mineur Exemption batterie / Doze non demandée. L'app ne demande pas REQUEST_IGNORE_BATTERY_OPTIMIZATIONS. Sans impact sur le ring app tuée (chemin FCM indépendant de la batterie). Feature de robustesse designée mais non construite (ONBOARDING-PERMISSIONS-DESIGN.md:112).
  • Mineur Token FCM non re-poussé si rotation app tuée. onTokenRefresh exige un isolate vivant ; auto-corrigé au prochain démarrage via _fetchAndStoreToken / reregisterToken. Fenêtre de risque étroite (push_notification_service.dart:127-138).
  • Mineur Un seul PushToken par UUID (pas multi-appareils). Connection.uuid en clé primaire, une seule colonne PushToken ; le dernier login gagne. Invalidation par liveness (purge sur UNREGISTERED) existe (FcmPushService.php:161-177). Multi-device jamais construit.
  • Mineur Channels de notif créés paresseusement. Créés au moment de construire la 1ʳᵉ notif d'appel, juste avant notify() — pattern Android standard. Le 1ᵉʳ appel sonne (prouvé live). Effet borné à l'absence des channels dans les Réglages tant qu'aucun appel reçu (CallkitNotificationManager.kt:172).
  • Mineur 2ᵉ appel concurrent (call waiting) hors foreground. Coordination native multi-appel non construite (FLAG-0630, statique seulement) : promotion d'un 2ᵉ INVITE en foreground, présentation de deux CallKit simultanées, champ is_call_waiting côté backend. Pas une régression prouvée.
  • Mineur result.log résiduel côté FreeSWITCH. Fichier-log figé (perte d'observabilité), plus aucun lua ne l'écrit (le lua final poste vers /dev/null). Pas une casse fonctionnelle (FLAG-0620 RESOLVED, push OK live).
  • Mineur FLAG-0612 doc périmé (chemins push). Le legacy a été supprimé ; restent exactement deux services séparés par plateforme (FcmPushService Android / ApnsVoipPushService iOS) — split per-OS standard. ApnsVoipPushService inerte tant que services.apns incomplet. Doc à mettre à jour.
  • Mineur PushToken par UUID, dédup côté app fail-open. IncomingPushDedup déduplique par call_uuid (TTL 45 s, inter-isolate, fail-open volontaire « pour ne JAMAIS empêcher un vrai appel de sonner »). TTL fixe = coquetterie de configurabilité, pas un bug (incoming_push_dedup.dart:18-30).
  • Mineur Mode dégradé → endAllCalls défensif. En corrélation dégradée, endAllCalls ferme toutes les CallKit ; comme l'app ne peut structurellement afficher qu'une seule CallKit aujourd'hui, le préjudice est théorique (incoming_call_machine.dart:250-253). Ne deviendrait réel que si le multi-CallKit était implémenté.
  • Mineur Refonte onboarding-permissions #94 non mergée dans main. Routage OEM/MIUI, priming, FSI version-aware : présents uniquement sous le tag d'archive archive/onboarding-perms-20260624. Pas un prérequis pour que ça sonne (preuve live + ProjectFlow #94 = done) — un plus pour le plein écran sur écran verrouillé.
  • Mineur ring_wait_bridge.lua = script orphelin. Code mort superseded par wait_for_register.lua (lui câblé). Impact runtime nul, candidat à suppression (ring_wait_bridge.lua:1-47).
  • Mineur Pas d'observabilité sur échec d'init Firebase. Le garde firebaseReady est un fail-safe correct ; en cas d'échec, juste un debugPrint, pas de retry ni retour utilisateur (main.dart:346-359). Branche non atteinte en prod (Firebase provisionné, ring prouvé live).

Corrections par rapport à l'analyse initiale

Une première version de la page était trop pessimiste et laissait croire que le système de notification était cassé. C'est faux. Voici, honnêtement, ce qui était dit à tort → la réalité vérifiée.

Affirmations RÉFUTÉES (étaient fausses)

À tort : « Le service foreground est inutile pour l'appel entrant en background — le pont SIP→UI serait mort, l'appel entrant app tuée ne remonterait pas » (verdict BLOQUANT initial).

Réalité : Le verdict confond deux chemins. L'appel entrant app tuée ne passe PAS par le foreground service : il passe par l'isolate background FCM (main.dart:30-53main.dart:177-180incoming_call_service.dart:39-138). Le pont SIP→UI pour ce chemin est câblé et prouvé fonctionnel live (2026-06-09).

À tort : « USE_FULL_SCREEN_INTENT sous targetSdk 34+ = permission restreinte non vérifiée » (MAJEUR).

Réalité : La permission EST vérifiée et demandée. App en targetSdk 36 (build.gradle:30), perm déclarée (AndroidManifest.xml:16) et demandée via requestFullIntentPermission() + canUseFullScreenIntent() (push_notification_service.dart:94-106), API native gardée (CallkitNotificationManager.kt:1039-1055).

À tort : « Faux succès du pipeline /pushnotification legacy — success:true codé en dur même en cas d'échec » (MAJEUR, FLAG-0150).

Réalité : Ce pipeline n'existe plus (supprimé le 2026-06-04, commit 1b795f0). Le chemin actif utilise FcmPushService, qui dérive correctement success du code HTTP (FcmPushService.php:140-160). Flag périmé.

À tort : « Mismatch d'environnement des credentials FCM » (MAJEUR).

Réalité : Aucun mismatch. Les quatre sources d'identité Firebase concordent sur spotifone-f76ea (infra.env:166, google-services.json:4, GoogleService-Info.plist:19, SA déchiffré). Un seul fichier de config partagé. FCM HTTP 200 prouvé live.

Documentation PÉRIMÉE (le code a évolué)

À tort : « L'émetteur du webhook d'appel entrant n'est pas câblé côté infra » (FLAG-0001 section H, MAJEUR).

Réalité : Le producteur incoming_call_notify.lua EST câblé (injecté dans les routes DID, DestinationRepository.php:166 / :702). L'affirmation « aucun composant ne l'appelle » repose sur un grep daté du 2026-05-27, désormais périmé. Le seul vrai blocage (bgsystem) a été corrigé — FLAG-0620 RESOLVED, preuve E2E live le 2026-06-09. FLAG-0001 reste OPEN et n'a pas été mis à jour depuis le fix : sa partie « pont SIP→CallKit absent côté app » est largement périmée.

À tort : « Trois chemins push coexistent sans owner tranché » (MINEUR, FLAG-0612).

Réalité : Le legacy (config pushnotification.php, relais celloip, route /api/pushnotification) a disparu. Restent exactement deux services séparés par plateforme — split per-OS standard, conforme à la décision demandée. Doc à mettre à jour.

Hors-sujet (constat exact mais ce n'est pas un défaut)

  • Foreground service no-op : choix de design assumé, sans rôle dans le réveil app tuée (sip_foreground_service.dart:19-73).
  • « Redémarrage du service SIP non câblé » : le service SIP n'est pas le mécanisme du réveil ; il meurt volontairement avec l'app (sip_foreground_service.dart:57-73).
  • Plein écran lockscreen « PARTIEL » : la dépendance à la perm full-screen-intent MIUI est le comportement Android/OEM normal, pas un bug Spotifone (CallkitIncomingActivity.kt:91-92).
  • « Le push ne porte pas l'audio » : architecture normale d'un softphone SIP-sur-push ; l'audio s'établit via REGISTER + INVITE (sip_controller.dart:46-100).
  • « Aucun bloc notification FCM = pas de fallback » : le data-only est REQUIS pour réveiller l'app tuée ; un bloc notification ne pourrait pas afficher la CallKit (FcmPushService.php:38).
  • Pas de meta-data default_notification_channel_id : sans objet en data-only ; la CallKit déclare ses propres canaux (incoming_call_service.dart:111-122).
  • Dédup / TTL 45 s fixe : fail-open volontaire, biais vers la sonnerie ; aucun impact prouvé sur le ring (incoming_push_dedup.dart:18-30).
  • Webhook entrant sans dédup serveur : la dédup existe au point de convergence (l'app, incoming_push_dedup.dart) ; choix d'architecture.
  • Placement notification du moteur unifié no-op : stub pluggable volontaire, sans rapport avec l'appel entrant (notification_sink.dart:24-42).
  • Son/vibration « INCONNU » : en fait câblés (CallkitSoundPlayerManager.kt), ring prouvé live ; seul résidu = qualité audible non re-mesurée à la marge.