# Plan : Intégration LiveKit — BY+ Access (Gate + User App)

## Contexte

Le système BY+ Access est composé de deux apps React Native Expo :

- **access-gate** — l'interphone/portier (hardware door device)
- **access-user-app** — l'app mobile de l'habitant

Les deux utilisent actuellement WebRTC manuel : signalisation custom sur Socket.IO (Laravel Echo), échange SDP offer/answer, ICE candidates, tout géré à la main. Le backend Laravel orchestre la signalisation entre les deux parties.

**Objectif :** Remplacer toute la couche WebRTC par LiveKit SDK sur les deux apps. LiveKit gère signalisation, ICE, TURN, codec, reconnexion. Les échanges Socket.IO se limitent aux événements métier (sonnerie, ouverture de porte).

---

## Flux actuel (pour référence)

```
[Gate]  POST /api/v1/ring → Backend crée session, notifie mobile
[Gate]  écoute CONNEXION_EXTABLISH
[Mobile] reçoit RING → accepte → émet CONNEXION_EXTABLISH
[Gate]  reçoit CONNEXION_EXTABLISH → createOffer → émet START_CALL
[Mobile] reçoit START_CALL → createAnswer → émet ACCEPT_CALL
[Both]  échange ICE_CANDIDATE (bidirectionnel)
[Both]  WebRTC connecté
```

## Flux cible avec LiveKit

```
[Gate]  POST /api/v1/ring → Backend crée room LiveKit
        → génère token gate
        → génère un token nominatif par résident de la porte (liste connue)
        → émet RING sur Door-{door_uid} (canal porte existant) :
          { peerId, doorName, room, livekit_url, tokens: { userId1: "eyJ...", userId2: "eyJ..." } }
        ← Réponse au gate : { livekit_token: gate_token, livekit_url, room }

[Gate]  room.connect(livekit_url, gate_token) → publie caméra + micro, attend

[Mobile] reçoit RING sur Door-{door_uid} (canal privé, accès restreint aux résidents)
         → pioche son token : tokens[currentUserId]
         → affiche UI sonnerie

[Mobile] User appuie "Répondre" → room.connect(livekit_url, tokens[currentUserId])
         ← aucun appel API supplémentaire, aucun point de défaillance

[LiveKit] gère tout le reste (ICE, SDP, codec, reconnexion)

[Tokens non utilisés] expirent silencieusement après TTL (habitants qui n'ont pas répondu)
```

> **Pourquoi le canal porte avec token map ?**
> Le RING est déjà diffusé sur `Door-{door_uid}`, un private channel Laravel Echo — seuls les résidents autorisés y sont abonnés. Embarquer les tokens dans un objet `{ userId: token }` permet à chaque mobile de piocher le sien via son `currentUserId` (disponible dans Redux). Aucun second canal à ouvrir, aucun appel API au moment de la réponse.

**Events Socket.IO supprimés des deux côtés :**
`CONNEXION_EXTABLISH`, `START_CALL`, `ACCEPT_CALL`, `ICE_CANDIDATE`

**Events Socket.IO conservés :**
`RING`, `CALL_IGNORE`, `OPEN_DOOR`, `LEFT_CALL`, `CALL_DENIED`, `DOOR_ALWREADY_OPEN`, `OPEN_DOOR_MANUALLY`

---

## Étape 0 — Création du compte LiveKit Cloud

1. Aller sur **https://cloud.livekit.io** → Sign up
2. Créer deux **Projects** :
   - `byplus-access-staging`
   - `byplus-access-prod`
3. Récupérer dans Dashboard → Settings de chaque project :
   - `LIVEKIT_URL` (ex: `wss://byplus-access-prod.livekit.cloud`)
   - `LIVEKIT_API_KEY`
   - `LIVEKIT_API_SECRET`

> LiveKit Cloud inclut TURN géré → résout les problèmes NAT symmetric actuels (Google STUN only).

---

## Étape 1 — Backend Laravel

