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.
This commit is contained in:
chahinebrini 2026-06-04 21:48:34 +02:00
parent 7fae4539ae
commit 5531ef5419
4 changed files with 42 additions and 13 deletions

View File

@ -39,11 +39,23 @@ export default function CallScreen() {
// schlie\u00dfen \u2014 sonst flickert die Call-UI sofort weg ("kurz und verschwindet"). // schlie\u00dfen \u2014 sonst flickert die Call-UI sofort weg ("kurz und verschwindet").
const mountedAt = useRef(Date.now()); const mountedAt = useRef(Date.now());
// Helper: zur\u00fcck oder Fallback zu Home, wenn kein Back-Stack vorhanden // Helper: Call-Screen schließen. Wir nutzen IMMER replace('/') statt back().
// (z.B. wenn /call via VoIP-PushKit / Deep-Link als Initial-Route ge\u00f6ffnet wurde). //
const closeScreen = () => { // Warum nicht router.back()?
if (router.canGoBack()) router.back(); // React-Navigation dispatched GO_BACK asynchron über seinen Reducer. Wenn
else router.replace('/'); // canGoBack() zwar true zurückgibt aber der Stack-Zustand zwischenzeitlich
// (z.B. durch AppState-Change oder Tab-Switch während des Calls) inkonsistent
// geworden ist, wirft der Reducer einen GO_BACK-Action-Error — und der landet
// NICHT im try/catch um back(), sondern crasht beim nächsten Render in
// wrap-jsx.js. replace('/') ist deterministisch.
const closeScreen = (why: string) => {
const sinceMount = Date.now() - mountedAt.current;
console.log('[call-screen] closeScreen why=', why, 'sinceMount=', sinceMount, 'ms');
try {
router.replace('/');
} catch (err) {
console.warn('[call-screen] router.replace(/) threw', err);
}
}; };
// Kein aktiver Call \u2192 Screen schlie\u00dfen. // Kein aktiver Call \u2192 Screen schlie\u00dfen.
@ -54,20 +66,21 @@ export default function CallScreen() {
// Initial-Mount-Race: noch warten ob startCall/receiveIncoming den status // Initial-Mount-Race: noch warten ob startCall/receiveIncoming den status
// gleich auf outgoing/incoming setzt. // gleich auf outgoing/incoming setzt.
const tm = setTimeout(() => { const tm = setTimeout(() => {
if (useCallStore.getState().status === 'idle') closeScreen(); if (useCallStore.getState().status === 'idle') closeScreen('idle-after-grace');
}, 250 - sinceMount); }, 250 - sinceMount);
return () => clearTimeout(tm); return () => clearTimeout(tm);
} }
closeScreen(); closeScreen('idle');
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [status]); }, [status]);
// Call beendet → kurz "beendet" zeigen, dann schließen + aufräumen. // Call beendet → kurz "beendet" zeigen, dann schließen + aufräumen.
useEffect(() => { useEffect(() => {
if (status !== 'ended') return; if (status !== 'ended') return;
console.log('[call-screen] status=ended -> will close in 1300ms; endReason=', endReason);
const tm = setTimeout(() => { const tm = setTimeout(() => {
clear(); clear();
closeScreen(); closeScreen('ended-timeout');
}, 1300); }, 1300);
return () => clearTimeout(tm); return () => clearTimeout(tm);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps

View File

@ -8,7 +8,7 @@
* Wird einmal app-weit im _layout.tsx aufgerufen. * Wird einmal app-weit im _layout.tsx aufgerufen.
*/ */
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Platform } from 'react-native'; import { AppState, Platform } from 'react-native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import RNCallKeep from 'react-native-callkeep'; import RNCallKeep from 'react-native-callkeep';
import { useCallStore } from '../stores/call'; import { useCallStore } from '../stores/call';
@ -62,19 +62,32 @@ 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); console.log('[callkeep] answer', callUUID, 'appState=', AppState.currentState);
const st = useCallStore.getState(); const st = useCallStore.getState();
if (st.status !== 'incoming') return; if (st.status !== 'incoming') return;
// Call-Screen öffnen und Accept-Flow triggern. // Call-Screen \u00f6ffnen und Accept-Flow triggern.
router.push('/call'); router.push('/call');
void st.acceptCall(); void st.acceptCall();
}; };
// User tippt "Ablehnen" oder "Auflegen" in der nativen UI // User tippt "Ablehnen" oder "Auflegen" in der nativen UI
const onEnd = ({ callUUID }: { callUUID: string }) => { const onEnd = ({ callUUID }: { callUUID: string }) => {
console.log('[callkeep] end', callUUID); const appState = AppState.currentState;
const st = useCallStore.getState(); const st = useCallStore.getState();
console.log('[callkeep] end', callUUID, 'appState=', appState, 'storeStatus=', st.status);
if (st.status === 'idle' || st.status === 'ended') return; if (st.status === 'idle' || st.status === 'ended') return;
// iOS-Foreground: AppDelegate.didReceiveIncomingPush MUSS reportNewIncomingCall
// aufrufen (Apple-Pflicht, sonst killt iOS die App). Im Foreground bedient
// sich der User aber an unserem In-App /call-Screen mit Accept/Decline-
// Buttons. CallKit's Auto-Dismiss-Timer (~5s im Foreground!) darf den
// User-Flow NICHT unterbrechen — sonst verschwindet der /call-Screen nach
// 1-5s ohne dass der User irgendwas getippt hat.
// (Empirisch verifiziert: dieser early-return ist genau der Fix für das
// "1-3s disappear"-Bug. Bitte nicht erneut entfernen.)
if (Platform.OS === 'ios' && appState === 'active' && st.status === 'incoming') {
console.log('[callkeep] endCall IGNORED (iOS foreground incoming — in-app UI is authoritative)');
return;
}
if (st.status === 'incoming') { if (st.status === 'incoming') {
st.declineCall(); st.declineCall();
} else { } else {

View File

@ -30,6 +30,7 @@ export function useIncomingCalls(myUserId: string | undefined) {
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;
const st = useCallStore.getState(); const st = useCallStore.getState();
console.log('[CALL/recv] CANCEL received callId=', callId, 'storeStatus=', st.status, 'storeCallId=', st.callId);
if (st.callId === callId && st.status === 'incoming') { if (st.callId === callId && st.status === 'incoming') {
// Caller hat aufgelegt bevor wir annehmen konnten → verpasster Anruf. // Caller hat aufgelegt bevor wir annehmen konnten → verpasster Anruf.
st.hangup('unanswered'); st.hangup('unanswered');

View File

@ -446,7 +446,8 @@ export const useCallStore = create<CallState>((set, get) => {
}, },
declineCall: () => { declineCall: () => {
const { callId, peer, startedAt } = get(); const { callId, peer, startedAt, status } = get();
clog('declineCall called — status=', status, 'callId=', callId);
if (callId) { if (callId) {
// CallKit/ConnectionService aus dem Lockscreen-UI entfernen. // CallKit/ConnectionService aus dem Lockscreen-UI entfernen.
try { callkit.endCall(callId); } catch {} try { callkit.endCall(callId); } catch {}
@ -469,6 +470,7 @@ export const useCallStore = create<CallState>((set, get) => {
hangup: (reason = 'ended') => { hangup: (reason = 'ended') => {
const { status, peer, callId, startedAt } = get(); const { status, peer, callId, startedAt } = get();
clog('hangup called — reason=', reason, 'status=', status, 'callId=', callId);
if (status === 'idle' || status === 'ended') { if (status === 'idle' || status === 'ended') {
teardown(); teardown();
return; return;