---
issueRef: "#109"
version: 5
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1785134675243
---

## #109 — Cadrage technique notification de séance Android (Architect, 2026-07-26)

Statut : **cadrage exploitable, prêt pour DevFrontend**, pas de blocage. Basé sur le cadrage UX #108 et l'existant réel (`WatchCompanionForegroundService.kt`, `WatchSessionProjectionProjector`, cf. [[gametime-watch-companion-implementation]]).

### Constat sur l'existant
Un `ForegroundService` tourne déjà pendant toute séance active (`android/app/src/main/kotlin/com/gametime/app/watch/WatchCompanionForegroundService.kt`), démarré/arrêté par `WatchWearDataLayerAdapter._syncForegroundService` sur simple base de `phase != noActiveSession` — **indépendamment du pairing montre**. Mais ce service est aujourd'hui **verrouillé sur la feature montre** : channel `gametime_watch_companion`, notification statique créée une seule fois dans `onCreate()` (titre/texte fixes « GameTime » / « Séance en cours »), `NOTIFICATION_ID = 91`, aucune méthode de mise à jour de contenu, `foregroundServiceType="connectedDevice|dataSync"`.

**Décision** : ne pas réutiliser ce service tel quel pour #102. On crée un **second foreground service indépendant**, propre à la notification de séance téléphone, avec son propre canal et son propre cycle de vie. Raison (ISP appliqué aux adapters Android) : coupler la notification "résumé de séance" au service montre ferait dépendre une feature du couplage montre/pairing implicite, alors que la notification doit exister avec ou sans montre appairée, et une évolution du companion montre ne doit jamais risquer de casser la notification (et réciproquement). Android autorise plusieurs foreground services concurrents sans conflit.

### Contrat côté domaine/application

Pas de nouvel état domaine : la notification est un **présentateur supplémentaire** d'un état déjà calculé. Réutiliser tel quel le flux existant `WatchProjectionSource.projections` (déjà émis à chaque changement pertinent : série, pause, repos, tick de chrono score) plutôt que dupliquer la logique de sélection du "chrono dominant" (priorité chrono > reps/score > étape, déjà implémentée dans `WatchSessionProjectionProjector` pour produire `dominantTimer`).

- Nouveau port application :
```dart
abstract interface class SessionNotificationGateway {
  Future<void> show(SessionNotificationContent content);
  Future<void> clear();
}
```
- Nouveau DTO pur (application layer) :
```dart
class SessionNotificationContent {
  final String title;       // nom exercice / "Repos" / exercice (pause)
  final String primaryLine; // mm:ss, "Série 2/4 · 8 reps", décompte repos, "En pause · ..."
  final String? secondaryLine; // "Programme 1/2 · Exercice 3/8", null si compact only
}
```
- Nouvelle fonction pure (testable sans plateforme) : `SessionNotificationContent buildSessionNotificationContent(WatchSessionProjection projection)` — mapping direct des états du tableau UX #108 (série+chrono, série reps/score, repos, pause, terminée→`clear()`). Réutilise `dominantTimer`/`phase`/indices déjà présents dans `WatchSessionProjection`, pas de nouveau champ requis sur ce DTO existant.
- Nouveau coordinateur application `SessionNotificationCoordinator` : s'abonne à `WatchProjectionSource.projections`, appelle `buildSessionNotificationContent` puis `gateway.show(...)`, appelle `gateway.clear()` sur `phase == noActiveSession`. **Ne dépend d'aucun état montre/pairing** — seul le flux de projection (déjà session-only) est consommé.

### Règle de rafraîchissement (traduction concrète de l'UX)
- `show()` appelé à chaque émission de projection représentant un changement structurel (bump de révision) : changement de série/étape/phase/pause.
- En plus, si `dominantTimer.runState == running` (chrono actif ou repos), un tick local **1s** recalcule `primaryLine` depuis `referenceEpochMs`/`accumulatedMs`/`targetMs` déjà présents sur `WatchTimerProjection` — tick **strictement local au coordinateur de notification**, ne déclenche jamais de bump de révision ni de republication vers la montre (éviter tout couplage/chatter croisé entre les deux features).
- Sinon (pas de chrono affiché), aucune mise à jour périodique — conforme à l'exigence UX "pas de rafraîchissement inutile".
- Invariant robustesse : un échec de `show()`/`clear()` ne doit jamais remonter d'exception dans le flux domaine/session (fire-and-forget côté notification) — une panne de notification ne doit jamais interrompre une séance en cours.

