diff --git a/apps/rebreak-native/app/call.tsx b/apps/rebreak-native/app/call.tsx index 5d9a8dc..8674c3c 100644 --- a/apps/rebreak-native/app/call.tsx +++ b/apps/rebreak-native/app/call.tsx @@ -39,11 +39,23 @@ export default function CallScreen() { // schlie\u00dfen \u2014 sonst flickert die Call-UI sofort weg ("kurz und verschwindet"). const mountedAt = useRef(Date.now()); - // Helper: zur\u00fcck oder Fallback zu Home, wenn kein Back-Stack vorhanden - // (z.B. wenn /call via VoIP-PushKit / Deep-Link als Initial-Route ge\u00f6ffnet wurde). - const closeScreen = () => { - if (router.canGoBack()) router.back(); - else router.replace('/'); + // Helper: Call-Screen schließen. Wir nutzen IMMER replace('/') statt back(). + // + // Warum nicht router.back()? + // React-Navigation dispatched GO_BACK asynchron über seinen Reducer. Wenn + // 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. @@ -54,20 +66,21 @@ export default function CallScreen() { // Initial-Mount-Race: noch warten ob startCall/receiveIncoming den status // gleich auf outgoing/incoming setzt. const tm = setTimeout(() => { - if (useCallStore.getState().status === 'idle') closeScreen(); + if (useCallStore.getState().status === 'idle') closeScreen('idle-after-grace'); }, 250 - sinceMount); return () => clearTimeout(tm); } - closeScreen(); + closeScreen('idle'); // eslint-disable-next-line react-hooks/exhaustive-deps }, [status]); // Call beendet → kurz "beendet" zeigen, dann schließen + aufräumen. useEffect(() => { if (status !== 'ended') return; + console.log('[call-screen] status=ended -> will close in 1300ms; endReason=', endReason); const tm = setTimeout(() => { clear(); - closeScreen(); + closeScreen('ended-timeout'); }, 1300); return () => clearTimeout(tm); // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/apps/rebreak-native/hooks/useCallKeepEvents.ts b/apps/rebreak-native/hooks/useCallKeepEvents.ts index 70710a5..3749fd0 100644 --- a/apps/rebreak-native/hooks/useCallKeepEvents.ts +++ b/apps/rebreak-native/hooks/useCallKeepEvents.ts @@ -8,7 +8,7 @@ * Wird einmal app-weit im _layout.tsx aufgerufen. */ import { useEffect } from 'react'; -import { Platform } from 'react-native'; +import { AppState, Platform } from 'react-native'; import { useRouter } from 'expo-router'; import RNCallKeep from 'react-native-callkeep'; import { useCallStore } from '../stores/call'; @@ -62,19 +62,32 @@ export function useCallKeepEvents() { // User tippt "Annehmen" in der CallKit-/ConnectionService-UI const onAnswer = ({ callUUID }: { callUUID: string }) => { - console.log('[callkeep] answer', callUUID); + console.log('[callkeep] answer', callUUID, 'appState=', AppState.currentState); const st = useCallStore.getState(); if (st.status !== 'incoming') return; - // Call-Screen öffnen und Accept-Flow triggern. + // Call-Screen \u00f6ffnen und Accept-Flow triggern. router.push('/call'); void st.acceptCall(); }; // User tippt "Ablehnen" oder "Auflegen" in der nativen UI const onEnd = ({ callUUID }: { callUUID: string }) => { - console.log('[callkeep] end', callUUID); + const appState = AppState.currentState; const st = useCallStore.getState(); + console.log('[callkeep] end', callUUID, 'appState=', appState, 'storeStatus=', st.status); 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') { st.declineCall(); } else { diff --git a/apps/rebreak-native/hooks/useIncomingCalls.ts b/apps/rebreak-native/hooks/useIncomingCalls.ts index 5a4af92..6acf1b7 100644 --- a/apps/rebreak-native/hooks/useIncomingCalls.ts +++ b/apps/rebreak-native/hooks/useIncomingCalls.ts @@ -30,6 +30,7 @@ export function useIncomingCalls(myUserId: string | undefined) { chan.on('broadcast', { event: 'cancel' }, (msg: any) => { const callId = msg?.payload?.callId as string | undefined; 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') { // Caller hat aufgelegt bevor wir annehmen konnten → verpasster Anruf. st.hangup('unanswered'); diff --git a/apps/rebreak-native/stores/call.ts b/apps/rebreak-native/stores/call.ts index 39aa44d..94db172 100644 --- a/apps/rebreak-native/stores/call.ts +++ b/apps/rebreak-native/stores/call.ts @@ -446,7 +446,8 @@ export const useCallStore = create((set, get) => { }, declineCall: () => { - const { callId, peer, startedAt } = get(); + 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 {} @@ -469,6 +470,7 @@ export const useCallStore = create((set, get) => { 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;