diff --git a/android/app/src/main/java/chat/vojo/app/AudioRoutePlugin.java b/android/app/src/main/java/chat/vojo/app/AudioRoutePlugin.java deleted file mode 100644 index a878c1de..00000000 --- a/android/app/src/main/java/chat/vojo/app/AudioRoutePlugin.java +++ /dev/null @@ -1,170 +0,0 @@ -package chat.vojo.app; - -import android.content.Context; -import android.media.AudioDeviceInfo; -import android.media.AudioManager; -import android.os.Build; -import android.util.Log; - -import com.getcapacitor.JSObject; -import com.getcapacitor.Plugin; -import com.getcapacitor.PluginCall; -import com.getcapacitor.PluginMethod; -import com.getcapacitor.annotation.CapacitorPlugin; - -import java.util.List; - -/** - * JS → Android bridge for in-call audio OUTPUT routing (loudspeaker ⇄ earpiece) - * during a DM voice call. - * - * WHY THIS EXISTS. Call audio is owned by Chromium's WebRTC stack inside the - * Capacitor System WebView. For a getUserMedia voice call that stack puts the - * session in MODE_IN_COMMUNICATION and routes to the EARPIECE by default, and - * there is no in-WebView lever to move it — the Audio Output Devices API - * (setSinkId / selectAudioOutput) is unimplemented on Android WebView. So the - * only way to give the user a «громкая связь / loudspeaker» toggle is to reach - * the platform AudioManager natively and flip the output device on top of the - * session the WebView already owns. - * - * COEXISTENCE RULE (critical). The WebView's WebRTC is the single owner of the - * audio session: it already called setMode(MODE_IN_COMMUNICATION) and acquired - * audio focus. This plugin therefore ONLY flips the output device — it does NOT - * call setMode(), does NOT request audio focus, and does NOT start its own ADM. - * Two owners of the same route produce ghost echo / half-muted audio. Mirrors - * the route-only slice of element-android's DefaultAudioDeviceRouter without - * taking ownership of the session. - * - * API split: - * - API 31+ (S): speaker-on = AudioManager.setCommunicationDevice() to the - * TYPE_BUILTIN_SPEAKER from getAvailableCommunicationDevices(); speaker-off - * = clearCommunicationDevice() so the platform auto-routes to a connected - * headset (wired/BT/USB) or the earpiece. clearCommunicationDevice() also - * restores the platform default on call end. - * - API < 31: legacy AudioManager.setSpeakerphoneOn(boolean). Deprecated but - * the only option pre-S; works because the WebView already set - * MODE_IN_COMMUNICATION. - * - * NOTE (on-device verification pending): some OEM WebView builds resist - * app-side routing once they own the session. setSpeaker resolves with the - * route the plugin OBSERVES after the call (getRoute re-read), so the JS side - * can trust the resolved value rather than assuming the request took. - */ -@CapacitorPlugin(name = "AudioRoute") -public class AudioRoutePlugin extends Plugin { - - private static final String TAG = "AudioRoute"; - - private AudioManager am() { - Context ctx = getContext(); - if (ctx == null) return null; - return (AudioManager) ctx.getSystemService(Context.AUDIO_SERVICE); - } - - /** - * setSpeaker({ on: boolean }) → { speaker: boolean } - * Flips the call output to the built-in speaker (on) or earpiece (off). - * Resolves with the route observed AFTER the change so JS state tracks reality. - */ - @PluginMethod - public void setSpeaker(PluginCall call) { - Boolean on = call.getBoolean("on", Boolean.TRUE); - AudioManager audio = am(); - if (audio == null) { - call.reject("no_audio_manager"); - return; - } - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - if (Boolean.TRUE.equals(on)) { - AudioDeviceInfo speaker = - findCommunicationDevice(audio, AudioDeviceInfo.TYPE_BUILTIN_SPEAKER); - if (speaker != null) { - boolean ok = audio.setCommunicationDevice(speaker); - Log.d(TAG, "setCommunicationDevice speaker ok=" + ok); - } else { - Log.w(TAG, "no builtin speaker device"); - } - } else { - // Speaker OFF: hand routing back to the platform rather than - // forcing TYPE_BUILTIN_EARPIECE. Auto-selection prefers a - // connected wired / Bluetooth / USB headset and falls back - // to the earpiece — forcing the earpiece would yank audio - // off a headset the user is actually wearing. - audio.clearCommunicationDevice(); - Log.d(TAG, "clearCommunicationDevice (speaker off -> headset/earpiece)"); - } - } else { - // Legacy: relies on the WebView having set MODE_IN_COMMUNICATION. - // setSpeakerphoneOn(false) lets the system keep a wired headset. - audio.setSpeakerphoneOn(Boolean.TRUE.equals(on)); - Log.d(TAG, "setSpeakerphoneOn " + on); - } - } catch (Throwable t) { - Log.e(TAG, "setSpeaker failed", t); - call.reject("set_speaker_failed: " + t.getClass().getSimpleName()); - return; - } - JSObject ret = new JSObject(); - ret.put("speaker", isSpeakerOn(audio)); - call.resolve(ret); - } - - /** - * getRoute() → { speaker: boolean } - * Reads the currently active output route. - */ - @PluginMethod - public void getRoute(PluginCall call) { - AudioManager audio = am(); - if (audio == null) { - call.reject("no_audio_manager"); - return; - } - JSObject ret = new JSObject(); - ret.put("speaker", isSpeakerOn(audio)); - call.resolve(ret); - } - - /** - * clear() → void - * Restores the platform-default communication route on call end so the - * next call / app doesn't inherit a forced speaker. Mandatory teardown. - */ - @PluginMethod - public void clear(PluginCall call) { - AudioManager audio = am(); - if (audio == null) { - call.resolve(); - return; - } - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - audio.clearCommunicationDevice(); - } else { - audio.setSpeakerphoneOn(false); - } - Log.d(TAG, "clear: route restored to default"); - } catch (Throwable t) { - Log.w(TAG, "clear failed", t); - } - call.resolve(); - } - - private static AudioDeviceInfo findCommunicationDevice(AudioManager audio, int type) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return null; - List devices = audio.getAvailableCommunicationDevices(); - for (AudioDeviceInfo dev : devices) { - if (dev.getType() == type) return dev; - } - return null; - } - - private static boolean isSpeakerOn(AudioManager audio) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - AudioDeviceInfo cur = audio.getCommunicationDevice(); - return cur != null && cur.getType() == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER; - } - return audio.isSpeakerphoneOn(); - } -} diff --git a/android/variables.gradle b/android/variables.gradle index d6daf841..4aa9fb0d 100644 --- a/android/variables.gradle +++ b/android/variables.gradle @@ -1,5 +1,8 @@ ext { - minSdkVersion = 24 + // API 26 (Android 8.0): floor for the native Telecom backend (CallsManager is + // @RequiresApi(26)). Telecom is now the sole call backend (no legacy fallback), + // so the whole app requires 26+. API 24-25 (Android 7.x) share in 2026 is ~0%. + minSdkVersion = 26 compileSdkVersion = 36 targetSdkVersion = 36 androidxActivityVersion = '1.11.0' diff --git a/src/app/hooks/useCallSpeaker.ts b/src/app/hooks/useCallSpeaker.ts index 060d4ff3..3e300999 100644 --- a/src/app/hooks/useCallSpeaker.ts +++ b/src/app/hooks/useCallSpeaker.ts @@ -1,20 +1,16 @@ import { useAtom } from 'jotai'; import { useCallback } from 'react'; import { callSpeakerAtom } from '../state/callEmbed'; -import { callAudioRoute } from '../plugins/call/callAudioRoute'; import { telecomCall } from '../plugins/call/telecomCall'; +import { isAndroidPlatform } from '../utils/capacitor'; // In-call loudspeaker ⇄ earpiece toggle. The atom holds the UI truth. // -// Two backends: -// - Telecom active (TELECOM_ENABLED + API>=26): the OS owns the call's audio -// route, so we ask Telecom to switch endpoints (requestEndpoint). The route -// that actually takes comes back via the `telecomEndpoint` event, which -// useTelecomConnectionSync mirrors into this atom — so we DON'T call -// AudioManager.setCommunicationDevice here (concurrent use corrupts the -// Telecom-owned route). -// - Legacy (no Telecom): the AudioRoute plugin flips the WebView's WebRTC -// output and reports the route it observed; we reconcile to that. +// Telecom owns the call's audio route on Android: we ask it to switch endpoints +// (requestEndpoint), and the route that actually takes comes back via the +// `telecomEndpoint` event, which useTelecomConnectionSync mirrors into this atom. +// We never touch AudioManager.setCommunicationDevice (concurrent use corrupts +// the Telecom-owned route) — the legacy AudioRoute plugin has been retired. // // `available` is false on web / iOS, where the OS owns output routing and the // UI hides the control. @@ -28,16 +24,9 @@ export const useCallSpeaker = (): { const toggle = useCallback(() => { const next = !speaker; setSpeaker(next); - if (telecomCall.enabled()) { - // Telecom owns the route; the telecomEndpoint event reconciles the atom. - telecomCall.requestEndpoint(next ? 'SPEAKER' : 'EARPIECE').catch(() => undefined); - return; - } - callAudioRoute.setSpeaker(next).then( - (actual) => setSpeaker(actual), - () => undefined - ); + // Telecom owns the route; the telecomEndpoint event reconciles the atom. + telecomCall.requestEndpoint(next ? 'SPEAKER' : 'EARPIECE').catch(() => undefined); }, [speaker, setSpeaker]); - return { speaker, toggle, available: callAudioRoute.available() }; + return { speaker, toggle, available: isAndroidPlatform() }; }; diff --git a/src/app/plugins/call/callAudioRoute.ts b/src/app/plugins/call/callAudioRoute.ts deleted file mode 100644 index 1540080e..00000000 --- a/src/app/plugins/call/callAudioRoute.ts +++ /dev/null @@ -1,61 +0,0 @@ -// Typed wrapper around the native AudioRoute Capacitor plugin. -// -// The plugin (AudioRoutePlugin.java) flips the in-call audio OUTPUT between the -// loudspeaker and the earpiece during a DM voice call. This is the ONLY way to -// offer a «громкая связь» toggle: call audio is owned by the WebView's WebRTC -// stack, which routes to the earpiece by default with no in-WebView lever -// (setSinkId / selectAudioOutput are unimplemented on Android WebView). -// -// Android-only. On web / iOS the OS handles output routing, so every method is -// a no-op and `available()` returns false (the UI hides the toggle there). - -import { registerPlugin } from '@capacitor/core'; -import { isAndroidPlatform } from '../../utils/capacitor'; - -interface AudioRoutePlugin { - setSpeaker(options: { on: boolean }): Promise<{ speaker: boolean }>; - getRoute(): Promise<{ speaker: boolean }>; - clear(): Promise; -} - -const plugin = registerPlugin('AudioRoute'); - -export const callAudioRoute = { - // Whether an in-app speaker/earpiece toggle can do anything on this platform. - available(): boolean { - return isAndroidPlatform(); - }, - - // Flip the call output to speaker (on) or earpiece (off). Resolves with the - // route the native side OBSERVES afterwards — some OEM WebViews resist - // app-side routing, so JS trusts the resolved value, not the request. - async setSpeaker(on: boolean): Promise { - if (!isAndroidPlatform()) return on; - try { - const res = await plugin.setSpeaker({ on }); - return res.speaker; - } catch { - // Plugin missing / threw — report the requested state so the UI doesn't - // get stuck, but the actual route is whatever WebRTC chose. - return on; - } - }, - - // Read the currently active output route (true = loudspeaker). - async getRoute(): Promise { - if (!isAndroidPlatform()) return false; - try { - const res = await plugin.getRoute(); - return res.speaker; - } catch { - return false; - } - }, - - // Restore the platform-default route. MUST be called on every call teardown - // (hangup / remote hangup / error) or the next call inherits a forced route. - clear(): Promise { - if (!isAndroidPlatform()) return Promise.resolve(); - return plugin.clear().catch(() => undefined); - }, -}; diff --git a/src/app/plugins/call/telecomCall.ts b/src/app/plugins/call/telecomCall.ts index 4abdf5ab..b1d389e4 100644 --- a/src/app/plugins/call/telecomCall.ts +++ b/src/app/plugins/call/telecomCall.ts @@ -9,21 +9,16 @@ // WebView; this layer only drives the Telecom call STATE and translates its // callbacks (answer / disconnect / hold / mute / route) back to JS. // -// Phase A ships this default-OFF (TELECOM_ENABLED=false) so the first build -// can't regress live calls before the path is validated on a Samsung device. -// Flip the flag to exercise it; once validated it becomes the default and the -// legacy FGS-only path is retired. +// 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 / API<26 every method is a no-op and never -// touches the native plugin. +// 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'; -// Phase A: default off. See plan §10 — flip to test on-device, then promote to -// default once the audio-ownership / echo behavior is confirmed on Samsung. -export const TELECOM_ENABLED = false; - /** Audio routes mirrored to/from CallEndpointCompat types (see VojoCallsManager). */ export type TelecomRoute = 'EARPIECE' | 'SPEAKER' | 'BLUETOOTH' | 'WIRED' | 'STREAMING' | 'UNKNOWN'; @@ -47,7 +42,7 @@ export interface TelecomStartOptions { export interface TelecomCallPlugin { startCall(options: TelecomStartOptions): Promise; - answer(options: { video?: boolean }): Promise; + answer(options: { roomId: string; video?: boolean }): Promise; setActive(): Promise; endCall(): Promise; requestEndpoint(options: { route: TelecomRoute }): Promise; @@ -83,8 +78,8 @@ export interface TelecomCallPlugin { const plugin = registerPlugin('TelecomCall'); -/** True only when the Telecom backend should be driven (Android + flag on). */ -export const isTelecomEnabled = (): boolean => isAndroidPlatform() && TELECOM_ENABLED; +/** True on Android, where Telecom is the native call backend (no-op elsewhere). */ +export const isTelecomEnabled = (): boolean => isAndroidPlatform(); export const telecomCall = { enabled: isTelecomEnabled, @@ -95,9 +90,12 @@ export const telecomCall = { if (!isTelecomEnabled()) return Promise.resolve(); return plugin.startCall(options); }, - answer(video = false): Promise { + // 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({ video }); + return plugin.answer({ roomId, video }); }, setActive(): Promise { if (!isTelecomEnabled()) return Promise.resolve();