> ⚠️ Modifications dans le repo Laravel backend, pas dans ces deux apps.

### 1.1 Installer le SDK LiveKit PHP

```bash
composer require agence104/livekit-server-sdk
```

### 1.2 Variables d'environnement backend

```env
LIVEKIT_URL=wss://byplus-access-prod.livekit.cloud
LIVEKIT_API_KEY=APxxxxx
LIVEKIT_API_SECRET=xxxxxxxxxx
```

### 1.3 Modifier le handler de sonnerie — `POST /api/v1/ring`

**Qui crée la room ?** Le backend, au moment où le gate appelle `POST /api/v1/ring`.

Le contrôleur doit :

1. Générer `room_name = "door-{uuid}"`
2. Créer la room LiveKit via `RoomServiceClient->createRoom()`
3. Générer le **token gate**
4. Récupérer la liste des résidents associés à cette porte
5. Générer un **token nominatif par résident** (identité = userId)
6. Émettre RING sur le **canal privé de chaque résident** avec son token individuel
7. Retourner le token gate dans la réponse HTTP

```php
// RingController@store
public function store(Request $request)
{
    $roomName = 'door-' . Str::uuid();
    $apiKey   = config('livekit.api_key');
    $secret   = config('livekit.api_secret');
    $url      = config('livekit.url');

    // Créer la room (timeout 5 min si vide)
    $roomSvc = new RoomServiceClient($url, $apiKey, $secret);
    $roomSvc->createRoom((new CreateRoomRequest())
        ->setName($roomName)
        ->setEmptyTimeout(300));

    // Token gate (publisher caméra/micro + subscriber)
    $gateToken = (new AccessToken($apiKey, $secret))
        ->setIdentity('gate-' . $request->header('device'))
        ->addGrant((new VideoGrant())
            ->setRoomJoin(true)->setRoom($roomName)
            ->setCanPublish(true)->setCanSubscribe(true))
        ->setTtl(600)->toJwt();

    // Récupérer les résidents de cette porte et générer un token par user
    $tokens = [];
    foreach ($door->users as $user) {
        $tokens[$user->id] = (new AccessToken($apiKey, $secret))
            ->setIdentity((string) $user->id)
            ->setName($user->name)
            ->addGrant((new VideoGrant())
                ->setRoomJoin(true)->setRoom($roomName)
                ->setCanPublish(true)->setCanSubscribe(true))
            ->setTtl(300)->toJwt();
    }

    // Émettre RING sur le canal porte existant — token map indexé par userId
    broadcast(new DoorRingEvent('Door-' . $door->uid, [
        'peerId'      => $request->input('data.data.peerId'),
        'doorName'    => $door->name,
        'room'        => $roomName,
        'livekit_url' => $url,
        'tokens'      => $tokens,  // { "123": "eyJ...", "456": "eyJ..." }
    ]));

    // Retourner le token au gate dans la réponse HTTP
    return response()->json([
        'livekit_token' => $gateToken,
        'livekit_url'   => $url,
        'room'          => $roomName,
    ]);
}
```

> **Le canal `Door-{door_uid}` est un private channel** — seuls les résidents autorisés y sont abonnés. Le mobile pioche son token via `tokens[currentUserId]`. Les tokens des habitants qui ne répondent pas expirent silencieusement à TTL.

### 1.4 Supprimer les handlers de signalisation backend

Supprimer les Socket.IO handlers pour :
`CONNEXION_EXTABLISH`, `START_CALL`, `ACCEPT_CALL`, `ICE_CANDIDATE`

---

## Étape 2 — access-gate : installation

### 2.1 Installer le SDK LiveKit

```bash
npx expo install @livekit/react-native @livekit/react-native-webrtc
```

> `@livekit/react-native-webrtc` remplace `react-native-webrtc`.

### 2.2 Supprimer les dépendances obsolètes

```bash
npm uninstall react-native-webrtc
```

### 2.3 Mettre à jour `app.json` / Expo plugin

