import { useEffect } from 'react'; import { useAtomValue, useSetAtom, useStore } from 'jotai'; import { App } from '@capacitor/app'; import { pendingCallActionAtom } from '../state/pendingCallAction'; import { incomingCallsAtom } from '../state/incomingCalls'; import { callEmbedAtom, overLockCallAtom } from '../state/callEmbed'; import { useMatrixClient } from './useMatrixClient'; import { isNativePlatform } from '../utils/capacitor'; import { useSwitchOrStartDmCall } from './useSwitchOrStartDmCall'; import { telecomCall } from '../plugins/call/telecomCall'; import { overLockCall } from '../plugins/call/overLockCall'; import { callForegroundService } from '../plugins/call/callForegroundService'; // Consumes pending call actions emitted by the native Android push-action // listener (see usePushNotifications.ts). Must be mounted inside CallEmbedProvider // so useSwitchOrStartDmCall can reach the embed atom. export const usePendingCallActionConsumer = (): void => { const pending = useAtomValue(pendingCallActionAtom); const setPending = useSetAtom(pendingCallActionAtom); const setIncoming = useSetAtom(incomingCallsAtom); const setOverLock = useSetAtom(overLockCallAtom); const switchOrStartDmCall = useSwitchOrStartDmCall(); const store = useStore(); const mx = useMatrixClient(); useEffect(() => { if (!pending) return; if (pending.kind === 'answer') { const { roomId, notifEventId } = pending; setPending(undefined); // Move a ring-time Telecom session (created natively from the FCM ring, // Phase C) RINGING → ACTIVE. This synchronously marks the session answered // so the REMOVE_BY_* below — which bridges removeIncomingRing — keeps the // now-active session + its phoneCall FGS instead of tearing them down. // No-op when Telecom is off or no native ring session exists. telecomCall.answer(roomId).then( () => { // Clear the native ring slot (telecomRingEventId) directly. A // cold-start answer-from-killed ring may not be in incomingCallsAtom // yet, so the atom REMOVE_BY_* below would be a no-op and the slot // would stick ~32s (until the expiry alarm), degrading the next call // to notification-only (M12). answer() above set the session answered, // so maybeTeardownTelecomRing keeps the now-active session + its FGS. if (notifEventId) { callForegroundService.removeIncomingRing(notifEventId).catch(() => undefined); } }, () => { /* best-effort — JS join below is the source of truth for media */ } ); // Close the §2.2 mic-retention window on answer-from-killed: the ring-time // FGS is phoneCall-only, and the microphone type is otherwise added only on // JoinCall (seconds later) — leaving mic AppOps unprotected under the lock in // between. Re-issue the FGS with the microphone type NOW. computeForegroundType // intersects with the RECORD_AUDIO grant natively, so this is upgrade-or-noop // (stays phoneCall-only if the grant is somehow missing — never throws). (M11) callForegroundService .start({ phoneCall: true, title: mx.getRoom(roomId)?.name || undefined }) .catch(() => undefined); // If the user answered on a locked device, MainActivity is showing over the // keyguard (over_lock) — enter the full-screen over-lock call screen so the // rest of the app stays covered (privacy) while audio joins without unlock. // `left` guards a late getState() from re-showing the overlay after we've // already exited (join failure / unlock racing the bridge round-trip). let left = false; const leaveOverLock = () => { left = true; overLockCall.exit().then( () => setOverLock(false), () => setOverLock(false) ); }; overLockCall.getState().then( (s) => { if (!left && s.overLock) setOverLock(true); }, () => undefined ); const dropRing = () => { if (notifEventId) { setIncoming({ type: 'REMOVE_BY_NOTIF_ID', notifEventId }); return; } setIncoming({ type: 'REMOVE_BY_ROOM', roomId }); }; switchOrStartDmCall(roomId, true /* answering an incoming ring */) .then(() => { dropRing(); // switchOrStartDmCall resolves (not rejects) when the room isn't in the // store yet (cold-start answer-from-killed). With no embed the over-lock // screen would strand the user on a spinner, so bail out of over-lock. // No embed means the joined/embed-lifecycle teardowns won't run either, // so end the Telecom session AND stop the ring-time phoneCall FGS here // (both leak otherwise — H2). if (!store.get(callEmbedAtom)) { telecomCall.endCall().catch(() => undefined); callForegroundService.stop().catch(() => undefined); leaveOverLock(); } }) .catch((err: unknown) => { // eslint-disable-next-line no-console console.warn('[call] native answer switch/start failed', err); // Join failed after we answered the Telecom session — tear it down so // the phoneCall FGS + system call presence don't leak. endCall() flips // the native session out of "answered"; stop() ends the FGS directly // (the atom dropRing below is a no-op for a cold-start ring not in the // atom, so don't depend on it for FGS teardown — H2). telecomCall.endCall().catch(() => undefined); callForegroundService.stop().catch(() => undefined); leaveOverLock(); dropRing(); }); return; } const { roomId, notifEventId } = pending; setPending(undefined); // Unreachable in practice: every Decline button press fires // CallDeclineReceiver via PendingIntent.getBroadcast, // so MainActivity never boots and `pushNotificationActionPerformed` never // fires with call_action='decline'. Nothing else queues a decline onto // pendingCallActionAtom. Kept as a safety-net in case a future JS-path // (in-app banner decline, retry flow, etc.) starts emitting here. setIncoming({ type: 'REMOVE_BY_NOTIF_ID', notifEventId }); // Fire-and-minimize: dispatch the decline then minimize the app once the // request settles (success OR failure). Minimizing before sendRtcDecline // resolves risks the WebView getting paused mid-request on slower devices; // waiting for settlement gives the network call the tick it needs. const minimize = () => { if (!isNativePlatform()) return; App.minimizeApp().catch(() => { /* minimize not supported / already in background */ }); }; mx.sendRtcDecline(roomId, notifEventId).then( () => minimize(), (err: unknown) => { // eslint-disable-next-line no-console console.warn('[call] sendRtcDecline (from push action) failed', err); minimize(); } ); }, [pending, setPending, setIncoming, setOverLock, switchOrStartDmCall, store, mx]); };