import { create } from 'zustand'; import { AppState, NativeModules, Platform } from 'react-native'; import type { RealtimeChannel } from '@supabase/supabase-js'; import { supabase } from '../lib/supabase'; import { apiFetch } from '../lib/api'; import { startRingback, stopRingback } from '../lib/ringback'; import * as callkit from '../lib/callkit'; // ─── Voice-Call-Engine (1:1, Audio-only, foreground-only / Phase 1) ────────── // // Signaling läuft über Supabase Realtime Broadcast — KEIN eigener Signaling- // Server nötig: // • Ring-Channel `call-ring:` → nur die initiale Einladung/Abbruch. // • Call-Channel `call:` → ready/offer/answer/ice/decline/hangup. // // Handshake-Reihenfolge (verhindert "offer kommt bevor Callee subscribed"-Race): // Caller: ring → join call-channel → WARTET auf `ready` → erst dann offer. // Callee: accept → join call-channel → `ready` → wartet auf offer → answer. // // react-native-webrtc ist ein natives Modul → LAZY require + Guard. In einem // Build ohne das Modul (z.B. der aktuelle Dev-Build) ist isWebRTCAvailable() // false und der Call-Button meldet "Rebuild nötig" statt zu crashen. export function isWebRTCAvailable(): boolean { return !!(NativeModules as any).WebRTCModule; } export type CallStatus = | 'idle' | 'outgoing' // wir rufen an, warten auf Annahme | 'incoming' // es klingelt bei uns | 'connecting' // angenommen, ICE/DTLS baut auf | 'connected' // Audio läuft | 'ended'; export type CallPeer = { id: string; nickname: string; avatar: string | null }; export type CallEndReason = 'declined' | 'ended' | 'failed' | 'unanswered' | 'busy' | null; const UNANSWERED_MS = 35_000; // Callee-seitiges Auto-Cancel: Wenn ein eingehender Anruf in dieser Zeit // weder angenommen noch durch Caller-cancel beendet wird (z.B. weil die App // im Background war, der Caller schon aufgelegt hat und das ring-cancel- // broadcast wegen fehlender Realtime-Subscription nicht ankam), räumen wir // den stale 'incoming'-State auf — sonst sieht der User beim nächsten App- // Open einen "Geist-Anruf". const INCOMING_TIMEOUT_MS = 45_000; // Nicht-reaktive Handles (gehören nicht in den Zustand-State). let pc: any = null; let localStream: any = null; let callChan: RealtimeChannel | null = null; let pendingRemoteIce: any[] = []; let unansweredTimer: ReturnType | null = null; let incomingTimer: ReturnType | null = null; // Self-Heal-Fallback: setzt den Store nach einem beendeten Call zurück auf 'idle', // FALLS der /call-Screen das nicht tut (iOS-Banner-only Incoming der ohne Mount // des Screens endet). Ohne das bleibt status='ended' hängen und blockt jeden // Folge-Call (RING-Guard `!== idle`). let endedResetTimer: ReturnType | null = null; let selfMe: CallPeer | null = null; // für Caller-Side DM-Logging let currentRole: 'caller' | 'callee' | null = null; let loggedCallId: string | null = null; // Idempotenz-Guard für DM-Log type CallState = { status: CallStatus; peer: CallPeer | null; callId: string | null; muted: boolean; speaker: boolean; startedAt: number | null; endReason: CallEndReason; startCall: (peer: CallPeer, me: CallPeer) => Promise; receiveIncoming: (callId: string, from: CallPeer) => void; acceptCall: () => Promise; declineCall: () => void; hangup: (reason?: CallEndReason) => void; toggleMute: () => void; toggleSpeaker: () => void; _clear: () => void; }; function rtc() { // eslint-disable-next-line @typescript-eslint/no-var-requires return require('react-native-webrtc'); } // Audio-Routing (Earpiece vs. Lautsprecher) über AVAudioSession (iOS) / // AudioManager (Android). react-native-webrtc exposed das nicht direkt. function inCall() { // eslint-disable-next-line @typescript-eslint/no-var-requires return require('react-native-incall-manager').default; } // Diagnose-Logging (taucht in Metro-Konsole + adb logcat ReactNativeJS auf). function clog(...args: any[]) { console.log('[CALL]', ...args); } // Fire-and-forget: einem noch klingelnden Callee (noch nicht im Call-Channel) // den Abbruch auf seinem Ring-Channel signalisieren. function fireRingCancel(peerId: string, callId: string) { const chan = supabase.channel(`call-ring:${peerId}`); chan.subscribe((s: string) => { if (s === 'SUBSCRIBED') { chan.send({ type: 'broadcast', event: 'cancel', payload: { callId } }); setTimeout(() => supabase.removeChannel(chan), 300); } }); } function teardown() { if (unansweredTimer) { clearTimeout(unansweredTimer); unansweredTimer = null; } if (incomingTimer) { clearTimeout(incomingTimer); incomingTimer = null; } try { pc?.close?.(); } catch {} try { localStream?.getTracks?.().forEach((t: any) => t.stop()); } catch {} if (callChan) { supabase.removeChannel(callChan); callChan = null; } // Audio-Session beenden → normales Audio-Routing wiederherstellen. // stopRingtone/stopRingback sind no-ops wenn nichts läuft — safe. try { inCall().stopRingtone(); } catch {} void stopRingback(); try { inCall().stop(); } catch {} pc = null; localStream = null; pendingRemoteIce = []; // Self-Heal: Wenn der Call auf 'ended' steht (hangup/decline/unanswered) und // der /call-Screen ihn nicht innerhalb seiner 1300ms-Logik auf 'idle' zieht // (weil er bei iOS-Incoming-Banner nie gemountet wurde), würde der Store ewig // 'ended' bleiben → ALLE Folge-Calls blockiert. Entkoppelter Fallback-Reset. if (endedResetTimer) { clearTimeout(endedResetTimer); endedResetTimer = null; } endedResetTimer = setTimeout(() => { endedResetTimer = null; const s = useCallStore.getState(); if (s.status === 'ended') { useCallStore.setState({ status: 'idle', peer: null, callId: null, muted: false, speaker: false, startedAt: null, endReason: null, }); } }, 2500); } // DM-Log nach Call-Ende. Nur der CALLER schreibt (verhindert Duplikate). // Format: attachmentType='call', attachmentName='::'. async function logCallToChat( peerId: string, callId: string, state: 'ended' | 'unanswered' | 'declined' | 'failed' | 'busy', startedAt: number | null, ) { // Caller loggt jeden Call. Callee loggt NUR missed/declined/unanswered // wenn der Caller dazu nicht kommt (z.B. Callee drückt 'decline' bevor der // Caller-DM-Call durchgeht). Verbundene Calls werden vom Caller geloggt. if (currentRole === 'callee' && state === 'ended') return; if (loggedCallId === callId) return; loggedCallId = callId; const durSec = startedAt ? Math.max(0, Math.round((Date.now() - startedAt) / 1000)) : 0; const meta = `audio:${state}:${durSec}`; try { await apiFetch('/api/chat/dm', { method: 'POST', body: { receiverId: peerId, content: '', attachmentType: 'call', attachmentName: meta, }, }); clog('logged call DM →', peerId, meta); } catch (e: any) { clog('logCallToChat FAILED', e?.message ?? e); } } export const useCallStore = create((set, get) => { // ─── WebRTC-Setup gemeinsam für beide Seiten ────────────────────────────── async function buildPeer() { const { RTCPeerConnection } = rtc(); clog('buildPeer: fetching ice-servers…'); let ice: { iceServers: any[]; iceTransportPolicy?: string }; try { ice = await apiFetch('/api/calls/ice-servers'); } catch (e: any) { clog('buildPeer: ICE-FETCH FAILED', e?.message ?? e, e?.statusCode ?? ''); throw e; } clog('buildPeer: ice ok', JSON.stringify({ servers: ice.iceServers?.length, policy: ice.iceTransportPolicy, urls: ice.iceServers?.[0]?.urls, })); pc = new RTCPeerConnection({ iceServers: ice.iceServers, iceTransportPolicy: (ice.iceTransportPolicy as any) ?? 'relay', }); pc.addEventListener('icecandidate', (e: any) => { if (e.candidate && callChan) { callChan.send({ type: 'broadcast', event: 'ice', payload: { candidate: e.candidate.candidate, sdpMid: e.candidate.sdpMid, sdpMLineIndex: e.candidate.sdpMLineIndex, }, }); } }); pc.addEventListener('icecandidateerror', (e: any) => { clog('ICE-CANDIDATE-ERROR', e?.errorCode, e?.errorText, e?.url); }); pc.addEventListener('iceconnectionstatechange', () => { clog('iceConnectionState =', pc?.iceConnectionState); }); pc.addEventListener('icegatheringstatechange', () => { clog('iceGatheringState =', pc?.iceGatheringState); }); pc.addEventListener('connectionstatechange', () => { const st = pc?.connectionState; clog('connectionState =', st); if (st === 'connected') { if (get().status !== 'connected') { set({ status: 'connected', startedAt: Date.now() }); } // Ringback aus — Verbindung steht. void stopRingback(); // CallKit/ConnectionService: Call als aktiv markieren (Android needs this // für Mute/Speaker-Controls). try { const cid = get().callId; if (cid) callkit.reportConnected(cid); } catch {} // WebRTC hat seine eigene AVAudioSession aktiviert und unser früherer // InCallManager-Call ist evtl. überschrieben worden — Speaker-Route // jetzt erneut setzen, damit der User-Toggle wirklich greift. try { inCall().setForceSpeakerphoneOn(get().speaker); clog('post-connect: speaker route applied =', get().speaker); } catch (e: any) { clog('post-connect setForceSpeakerphoneOn failed:', e?.message ?? e); } } else if (st === 'failed') { get().hangup('failed'); } }); // Mikrofon holen + Track anhängen. clog('buildPeer: getUserMedia(audio)…'); const { mediaDevices } = rtc(); try { localStream = await mediaDevices.getUserMedia({ audio: true, video: false }); } catch (e: any) { clog('buildPeer: getUserMedia FAILED', e?.message ?? e); throw e; } localStream.getTracks().forEach((t: any) => pc.addTrack(t, localStream)); // Audio-Session in den "In-Call"-Mode bringen (richtet Routing, Bluetooth, // Audio-Focus etc. ein). Default = Earpiece, NICHT Speaker. try { inCall().start({ media: 'audio', auto: false }); inCall().setForceSpeakerphoneOn(get().speaker); } catch (e: any) { clog('InCallManager start failed:', e?.message ?? e); } clog('buildPeer: done (track added)'); } function addRemoteIce(payload: any) { const { RTCIceCandidate } = rtc(); const cand = new RTCIceCandidate(payload); if (pc?.remoteDescription) { pc.addIceCandidate(cand).catch(() => {}); } else { pendingRemoteIce.push(payload); } } function drainIce() { const { RTCIceCandidate } = rtc(); pendingRemoteIce.forEach((p) => pc?.addIceCandidate(new RTCIceCandidate(p)).catch(() => {})); pendingRemoteIce = []; } // Per-Call-Channel beitreten + Events verdrahten. role steuert offer/answer. function joinCallChannel(callId: string, role: 'caller' | 'callee') { const chan = supabase.channel(`call:${callId}`, { config: { broadcast: { self: false } }, }); chan.on('broadcast', { event: 'ready' }, async () => { clog('recv ready (role=' + role + ', pc=' + !!pc + ')'); if (role !== 'caller' || !pc) return; const offer = await pc.createOffer({}); await pc.setLocalDescription(offer); clog('caller: sending offer'); chan.send({ type: 'broadcast', event: 'offer', payload: { type: offer.type, sdp: offer.sdp } }); }); chan.on('broadcast', { event: 'offer' }, async (msg: any) => { clog('recv offer (role=' + role + ', pc=' + !!pc + ')'); if (role !== 'callee' || !pc) return; const { RTCSessionDescription } = rtc(); await pc.setRemoteDescription(new RTCSessionDescription(msg.payload)); drainIce(); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); clog('callee: sending answer'); chan.send({ type: 'broadcast', event: 'answer', payload: { type: answer.type, sdp: answer.sdp } }); }); chan.on('broadcast', { event: 'answer' }, async (msg: any) => { clog('recv answer (role=' + role + ')'); if (role !== 'caller' || !pc) return; const { RTCSessionDescription } = rtc(); await pc.setRemoteDescription(new RTCSessionDescription(msg.payload)); drainIce(); }); chan.on('broadcast', { event: 'ice' }, (msg: any) => addRemoteIce(msg.payload)); chan.on('broadcast', { event: 'decline' }, () => { clog('recv decline'); get().hangup('declined'); }); chan.on('broadcast', { event: 'hangup' }, () => { clog('recv hangup'); get().hangup('ended'); }); callChan = chan; return chan; } return { status: 'idle', peer: null, callId: null, muted: false, speaker: false, startedAt: null, endReason: null, // ─── Caller ────────────────────────────────────────────────────────── startCall: async (peer, me) => { if (get().status !== 'idle') return; if (!isWebRTCAvailable()) throw new Error('webrtc_unavailable'); const callId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; clog('startCall → callee', peer.id, 'callId', callId); selfMe = me; currentRole = 'caller'; loggedCallId = null; set({ status: 'outgoing', peer, callId, muted: false, speaker: false, startedAt: null, endReason: null }); // 1) Callee anklingeln (ephemerer Ring-Channel des Callees). const ring = supabase.channel(`call-ring:${peer.id}`); await new Promise((res) => ring.subscribe((s: string) => s === 'SUBSCRIBED' && res())); clog('ring channel subscribed → sending ring'); ring.send({ type: 'broadcast', event: 'ring', payload: { callId, from: { id: me.id, nickname: me.nickname, avatar: me.avatar } }, }); setTimeout(() => supabase.removeChannel(ring), 300); // Background-Push an den Callee (deckt den Fall ab, dass die App nicht // im Foreground ist — Realtime ist dann nicht subscribed). Fire-and-forget: // ein fehlgeschlagener Push darf den Call nicht blocken. apiFetch('/api/calls/ring', { method: 'POST', body: { peerId: peer.id, callId }, }).catch((e: any) => clog('ring-push failed', e?.message ?? e)); // CallKit: User-Seite in Recents/UI als ausgehender Call sichtbar machen. try { callkit.startOutgoingCall(callId, peer.nickname || 'ReBreak'); } catch {} // Ringback-Ton für den Anrufer — eigenes mp3-Asset (EU-Standard 425 Hz, // 1s an / 4s aus). InCallManager.start() ohne `ringback`-Param läuft // parallel für die AVAudioSession-Aktivierung, der eigentliche Ton kommt // von expo-av. try { inCall().start({ media: 'audio', auto: false }); } catch (e: any) { clog('inCall start (caller) failed', e?.message ?? e); } void startRingback(); // 2) Call-Channel join (Handler setzen) → PeerConnection bauen → ERST DANN // subscriben. Sonst könnte ein schnelles `ready` den offer-Handler treffen, // bevor pc existiert → kein offer → Deadlock. const chan = joinCallChannel(callId, 'caller'); try { await buildPeer(); } catch (e: any) { clog('startCall: buildPeer FAILED →', e?.message ?? e); get().hangup('failed'); throw e; } chan.subscribe((s: string) => clog('caller call-channel:', s)); // 3) Timeout: keine Annahme → auflegen (hangup signalisiert Ring-Cancel). unansweredTimer = setTimeout(() => { if (get().status === 'outgoing') get().hangup('unanswered'); }, UNANSWERED_MS); }, // ─── Callee: Klingeln empfangen ─────────────────────────────────────── receiveIncoming: (callId, from) => { // Schon im Gespräch / oder bereits am Klingeln mit derselben callId? // → ignorieren (dedup: Realtime + VoIP-Push können beide feuern). const cur = get(); if (cur.status === 'ended') { // Stale 'ended' von einem vorherigen Call (z.B. iOS-Banner-Incoming der // ohne /call-Screen endete) → als 'idle' behandeln, sonst blockt er den // neuen Call. Self-Heal-Timer abbrechen, wir übernehmen jetzt. clog('receiveIncoming: clearing stale ended-state for new call', callId); if (endedResetTimer) { clearTimeout(endedResetTimer); endedResetTimer = null; } } else if (cur.status !== 'idle') { if (cur.status === 'incoming' && cur.callId === callId) { clog('receiveIncoming dedup (already incoming for', callId, ')'); } return; } clog('receiveIncoming from', from.id, 'callId', callId); currentRole = 'callee'; loggedCallId = null; set({ status: 'incoming', peer: from, callId, muted: false, speaker: false, startedAt: null, endReason: null }); // CallKit-/ConnectionService-UI hochziehen — ABER nur wenn App NICHT im // Vordergrund. Im Foreground kümmert sich der In-App /call-Screen darum, // sonst gibt es Doppel-UI (System-Banner + Fullscreen). // // iOS-Hinweis: Bei VoIP-Push hat AppDelegate.swift bereits // `RNCallKeep.reportNewIncomingCall` aufgerufen — wir dürfen das hier // NICHT erneut tun (gleiche UUID → CallKit zeigt Banner doppelt / // verhält sich kaputt). Auf iOS gibt es keinen sinnvollen JS-Pfad // für Background-Ring, da Supabase-Realtime im Background nicht läuft; // jeder bg-Ring kommt zwingend via PushKit → AppDelegate. if (Platform.OS !== 'ios' && AppState.currentState !== 'active') { try { callkit.displayIncomingCall(callId, from.nickname || 'ReBreak'); } catch {} } else { clog('receiveIncoming: skipping JS-side CallKit show (foreground OR iOS-AppDelegate already handled)'); } // Auto-Cancel-Timer: Wenn nach 45s noch immer 'incoming' (kein Accept, // kein Caller-Cancel angekommen), als 'unanswered' aufräumen damit nicht // beim nächsten App-Open ein toter Call-Screen sichtbar wird. if (incomingTimer) clearTimeout(incomingTimer); incomingTimer = setTimeout(() => { const st = get(); if (st.status === 'incoming' && st.callId === callId) { clog('receiveIncoming: stale incoming timeout → hangup(unanswered)'); st.hangup('unanswered'); } }, INCOMING_TIMEOUT_MS); // CallKit (iOS) + ConnectionService (Android) spielen ihren eigenen // Ringtone — KEIN InCallManager.startRingtone() hier, sonst doppeltes // Klingeln. InCallManager.start() bleibt aus demselben Grund weg; der // CallKit-Pfad aktiviert die AVAudioSession selbst. }, acceptCall: async () => { const { callId, status } = get(); if (status !== 'incoming' || !callId) return; if (!isWebRTCAvailable()) throw new Error('webrtc_unavailable'); clog('acceptCall callId', callId); // Klingelton stoppen — wir nehmen ab. try { inCall().stopRingtone(); } catch {} set({ status: 'connecting' }); const chan = joinCallChannel(callId, 'callee'); await new Promise((res) => chan.subscribe((s: string) => s === 'SUBSCRIBED' && res())); clog('callee call-channel subscribed'); try { await buildPeer(); } catch (e: any) { clog('acceptCall: buildPeer FAILED →', e?.message ?? e); get().hangup('failed'); throw e; } clog('callee: sending ready'); chan.send({ type: 'broadcast', event: 'ready', payload: {} }); }, declineCall: () => { const { callId, peer, startedAt, status } = get(); clog('declineCall called — status=', status, 'callId=', callId); if (callId) { // CallKit/ConnectionService aus dem Lockscreen-UI entfernen. try { callkit.endCall(callId); } catch {} // Kurz joinen um decline zu senden (Callee war noch nicht im Channel). const chan = supabase.channel(`call:${callId}`); chan.subscribe((s: string) => { if (s === 'SUBSCRIBED') { chan.send({ type: 'broadcast', event: 'decline', payload: {} }); setTimeout(() => supabase.removeChannel(chan), 300); } }); } // Callee logged abgelehnten Call in eigenen Chat-Verlauf. if (peer && callId) { logCallToChat(peer.id, callId, 'declined', startedAt); } set({ status: 'ended', endReason: 'declined' }); teardown(); }, hangup: (reason = 'ended') => { const { status, peer, callId, startedAt } = get(); clog('hangup called — reason=', reason, 'status=', status, 'callId=', callId); if (status === 'idle' || status === 'ended') { teardown(); return; } // CallKit/ConnectionService schließen. if (callId) { try { callkit.endCall(callId); callkit.reportEnded(callId, reason as any); } catch {} } // Peer im Call-Channel informieren (falls schon verbunden). callChan?.send({ type: 'broadcast', event: 'hangup', payload: {} }); // Klingelt der Callee noch (noch nicht im Call-Channel)? → Ring abbrechen. if (peer && callId && (status === 'outgoing' || status === 'connecting')) { fireRingCancel(peer.id, callId); } // Call als DM ins Chat-Log schreiben (nur Caller, fire-and-forget). if (peer && callId && reason) { const state = (reason === null ? 'ended' : reason) as 'ended' | 'unanswered' | 'declined' | 'failed' | 'busy'; logCallToChat(peer.id, callId, state, startedAt); } set({ status: 'ended', endReason: reason }); teardown(); }, toggleMute: () => { const next = !get().muted; try { localStream?.getAudioTracks?.().forEach((t: any) => { t.enabled = !next; }); } catch {} set({ muted: next }); }, toggleSpeaker: () => { const next = !get().speaker; try { // Sicherheitshalber start() erneut aufrufen — falls die AVAudioSession // von WebRTC zwischendurch deaktiviert wurde, sonst greift // setForceSpeakerphoneOn auf iOS nicht. inCall().start({ media: 'audio', auto: false }); inCall().setForceSpeakerphoneOn(next); clog('toggleSpeaker →', next); } catch (e: any) { clog('setForceSpeakerphoneOn failed:', e?.message ?? e); } set({ speaker: next }); }, _clear: () => { teardown(); set({ status: 'idle', peer: null, callId: null, muted: false, speaker: false, startedAt: null, endReason: null }); }, }; });