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:
parent
7fae4539ae
commit
5531ef5419
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -446,7 +446,8 @@ export const useCallStore = create<CallState>((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<CallState>((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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user