```json
// Remplacer dans plugins[] :
"@config-plugins/react-native-webrtc"
// Par :
"@livekit/react-native-webrtc"
```

---

## Étape 3 — access-gate : réécrire `hooks/useBellHook.js`

**Fichier :** `hooks/useBellHook.js`

### Avant

```js
const ring = async () => {
  post("ring", data).then((resp) => {
    emit(EventType.RING, { peerId });
  });
};
```

### Après

```js
import { Room } from "@livekit/react-native";

const ring = async () => {
  const resp = await post("ring", data);
  const { livekit_token, livekit_url, room: roomName } = resp.data;

  // Rejoindre la room LiveKit directement
  await livekitRoom.connect(livekit_url, livekit_token);
  await livekitRoom.localParticipant.setCameraEnabled(true);
  await livekitRoom.localParticipant.setMicrophoneEnabled(true);

  // router.push('/belling') reste inchangé
};
```

`livekitRoom` est une instance `new Room()` partagée via le `CallContext` (voir étape suivante).

---

## Étape 4 — access-gate : réécrire `provider/call_provider.tsx`

**Fichier :** `provider/call_provider.tsx`

### Supprimer entièrement

- `peerConns` map et tout cycle de vie RTCPeerConnection
- `cleanupPeer()`, `closeCall()` (logique WebRTC manuelle)
- `localStream` / `remoteStreams` (gérés par LiveKit SDK)
- `localStreamRef`

### Remplacer par

```tsx
import { Room, RoomEvent } from "@livekit/react-native";

const room = useMemo(() => new Room(), []);

const leaveRoom = useCallback(() => {
  room.disconnect();
}, [room]);

// Exposer dans le context
const contextValue = { room, leaveRoom };
```

### Conserver

- `cleanupPeer` simplifié : appelle `room.disconnect()`
- La structure du Context pour que les hooks consumers compilent

---

## Étape 5 — access-gate : réécrire `hooks/useStreamHook.js`

**Fichier :** `hooks/useStreamHook.js`

### Supprimer entièrement

- `getOrCreatePeer()` (RTCPeerConnection manuelle)
- `processCall()` (createOffer / setLocalDescription / emit START_CALL)
- `preferCodec()` (manipulation SDP VP9)
- Listeners : `CONNEXION_EXTABLISH`, `START_CALL`, `ACCEPT_CALL`, `ICE_CANDIDATE`
- `getUserMedia` manuel

### Conserver / adapter

- `startCallListener()` → écoute `LEFT_CALL`, `CALL_IGNORE`, `DOOR_ALWREADY_OPEN`
- `cleanupPeer()` → appelle `room.disconnect()`

---

## Étape 6 — access-gate : réécrire `hooks/useBellAuthHook.js`

**Fichier :** `hooks/useBellAuthHook.js`

### Supprimer

- `on(EventType.CONNEXION_EXTABLISH, handleConnexion)`
- Tout ce qui démarre la signalisation WebRTC

### Conserver

- `on(EventType.OPEN_DOOR, ...)` → ouvre le relais physique + émet `DOOR_ALWREADY_OPEN`
- `on(EventType.CALL_IGNORE, ...)` → `room.disconnect()`, navigation vers home
- `on(EventType.LEFT_CALL, ...)` → `room.disconnect()`

---

## Étape 7 — access-gate : mettre à jour `app/belling.js`

**Fichier :** `app/belling.js`

### Avant

- `<StreamCard/>` affichait `RTCView` avec `localStream.toURL()`

### Après

```jsx
import { useLocalParticipant, VideoView } from "@livekit/react-native";

const { localParticipant } = useLocalParticipant();
const cameraTrack = localParticipant?.getTrackPublication(Track.Source.Camera);

// Render :
{
  cameraTrack?.videoTrack && (
    <VideoView style={styles.localVideo} videoTrack={cameraTrack.videoTrack} />
  );
}
```

### Bouton "Close"

```js
const handleClose = () => {
  leaveRoom(); // LiveKit disconnect
  emit(EventType.DISCONNECT, {});
  router.replace("/home");
};
```

