vojo/src/app/hooks/usePendingCallActionConsumer.ts

146 lines
7.1 KiB
TypeScript

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]);
};