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:
parent
084f821bc5
commit
ac1d33afb8
9
apps/rebreak-native/NEXT_RELEASE.md
Normal file
9
apps/rebreak-native/NEXT_RELEASE.md
Normal 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.)
|
||||||
@ -782,6 +782,7 @@ export default function DmScreen() {
|
|||||||
avatar={partner?.avatar ?? null}
|
avatar={partner?.avatar ?? null}
|
||||||
nickname={partner?.nickname ?? '?'}
|
nickname={partner?.nickname ?? '?'}
|
||||||
size="md"
|
size="md"
|
||||||
|
rawPresence
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View style={{ flexShrink: 1 }}>
|
<View style={{ flexShrink: 1 }}>
|
||||||
|
|||||||
@ -14,6 +14,10 @@ type Props = {
|
|||||||
size?: Size;
|
size?: Size;
|
||||||
showOnlineIndicator?: boolean;
|
showOnlineIndicator?: boolean;
|
||||||
isBot?: 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<
|
const SIZE_MAP: Record<
|
||||||
@ -60,9 +64,10 @@ export function UserAvatar({
|
|||||||
size = 'md',
|
size = 'md',
|
||||||
showOnlineIndicator = true,
|
showOnlineIndicator = true,
|
||||||
isBot = false,
|
isBot = false,
|
||||||
|
rawPresence = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const { isOnline } = useOnlineUsers();
|
const { isOnline, onlineUserIds } = useOnlineUsers();
|
||||||
const [imageFailed, setImageFailed] = useState(false);
|
const [imageFailed, setImageFailed] = useState(false);
|
||||||
|
|
||||||
const s = SIZE_MAP[size];
|
const s = SIZE_MAP[size];
|
||||||
@ -76,7 +81,7 @@ export function UserAvatar({
|
|||||||
showOnlineIndicator !== false &&
|
showOnlineIndicator !== false &&
|
||||||
!!userId &&
|
!!userId &&
|
||||||
!isBot &&
|
!isBot &&
|
||||||
isOnline(userId);
|
(rawPresence ? onlineUserIds.has(userId) : isOnline(userId));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ position: 'relative', width: s.avatar, height: s.avatar }}>
|
<View style={{ position: 'relative', width: s.avatar, height: s.avatar }}>
|
||||||
|
|||||||
@ -62,9 +62,16 @@ export function useCallKeepEvents() {
|
|||||||
|
|
||||||
// User tippt "Annehmen" in der CallKit-/ConnectionService-UI
|
// User tippt "Annehmen" in der CallKit-/ConnectionService-UI
|
||||||
const onAnswer = ({ callUUID }: { callUUID: string }) => {
|
const onAnswer = ({ callUUID }: { callUUID: string }) => {
|
||||||
console.log('[callkeep] answer', callUUID, 'appState=', AppState.currentState);
|
|
||||||
const st = useCallStore.getState();
|
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.
|
// Call-Screen \u00f6ffnen und Accept-Flow triggern.
|
||||||
router.push('/call');
|
router.push('/call');
|
||||||
void st.acceptCall();
|
void st.acceptCall();
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { useCallStore, type CallPeer } from '../stores/call';
|
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 callId = msg?.payload?.callId as string | undefined;
|
||||||
const from = msg?.payload?.from as CallPeer | undefined;
|
const from = msg?.payload?.from as CallPeer | undefined;
|
||||||
if (!callId || !from) return;
|
if (!callId || !from) return;
|
||||||
// Schon in einem Call → ignorieren (MVP: kein call-waiting).
|
// Schon in einem AKTIVEN Call → ignorieren (MVP: kein call-waiting).
|
||||||
if (useCallStore.getState().status !== 'idle') return;
|
// '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);
|
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) => {
|
chan.on('broadcast', { event: 'cancel' }, (msg: any) => {
|
||||||
const callId = msg?.payload?.callId as string | undefined;
|
const callId = msg?.payload?.callId as string | undefined;
|
||||||
|
|||||||
@ -68,6 +68,11 @@ export async function setupCallKeep(): Promise<void> {
|
|||||||
);
|
);
|
||||||
} catch {}
|
} 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;
|
didSetup = true;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.warn('[callkeep] setup failed', e?.message ?? e);
|
console.warn('[callkeep] setup failed', e?.message ?? e);
|
||||||
|
|||||||
@ -52,6 +52,11 @@ let callChan: RealtimeChannel | null = null;
|
|||||||
let pendingRemoteIce: any[] = [];
|
let pendingRemoteIce: any[] = [];
|
||||||
let unansweredTimer: ReturnType<typeof setTimeout> | null = null;
|
let unansweredTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let incomingTimer: 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 selfMe: CallPeer | null = null; // für Caller-Side DM-Logging
|
||||||
let currentRole: 'caller' | 'callee' | null = null;
|
let currentRole: 'caller' | 'callee' | null = null;
|
||||||
let loggedCallId: string | null = null; // Idempotenz-Guard für DM-Log
|
let loggedCallId: string | null = null; // Idempotenz-Guard für DM-Log
|
||||||
@ -118,6 +123,21 @@ function teardown() {
|
|||||||
pc = null;
|
pc = null;
|
||||||
localStream = null;
|
localStream = null;
|
||||||
pendingRemoteIce = [];
|
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).
|
// 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?
|
// Schon im Gespräch / oder bereits am Klingeln mit derselben callId?
|
||||||
// → ignorieren (dedup: Realtime + VoIP-Push können beide feuern).
|
// → ignorieren (dedup: Realtime + VoIP-Push können beide feuern).
|
||||||
const cur = get();
|
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) {
|
if (cur.status === 'incoming' && cur.callId === callId) {
|
||||||
clog('receiveIncoming dedup (already incoming for', callId, ')');
|
clog('receiveIncoming dedup (already incoming for', callId, ')');
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user