### Contrat côté adapter Android (nouveau)
- Nouveau service Kotlin, ex. `android/app/src/main/kotlin/com/gametime/app/session/SessionStatusForegroundService.kt`, **distinct** de `WatchCompanionForegroundService`.
- Nouveau canal `gametime_session_status`, `IMPORTANCE_LOW` (pas de son, cohérent avec les mises à jour fréquentes de chrono), `NOTIFICATION_ID` distinct (ex. `92`, à ne pas réutiliser `91`).
- Contrairement au service montre, celui-ci **doit exposer une méthode de mise à jour de contenu** (pas de notification statique créée une fois) — `updateNotification(title, primaryLine, secondaryLine?)` appelée à chaque `show()`.
- `foregroundServiceType` : ni `dataSync` ni `connectedDevice` ne conviennent sémantiquement (ce n'est ni de la synchro de données ni un device connecté). Sur SDK 36, utiliser `specialUse` avec la propriété manifeste `android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE` (valeur libre du type `"workout-session-status"`). L'app n'étant pas distribuée sur le Play Store (APK signé debug, cf. [[gametime-android-release-networking-and-apk-build]]), la contrainte de justification `specialUse` en review Play ne s'applique pas ; seule la déclaration manifeste est requise par l'OS.
- Permission `POST_NOTIFICATIONS` déjà déclarée dans le manifest (réutilisée pour le canal montre) — pas de nouvelle permission requise. Si l'utilisateur refuse la permission notification, le foreground service démarre quand même (contrat Android standard : `startForeground` exige une `Notification`, affichée ou non selon permission) — aucune gestion spécifique à ajouter, ne jamais bloquer le démarrage de séance sur ce refus.

### Tests
- `buildSessionNotificationContent` : 100% unit-testable en Dart pur, mêmes conventions que `test/application/watch_companion_projection_test.dart` — couvrir les 5 états du tableau UX #108 (série+chrono, série reps/score, repos, pause, fin→clear).
- `SessionNotificationCoordinator` : test avec `StreamController` fake, vérifier `show()` sur changement structurel et sur tick actif uniquement, `clear()` sur fin de séance, absence d'appel superflu si aucun chrono actif.
- Service Kotlin : non testable en sandbox (cohérent avec la contrainte déjà connue pour `WatchCompanionForegroundService`, cf. [[gametime-watch-companion-implementation]]) — validation manuelle on-device requise (affichage réel, tick, disparition en fin de séance, comportement écran verrouillé).

### Hors périmètre (aligné UX #108)
Pas d'action bouton dans la notification (pas de PendingIntent Pause/Suivant) — tap ouvre l'app sur l'écran d'exécution en cours (Intent standard `PendingIntent.getActivity` vers l'activité principale, aucun deep-link spécifique nécessaire pour le MVP).

Explicitement **hors scope** : iOS. Le contrat `SessionNotificationGateway` est un port application générique (compatible iOS en théorie), mais aucun adapter iOS n'est cadré ni demandé ici — seul l'adapter Android (`MethodChannelSessionNotificationGateway` + plugin Kotlin) est dans le périmètre de #102/#110. Si iOS est demandé un jour, c'est un nouveau ticket d'adapter, pas une révision de ce contrat.

---

## Audit Architect — implémentation constatée dans le worktree (2026-07-27)

Statut mis à jour : **implémenté conformément au cadrage ci-dessus**, un garde-fou à fermer avant merge/QA.

Vérification du code réel (working tree non commité, branche `feature/ticket91-wear-os-watch-sync`) : `SessionNotificationCoordinator`/`buildSessionNotificationContent` (`lib/application/session_notification_use_cases.dart`), `MethodChannelSessionNotificationGateway` (`lib/infrastructure/session_notification/`), `SessionStatusForegroundService.kt`/`SessionNotificationPlugin.kt` (`android/app/src/main/kotlin/com/gametime/app/session/`) sont tous présents et conformes point par point au contrat cadré : consommation de `WatchCompanionProjectionUseCases.projections` (pas d'état dupliqué), tick 1s uniquement si timer actif, `clear()` sur `phase == noActiveSession || deviceSessionId.isEmpty`, appels gateway `unawaited(...).catchError((_) {})` (fire-and-forget confirmé), canal `gametime_session_status`/`NOTIFICATION_ID 92` distinct du service montre, `foregroundServiceType="specialUse"` avec `PROPERTY_SPECIAL_USE_FGS_SUBTYPE` comme cadré. Wiring dans `app_bootstrap.dart` (start juste après `watchWearDataLayerAdapter.start()`, dispose en premier).

**Garde-fou à fermer avant de considérer le lot terminé** : le manifest déclare bien `POST_NOTIFICATIONS`, mais **aucun code (Dart ou Kotlin) ne demande cette permission à l'exécution** nulle part dans l'app — ni pour ce nouveau canal, ni pour le canal montre pré-existant. Sur Android 13+ (API 33+, cohérent avec la cible SDK 36), sans cette demande explicite le service démarre normalement (exception foreground service) mais la notification peut ne jamais s'afficher à l'utilisateur, silencieusement. Contrat à ajouter pour DevFrontend : demander `POST_NOTIFICATIONS` une seule fois, paresseusement, au premier démarrage de séance (jamais un écran dédié, jamais bloquant — cohérent avec [[gametime-online-layer-philosophy]] appliqué par analogie aux permissions système) ; en cas de refus, ne rien tenter de plus, ne jamais redemander en boucle. C'est un gap transverse aux deux notifications (montre + séance), pas spécifique à l'une des deux — à traiter une seule fois pour les deux canaux.

Tests couvrant `buildSessionNotificationContent` déjà présents (`test/application/session_notification_use_cases_test.dart`) ; **`SessionNotificationCoordinator` lui-même (start/stop/tick/gateway failure) n'est pas encore testé** — à ajouter avant merge, cf. stratégie de test déjà cadrée ci-dessus.

---

## Vérification Architect de clôture (2026-07-27, second passage)

Statut final : **conforme, garde-fou permission fermé, un seul gap résiduel connu (non bloquant pour le cadrage, bloquant pour le merge).**

- Le garde-fou `POST_NOTIFICATIONS` signalé ci-dessus est **résolu** : `SessionNotificationPlugin.kt` (`android/app/src/main/kotlin/com/gametime/app/session/SessionNotificationPlugin.kt`) appelle `requestPostNotificationsPermissionIfNeeded()` dans `show()`, guardé par un flag `notificationPermissionRequested` (une seule demande, jamais de boucle), `SDK_INT >= TIRAMISU` uniquement, pas d'écran dédié. La permission `POST_NOTIFICATIONS` est globale à l'app (pas par canal) donc cette unique demande couvre aussi le canal montre pré-existant — le gap transverse est clos par un seul point d'appel, conforme à la recommandation.
- Gap résiduel inchangé : **`SessionNotificationCoordinator` n'a toujours pas de test dédié** (`test/application/session_notification_use_cases_test.dart` ne couvre que `buildSessionNotificationContent`, 5 cas). À ajouter avant merge (StreamController fake : `show()` sur changement structurel + sur tick actif, `clear()` en fin de séance, non-appel si pas de chrono actif) — cf. stratégie déjà cadrée plus haut. Ceci est un standard de qualité (couverture), **pas** une ambiguïté de contrat : n'importe qui connaissant `SessionNotificationCoordinator` peut écrire ce test sans redemander de cadrage.

**Réponse à la question "DevFrontend peut-il implémenter sans autre clarification ?" : oui.** Le contrat est complet et déjà appliqué à l'identique dans le code existant. Il ne reste aucune décision d'architecture ouverte pour #102/#110 — seulement une tâche de complétion de test (coordinateur) avant que le lot passe en QA (#111).