---

## Étape 8 — access-gate : `provider/socket_provider.tsx`

**Fichier :** `provider/socket_provider.tsx`

Supprimer les listeners pour : `CONNEXION_EXTABLISH`, `ICE_CANDIDATE`
Garder : `OPEN_DOOR_MANUALLY`, tous les événements métier existants.

---

## Étape 9 — access-user-app : installation

### 9.1 Installer le SDK LiveKit

```bash
npx expo install @livekit/react-native @livekit/react-native-webrtc
```

### 9.2 Supprimer les dépendances obsolètes

```bash
npm uninstall react-native-webrtc peerjs lib-jitsi-meet react-native-peer
```

### 9.3 Mettre à jour `app.config.js`

```js
// Remplacer dans plugins[] :
"@config-plugins/react-native-webrtc";
// Par :
"@livekit/react-native-webrtc";
```

---

## Étape 10 — access-user-app : enrichir `CallData` dans `call_provider.tsx`

**Fichier :** `provider/call_provider.tsx`

Le payload RING sur le canal privé contient maintenant `room`, `livekit_url` et `livekit_token` — nominatif, prêt à l'emploi. Aucun appel API supplémentaire nécessaire.

### 10.1 Enrichir le type `CallData`

```ts
type CallData = {
  peerId: string;
  doorName: string;
  room: string; // nouveau — room_name LiveKit
  livekitUrl: string; // nouveau — wss://...livekit.cloud
  livekitToken: string; // nouveau — token extrait de la map au moment du RING
};
```

Quand le provider reçoit l'event RING, extraire le token immédiatement :

```ts
// Dans le listener RING du call_provider.tsx
const currentUserId = store.getState().auth.user?.id;
const livekitToken = data.tokens?.[currentUserId];

setCallData({
  peerId: data.peerId,
  doorName: data.doorName,
  room: data.room,
  livekitUrl: data.livekit_url,
  livekitToken, // prêt, nominatif, aucun appel API requis
});
```

### 10.2 Supprimer entièrement de `call_provider.tsx`

- `RTCPeerConnection` et tout son cycle de vie
- `initialize()`, `establisgConnexion()`, `processAccept()`, `onIceCandidate()`
- `configuration` (STUN servers)
- Refs : `peerConnection`, `connExRetryTimerRef`, `closeCallAnswerTimerRef`
- Socket events : `START_CALL`, `ACCEPT_CALL`, `ICE_CANDIDATE`, `CONNEXION_EXTABLISH`

### 10.3 Remplacer par

```tsx
import { Room } from "@livekit/react-native";

const room = useMemo(() => new Room(), []);

const joinRoom = async () => {
  // Token déjà disponible — reçu dans le RING, nominatif, aucun appel API
  await room.connect(callData.livekitUrl, callData.livekitToken);
  await room.localParticipant.setCameraEnabled(true);
  await room.localParticipant.setMicrophoneEnabled(true);
};

const leaveRoom = () => room.disconnect();
```

### Conserver

- `toggleOnRing()` / `isRinging` — déclenchés par RING socket (inchangé)
- `openDoor()` / `ignoreCall()` — émettent toujours sur Socket.IO
- FCM / CallKeep logic — inchangé

---

## Étape 11 — access-user-app : réécrire `app/call.js`

**Fichier :** `app/call.js`

### Supprimer

- `RTCView` et `remoteStream.toURL()`

### Remplacer par

```jsx
import { useParticipants, VideoView } from "@livekit/react-native";
import { Track } from "livekit-client";

const participants = useParticipants();
const remote = participants.find((p) => !p.isLocal);
const cameraTrack = remote?.getTrackPublication(Track.Source.Camera);

{
  cameraTrack?.videoTrack && (
    <VideoView style={styles.remoteVideo} videoTrack={cameraTrack.videoTrack} />
  );
}
```

### Rejoindre la room au montage

```js
useEffect(() => {
  joinRoom();
  return () => leaveRoom();
}, []);
```

