refactor(calls): drop TELECOM_ENABLED flag, retire AudioRoute plugin, bump minSdk to 26

This commit is contained in:
heaven 2026-06-24 00:43:39 +03:00
parent f2eb363db4
commit 2c3dfcb965
5 changed files with 26 additions and 267 deletions

View file

@ -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<AudioDeviceInfo> 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();
}
}

View file

@ -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'

View file

@ -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() };
};

View file

@ -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<void>;
}
const plugin = registerPlugin<AudioRoutePlugin>('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<boolean> {
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<boolean> {
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<void> {
if (!isAndroidPlatform()) return Promise.resolve();
return plugin.clear().catch(() => undefined);
},
};

View file

@ -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<void>;
answer(options: { video?: boolean }): Promise<void>;
answer(options: { roomId: string; video?: boolean }): Promise<void>;
setActive(): Promise<void>;
endCall(): Promise<void>;
requestEndpoint(options: { route: TelecomRoute }): Promise<void>;
@ -83,8 +78,8 @@ export interface TelecomCallPlugin {
const plugin = registerPlugin<TelecomCallPlugin>('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<void> {
// 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({ video });
return plugin.answer({ roomId, video });
},
setActive(): Promise<void> {
if (!isTelecomEnabled()) return Promise.resolve();