É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 :
Étapes détaillées
- INVITE → producteur de webhook (infra). Le DID entrant déclenche
incoming_call_notify.luaqui 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 parbgsystemalors quemod_systemest absent → push jamais émis. Corrigé (passage àsystem+ sous-shell) —FLAG-0620RESOLVED. - 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: blocdata+android.priority=high, aucun blocnotification). C'est le choix REQUIS pour réveiller l'app force-tuée et router vers le handler de background. Service Account Firebasespotifone-f76ea, OAuth2, FCM HTTP 200 prouvé live. - FCM → isolate background.
FirebaseMessaging.onBackgroundMessage(main.dart:177-180) enregistrefirebaseMessagingBackgroundHandler(main.dart:30-53,@pragma('vm:entry-point')). Ce handler s'exécute même app tuée et appelleIncomingCallService.showIncomingCallFromPayload(main.dart:51). - Affichage CallKit plein écran.
IncomingCallService.showIncomingCallFromPayload(incoming_call_service.dart:39-138) appelleFlutterCallkitIncoming.showCallkitIncomingavecisShowFullLockedScreen:true, sonnerie système et vibration. Au démarrage à froid (cold-start), le push initial est rejoué viagetInitialMessage(push_notification_service.dart:327). - 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, etanswerCallné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
| Cas | Statut | Dé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.
onTokenRefreshexige 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.uuiden clé primaire, une seule colonnePushToken; le dernier login gagne. Invalidation par liveness (purge surUNREGISTERED) 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, champis_call_waitingcô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-0620RESOLVED, 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
(
FcmPushServiceAndroid /ApnsVoipPushServiceiOS) — split per-OS standard.ApnsVoipPushServiceinerte tant queservices.apnsincomplet. Doc à mettre à jour. - Mineur PushToken par UUID, dédup côté app fail-open.
IncomingPushDedupdéduplique parcall_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é →
endAllCallsdéfensif. En corrélation dégradée,endAllCallsferme 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 parwait_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
firebaseReadyest un fail-safe correct ; en cas d'échec, juste undebugPrint, 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-53 → main.dart:177-180 → incoming_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
notificationdu 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.