// 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; answer(options: { roomId: string; video?: boolean }): Promise; setActive(): Promise; endCall(): Promise; requestEndpoint(options: { route: TelecomRoute }): Promise; addListener( eventName: 'telecomAnswer', listenerFunc: (data: { video: boolean }) => void ): Promise; addListener( eventName: 'telecomDisconnect', listenerFunc: (data: { causeCode: number }) => void ): Promise; addListener( eventName: 'telecomSetActive' | 'telecomSetInactive', listenerFunc: () => void ): Promise; addListener( eventName: 'telecomMute', listenerFunc: (data: { muted: boolean }) => void ): Promise; addListener( eventName: 'telecomEndpoint', listenerFunc: (data: { route: TelecomRoute }) => void ): Promise; addListener( eventName: 'telecomEndpoints', listenerFunc: (data: { routes: TelecomRoute[] }) => void ): Promise; addListener( eventName: 'telecomError', listenerFunc: (data: { message: string }) => void ): Promise; } const plugin = registerPlugin('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 { 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 { if (!isTelecomEnabled()) return Promise.resolve(); return plugin.answer({ roomId, video }); }, setActive(): Promise { if (!isTelecomEnabled()) return Promise.resolve(); return plugin.setActive(); }, // Idempotent best-effort teardown — safe to call on any platform. endCall(): Promise { if (!isTelecomEnabled()) return Promise.resolve(); return plugin.endCall(); }, requestEndpoint(route: TelecomRoute): Promise { 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 { if (!isTelecomEnabled()) { return Promise.resolve({ remove: () => Promise.resolve() }); } // eslint-disable-next-line @typescript-eslint/no-explicit-any return plugin.addListener(eventName as any, listenerFunc); }, };