146 lines
7.1 KiB
TypeScript
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]);
|
|
};
|