refactor(calls): drop TELECOM_ENABLED flag, retire AudioRoute plugin, bump minSdk to 26
This commit is contained in:
parent
f2eb363db4
commit
2c3dfcb965
5 changed files with 26 additions and 267 deletions
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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() };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue