fix(native): phantom/zombie incoming calls (iOS) + DM online dot

Calls: an incoming call that ended without the in-app /call screen ever
mounting (iOS shows the native CallKit banner, not our screen) left the
call store stuck in 'ended' forever — the ended→idle reset only lived in
the /call screen. A stuck 'ended' then blocked every subsequent incoming
call (RING + VoIP push were received but dropped by the status!=='idle'
guard), so accepting from the banner produced a phantom CallKit call that
ticked as active with no connection, and the caller saw a missed call.
- store self-heals back to 'idle' after a call ends (teardown fallback)
- receiveIncoming + ring handler tolerate a stale 'ended' state
- onAnswer ends the native CallKit call when store has no incoming call
- RNCallKeep.endAllCalls() on launch clears leftover CallKit zombies

DM online dot: the green avatar dot used follow-gated presence while the
"online" text used raw presence → dot hidden for non-followed partners
even when online. DM header avatar now uses raw presence (rawPresence
prop) → consistent with the text on both platforms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-06-06 10:03:27 +02:00
parent 084f821bc5
commit ac1d33afb8
7 changed files with 72 additions and 8 deletions

View File

@ -0,0 +1,9 @@
# Next Release
## Fixes
- **Calls: fixed phantom/zombie incoming calls (iOS).** After an incoming call ended without the in-app call screen ever mounting (iOS shows the native CallKit banner, not our `/call` screen), the call store stayed stuck in the `ended` state forever. The `ended → idle` reset only lived in the `/call` screen, which never mounts for banner-only incoming calls. A stuck `ended` state then silently blocked every subsequent incoming call (RING + VoIP push were received but ignored by the `status !== 'idle'` guard), so accepting from the banner produced a phantom CallKit call that ticked as "active" with no real connection, and the caller saw a missed call.
- Store now self-heals back to `idle` after a call ends, decoupled from the call screen (fallback timer in `teardown`).
- `receiveIncoming` and the realtime ring handler now treat a stale `ended` state as acceptable (clear + proceed) instead of dropping the new call.
- `onAnswer` now ends the native CallKit call when the store has no `incoming` call, preventing a phantom "active" call.
- `RNCallKeep.endAllCalls()` on app launch clears leftover CallKit zombies from a previous session.
- **DM header: online dot now matches the online text.** The green online dot on the partner avatar used the follow-gated presence (`isOnline` = online AND you follow them), while the "online" text next to it used raw presence. In a DM the dot now uses raw presence too, so it shows whenever the partner is online — consistent with the text, regardless of follow relationship. (Looked like an Android-only bug but was the follow gate + asymmetric follow between the test accounts.)

View File

@ -782,6 +782,7 @@ export default function DmScreen() {
avatar={partner?.avatar ?? null}
nickname={partner?.nickname ?? '?'}
size="md"
rawPresence
/>
</View>
<View style={{ flexShrink: 1 }}>

View File

@ -14,6 +14,10 @@ type Props = {
size?: Size;
showOnlineIndicator?: boolean;
isBot?: boolean;
// Online-Punkt OHNE Follow-Gate (rohe Presence). Für Kontexte wo die Beziehung
// bereits etabliert ist (z.B. DM-Header) — sonst zeigt der Punkt nur bei
// gefolgten Usern, was inkonsistent zum „online"-Text wäre (der ist ungated).
rawPresence?: boolean;
};
const SIZE_MAP: Record<
@ -60,9 +64,10 @@ export function UserAvatar({
size = 'md',
showOnlineIndicator = true,
isBot = false,
rawPresence = false,
}: Props) {
const colors = useColors();
const { isOnline } = useOnlineUsers();
const { isOnline, onlineUserIds } = useOnlineUsers();
const [imageFailed, setImageFailed] = useState(false);
const s = SIZE_MAP[size];
@ -76,7 +81,7 @@ export function UserAvatar({
showOnlineIndicator !== false &&
!!userId &&
!isBot &&
isOnline(userId);
(rawPresence ? onlineUserIds.has(userId) : isOnline(userId));
return (
<View style={{ position: 'relative', width: s.avatar, height: s.avatar }}>

View File

@ -62,9 +62,16 @@ export function useCallKeepEvents() {
// User tippt "Annehmen" in der CallKit-/ConnectionService-UI
const onAnswer = ({ callUUID }: { callUUID: string }) => {
console.log('[callkeep] answer', callUUID, 'appState=', AppState.currentState);
const st = useCallStore.getState();
if (st.status !== 'incoming') return;
console.log('[callkeep] answer', callUUID, 'appState=', AppState.currentState, 'storeStatus=', st.status);
if (st.status !== 'incoming') {
// CallKit hat den Call nativ bereits auf 'aktiv' gesetzt (Banner \u2192 Timer).
// Ohne 'incoming'-State im Store k\u00f6nnen wir nicht joinen \u2192 es entst\u00fcnde ein
// Phantom-Call der ewig \u201eaktiv" tickt. Sauber beenden statt nur return.
console.log('[callkeep] answer but store not incoming \u2192 endAllCalls to avoid phantom');
try { RNCallKeep.endAllCalls(); } catch {}
return;
}
// Call-Screen \u00f6ffnen und Accept-Flow triggern.
router.push('/call');
void st.acceptCall();

View File

@ -1,4 +1,5 @@
import { useEffect } from 'react';
import { Platform } from 'react-native';
import { useRouter } from 'expo-router';
import { supabase } from '../lib/supabase';
import { useCallStore, type CallPeer } from '../stores/call';
@ -22,10 +23,20 @@ export function useIncomingCalls(myUserId: string | undefined) {
const callId = msg?.payload?.callId as string | undefined;
const from = msg?.payload?.from as CallPeer | undefined;
if (!callId || !from) return;
// Schon in einem Call → ignorieren (MVP: kein call-waiting).
if (useCallStore.getState().status !== 'idle') return;
// Schon in einem AKTIVEN Call → ignorieren (MVP: kein call-waiting).
// 'ended' ist KEIN aktiver Call (nur ein noch nicht aufgeräumter Rest eines
// vorherigen Calls) → durchlassen, receiveIncoming räumt ihn auf. Sonst
// blockt ein stale 'ended' jeden neuen Anruf.
const st = useCallStore.getState().status;
if (st !== 'idle' && st !== 'ended') return;
useCallStore.getState().receiveIncoming(callId, from);
router.push('/call');
// iOS: CallKit (via VoIP-Push → reportNewIncomingCall) IST die Eingehend-UI.
// Hier NICHT zu /call navigieren, sonst Doppel-UI (CallKit-Banner +
// Fullscreen-/call). Der Realtime-Ring dient auf iOS nur der Store-
// Hydration; der /call-Screen kommt erst nach „Annehmen" (CallKit →
// useCallKeepEvents.onAnswer → router.push('/call')).
// Android: Custom-/call ist die gewünschte Eingehend-UI.
if (Platform.OS !== 'ios') router.push('/call');
});
chan.on('broadcast', { event: 'cancel' }, (msg: any) => {
const callId = msg?.payload?.callId as string | undefined;

View File

@ -68,6 +68,11 @@ export async function setupCallKeep(): Promise<void> {
);
} catch {}
}
// Zombie-Cleanup beim App-Start: Ein vorheriger Call der nie sauber via
// endCall beendet wurde (z.B. Phantom nach Accept-ohne-Join) bleibt sonst in
// iOS-CallKit ewig „aktiv". setupCallKeep läuft nur einmal pro Launch
// (didSetup-Guard), daher kein Risiko einen legitimen Live-Call zu killen.
try { RNCallKeep.endAllCalls(); } catch {}
didSetup = true;
} catch (e: any) {
console.warn('[callkeep] setup failed', e?.message ?? e);

View File

@ -52,6 +52,11 @@ let callChan: RealtimeChannel | null = null;
let pendingRemoteIce: any[] = [];
let unansweredTimer: ReturnType<typeof setTimeout> | null = null;
let incomingTimer: ReturnType<typeof setTimeout> | 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<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
@ -118,6 +123,21 @@ function teardown() {
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).
@ -381,7 +401,13 @@ export const useCallStore = create<CallState>((set, get) => {
// 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 === '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, ')');
}