vojo/src/app/plugins/call/telecomCall.ts

129 lines
5 KiB
TypeScript

// Typed wrapper around the self-managed Telecom call plugin
// (TelecomCallPlugin.java + VojoCallsManager.kt). See
// docs/plans/telecom_migration.md.
//
// Telecom gives Vojo's native Android calls system-level call interop (a GSM
// call interrupts/holds the Vojo call and vice-versa), audio-focus
// arbitration, Bluetooth / Android Auto / Wear answer + route surfaces, and a
// system "ongoing call" presence. The actual media stays in the Element Call
// WebView; this layer only drives the Telecom call STATE and translates its
// callbacks (answer / disconnect / hold / mute / route) back to JS.
//
// Telecom is the sole native call backend on Android (minSdk 26 = CallsManager
// is always available; the legacy AudioRoute path has been retired). There is
// no opt-in flag any more — it is active on every Android build.
//
// Android-only. On web / iOS every method is a no-op and never touches the
// native plugin.
import { registerPlugin, type PluginListenerHandle } from '@capacitor/core';
import { isAndroidPlatform } from '../../utils/capacitor';
/** Audio routes mirrored to/from CallEndpointCompat types (see VojoCallsManager). */
export type TelecomRoute = 'EARPIECE' | 'SPEAKER' | 'BLUETOOTH' | 'WIRED' | 'STREAMING' | 'UNKNOWN';
/** Events emitted by TelecomCallPlugin (see VojoCallsManager.Listener). */
export type TelecomEvent =
| 'telecomAnswer'
| 'telecomDisconnect'
| 'telecomSetActive'
| 'telecomSetInactive'
| 'telecomMute'
| 'telecomEndpoint'
| 'telecomEndpoints'
| 'telecomError';
export interface TelecomStartOptions {
roomId: string;
displayName: string;
video?: boolean;
incoming?: boolean;
}
export interface TelecomCallPlugin {
startCall(options: TelecomStartOptions): Promise<void>;
answer(options: { roomId: string; video?: boolean }): Promise<void>;
setActive(): Promise<void>;
endCall(): Promise<void>;
requestEndpoint(options: { route: TelecomRoute }): Promise<void>;
addListener(
eventName: 'telecomAnswer',
listenerFunc: (data: { video: boolean }) => void
): Promise<PluginListenerHandle>;
addListener(
eventName: 'telecomDisconnect',
listenerFunc: (data: { causeCode: number }) => void
): Promise<PluginListenerHandle>;
addListener(
eventName: 'telecomSetActive' | 'telecomSetInactive',
listenerFunc: () => void
): Promise<PluginListenerHandle>;
addListener(
eventName: 'telecomMute',
listenerFunc: (data: { muted: boolean }) => void
): Promise<PluginListenerHandle>;
addListener(
eventName: 'telecomEndpoint',
listenerFunc: (data: { route: TelecomRoute }) => void
): Promise<PluginListenerHandle>;
addListener(
eventName: 'telecomEndpoints',
listenerFunc: (data: { routes: TelecomRoute[] }) => void
): Promise<PluginListenerHandle>;
addListener(
eventName: 'telecomError',
listenerFunc: (data: { message: string }) => void
): Promise<PluginListenerHandle>;
}
const plugin = registerPlugin<TelecomCallPlugin>('TelecomCall');
/** True on Android, where Telecom is the native call backend (no-op elsewhere). */
export const isTelecomEnabled = (): boolean => isAndroidPlatform();
export const telecomCall = {
enabled: isTelecomEnabled,
// Begin a Telecom session alongside the WebView call. Outgoing goes active
// immediately; incoming stays ringing until answer().
startCall(options: TelecomStartOptions): Promise<void> {
if (!isTelecomEnabled()) return Promise.resolve();
return plugin.startCall(options);
},
// Move a ring-time incoming Telecom session (RINGING → ACTIVE). roomId guards
// against answering the wrong session when multiple rings exist. No-op when
// Telecom is off or no native session exists for the room.
answer(roomId: string, video = false): Promise<void> {
if (!isTelecomEnabled()) return Promise.resolve();
return plugin.answer({ roomId, video });
},
setActive(): Promise<void> {
if (!isTelecomEnabled()) return Promise.resolve();
return plugin.setActive();
},
// Idempotent best-effort teardown — safe to call on any platform.
endCall(): Promise<void> {
if (!isTelecomEnabled()) return Promise.resolve();
return plugin.endCall();
},
requestEndpoint(route: TelecomRoute): Promise<void> {
if (!isTelecomEnabled()) return Promise.resolve();
return plugin.requestEndpoint({ route });
},
// Subscribe to a Telecom event. Returns a no-op handle when disabled so
// callers can unconditionally register + clean up. (Plain union event type:
// TS `Parameters<>` on the overloaded plugin signature only captures the last
// overload, so we don't derive the event name from it.)
addListener(
eventName: TelecomEvent,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
listenerFunc: (data: any) => void
): Promise<PluginListenerHandle> {
if (!isTelecomEnabled()) {
return Promise.resolve({ remove: () => Promise.resolve() });
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return plugin.addListener(eventName as any, listenerFunc);
},
};