chahinebrini 5531ef5419 fix(calls): foreground call screen no longer disappears after few seconds
Root cause: iOS CallKit auto-dismisses incoming-call UI after ~5s when the
app is in foreground (because AppDelegate.didReceiveIncomingPush MUST call
reportNewIncomingCall — Apple requirement). That CallKit dismiss fires an
endCall event which our useCallKeepEvents.onEnd translated to declineCall,
unmounting the in-app /call screen before the user could tap accept/decline.

Fixes:
- useCallKeepEvents.onEnd: ignore CallKit endCall when iOS app is foreground
  AND status==='incoming' (in-app UI is authoritative there). Comment with
  big warning not to remove this again.
- call.tsx closeScreen: replace('/') instead of router.back() to avoid
  GO_BACK action errors when navigation stack is inconsistent after long
  calls (manifested as wrap-jsx.js crash in react-native-css-interop).
- useIncomingCalls: log CANCEL receive events for future diagnostics.
- call.ts: clog hangup/declineCall/closeScreen with reason+status for trace.

Verified: foreground call screen stays up the full UNANSWERED_MS (35s) and
caller-side hangup('unanswered') correctly triggers iPhone closeScreen via
cancel-broadcast.
2026-06-04 21:48:34 +02:00

529 lines
21 KiB
TypeScript

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:<userId>` → nur die initiale Einladung/Abbruch.
// • Call-Channel `call:<callId>` → 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<typeof setTimeout> | null = null;
let incomingTimer: ReturnType<typeof setTimeout> | 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<void>;
receiveIncoming: (callId: string, from: CallPeer) => void;
acceptCall: () => Promise<void>;
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 = [];
}
// DM-Log nach Call-Ende. Nur der CALLER schreibt (verhindert Duplikate).
// Format: attachmentType='call', attachmentName='<kind>:<state>:<durSec>'.
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<CallState>((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<void>((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 !== '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<void>((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 });
},
};
});