### Contrôles mute/vidéo

```js
// Mute
room.localParticipant.setMicrophoneEnabled(!isMuted);
// Vidéo
room.localParticipant.setCameraEnabled(!isVideoOff);
```

---

## Étape 12 — access-user-app : cold-start FCM (Android)

**Fichier :** `index.js`

Le payload FCM ne peut pas embarquer la `tokens` map complète (les données FCM sont limitées). Le backend doit envoyer directement le token de l'utilisateur ciblé par la notification push — il sait à quel user il envoie la notif.

```js
// Background handler (index.js)
const { room, livekit_url, livekit_token, ...rest } = remoteMessage.data;
// livekit_token : token individuel, envoyé par le backend pour cet user spécifiquement
await AsyncStorage.setItem(
  "@pendingCall",
  JSON.stringify({
    ...rest,
    room,
    livekitUrl: livekit_url,
    livekitToken: livekit_token,
  }),
);
```

`call_provider.tsx` lit `@pendingCall` au démarrage → reconstruit `callData` avec `livekitToken` déjà prêt → `joinRoom()` appelle `room.connect()` directement, sans appel API.

---

## Étape 13 — access-user-app : `provider/socket_provider.tsx`

**Fichier :** `provider/socket_provider.tsx`

Supprimer les listeners/emitters pour : `CONNEXION_EXTABLISH`, `ICE_CANDIDATE`
Garder : tous les événements métier existants.

---

## Fichiers critiques à modifier

### access-gate

| Fichier                        | Action                                                           |
| ------------------------------ | ---------------------------------------------------------------- |
| `hooks/useBellHook.js`         | Utiliser réponse HTTP de `/ring` pour rejoindre la room LiveKit  |
| `provider/call_provider.tsx`   | Supprimer WebRTC manuel, exposer `Room` LiveKit via Context      |
| `hooks/useStreamHook.js`       | Supprimer tout (SDP, ICE, getUserMedia) — remplacer par LiveKit  |
| `hooks/useBellAuthHook.js`     | Supprimer CONNEXION_EXTABLISH listener, garder événements métier |
| `app/belling.js`               | RTCView → VideoView LiveKit                                      |
| `provider/socket_provider.tsx` | Supprimer events signalisation                                   |
| `app.json`                     | Changer plugin webrtc                                            |
| `package.json`                 | Swap react-native-webrtc → @livekit/react-native-webrtc          |

### access-user-app

| Fichier                        | Action                                                                      |
| ------------------------------ | --------------------------------------------------------------------------- |
| `provider/call_provider.tsx`   | Supprimer WebRTC manuel ; `joinRoom()` utilise le token reçu dans le RING   |
| `app/call.js`                  | RTCView → VideoView LiveKit                                                 |
| `provider/socket_provider.tsx` | Supprimer events signalisation                                              |
| `index.js`                     | Ajouter `room`, `livekit_url`, `livekit_token` dans AsyncStorage cold-start |
| `app.config.js`                | Changer plugin webrtc                                                       |
| `package.json`                 | Swap + supprimer peerjs, lib-jitsi-meet, react-native-peer                  |

---

## Vérification end-to-end

1. **Backend :** `POST /api/v1/ring` retourne `{ livekit_token, livekit_url, room }` — tester avec Postman
2. **Dashboard LiveKit Cloud → Rooms :** La room `door-{uuid}` apparaît lors de la sonnerie
3. **Gate :** Flux vidéo local visible dans `belling.js` après `ring()`
4. **Mobile :** Flux vidéo du gate visible dans `call.js` après acceptation
5. **Audio bidirectionnel :** Gate entend le mobile, mobile entend le gate
6. **Raccrochage :** La room disparaît du dashboard après hang-up ou timeout 5 min
7. **Cold-start Android :** Tuer l'app → recevoir FCM → répondre → vidéo OK
8. **Réseau 4G :** TURN géré par LiveKit Cloud — tester en coupant le WiFi
