android: Select default output device based on callIntent

Add comments on existing code
Extracted a specific android controller for isolation and better testing
This commit is contained in:
Valere
2026-03-12 19:00:09 +01:00
parent 748c8e6d0d
commit d3a12d5e9e
5 changed files with 866 additions and 36 deletions

View File

@@ -0,0 +1,253 @@
/*
Copyright 2026 Element Corp.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import {
combineLatest,
EMPTY,
map,
type Observable,
pairwise,
startWith,
Subject,
switchMap,
tap,
} from "rxjs";
import {
type AudioOutputDeviceLabel,
type MediaDevice,
type SelectedAudioOutputDevice,
} from "./MediaDevices.ts";
import type { ObservableScope } from "./ObservableScope.ts";
import type { RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
import { type Controls, type OutputDevice } from "../controls.ts";
/**
* The implementation of the audio output media device for Android when using the controlled audio output mode.
*
* In this mode, the hosting application (e.g. Element Mobile) is responsible for providing the list of available audio output devices.
* There are some android specific logic compared to others:
* - AndroidControlledAudioOutput is the only one responsible for selecting the best output device.
* - On android, we don't listen to the selected device from native code (control.setAudioDevice).
* - If a new device is added or removed, this controller will determine the new selected device based
* on the available devices (that is ordered by preference order) and the user's selection (if any).
*
* Given the differences in how the native code is handling the audio routing on Android compared to iOS,
* we have this separate implementation. It allows us to have proper testing and avoid side effects
* from platform specific logic breaking the other platform's implementation.
*/
export class AndroidControlledAudioOutput implements MediaDevice<
AudioOutputDeviceLabel,
SelectedAudioOutputDevice
> {
private logger = rootLogger.getChild(
"[MediaDevices AndroidControlledAudioOutput]",
);
/**
* Creates an instance of AndroidControlledAudioOutput.
*
* @constructor
* @param controlledDevices$ - The list of available output devices coming from the hosting application, ordered by preference order (most preferred first).
* @param scope - The ObservableScope to create the Behaviors in.
* @param initialIntent - The initial call intent (e.g. "audio" or "video") that can be used to determine the default audio routing (e.g. default to earpiece for audio calls and speaker for video calls).
* @param controls - The controls provided by the hosting application to control the audio routing and notify of user actions.
*/
public constructor(
private readonly controlledDevices$: Observable<OutputDevice[]>,
private readonly scope: ObservableScope,
private initialIntent: RTCCallIntent | undefined = undefined,
controls: Controls,
) {
this.selected$.pipe(scope.bind()).subscribe((device) => {
// Let the hosting application know which output device has been selected.
if (device !== undefined) {
this.logger.info("onAudioDeviceSelect called:", device);
controls.onAudioDeviceSelect?.(device.id);
// Also invoke the deprecated callback for backward compatibility
// TODO: it appears that on Android the hosting application is only using the deprecated callback (onOutputDeviceSelect)
// and not the new one (onAudioDeviceSelect), we should clean this up and only have one callback for audio device selection.
controls.onOutputDeviceSelect?.(device.id);
}
});
this.selected$
.pipe(
switchMap((selected, index) => {
if (selected == undefined) {
// If we don't have a selected device,
// we don't need to listen to changes in the available devices
return EMPTY;
}
// For a given selected device, we want to listen to changes in the available devices
// and determine if we need to switch the selected device based on that.
return this.controlledDevices$.pipe(pairwise()).pipe(
map(([previous, current]) => {
// If a device is added we might want to switch to it if it's more preferred than the currently selected device.
// If a device is removed, and it was the currently used, we want to switch to the next best device.
const newDeviceWasAdded = current.some(
(device) => !previous.some((d) => d.id === device.id),
);
if (newDeviceWasAdded) {
// check if the currently selected device is the most preferred one, if not switch to the most preferred one.
const mostPreferredDevice = current[0];
if (mostPreferredDevice.id !== selected.id) {
// Given this is automatic switching, we want to be careful and only switch to a more private device
// (e.g. from speaker to a BT headset) but not switch from a more private device to a less private one
// (e.g. from a BT headset to the speaker), as that can be disruptive for the user if it happens unexpectedly.
const candidate = current.find(
(device) => device.id === selected.id,
);
if (candidate?.isExternalHeadset == true) {
// Let's switch as it is a more private device.
this.deviceSelection$.next(mostPreferredDevice.id);
return;
}
}
}
const isSelectedDeviceStillAvailable = current.some(
(device) => device.id === selected.id,
);
if (!isSelectedDeviceStillAvailable) {
// The currently selected device is no longer available, switch to the most preferred available device.
// we can do this by switching back to the default device selection logic (by setting the preferred device to undefined),
// which will pick the most preferred available device.
this.logger.info(
`The currently selected device ${selected.id} is no longer available, switching to the most preferred available device.`,
);
this.deviceSelection$.next(undefined);
}
}),
);
}),
)
.pipe(scope.bind())
.subscribe();
}
private readonly deviceSelection$ = new Subject<string | undefined>();
public select(id: string): void {
this.logger.info(`select device: ${id}`);
this.deviceSelection$.next(id);
}
public readonly available$ = this.scope.behavior(
this.controlledDevices$.pipe(
map((availableRaw) => {
this.logger.info("available raw devices:", availableRaw);
const available = new Map<string, AudioOutputDeviceLabel>(
availableRaw.map((outputDevice) => {
return [outputDevice.id, mapDeviceToLabel(outputDevice)];
}),
);
this.logger.info("available devices mapped:", available);
return available;
}),
),
// start with an empty map
new Map<string, AudioOutputDeviceLabel>(),
);
/**
* On android, we don't listen to the selected device from native code (control.setAudioDevice).
* Instead, we determine the selected device ourselves based on the available devices and the user's selection (if any).
*/
public readonly selected$ = this.scope.behavior(
combineLatest([
this.available$,
this.deviceSelection$.pipe(startWith(undefined)),
])
.pipe(
map(([available, preferredId]) => {
this.logger.debug(
`selecting device: Preferred:${preferredId}: intent:${this.initialIntent}: Available: ${Array.from(available.keys()).join(",")}`,
);
if (preferredId !== undefined) {
return {
id: preferredId,
virtualEarpiece: false /** This is an iOS thing, always false for android*/,
};
} else {
// No preferred device, so pick a default.
const selected = this.chooseDefaultDeviceId(available);
return selected
? {
id: selected,
virtualEarpiece: false /** This is an iOS thing, always false for android*/,
}
: undefined;
}
}),
)
.pipe(
tap((selected) => {
this.logger.debug(`selected device: ${selected?.id}`);
}),
),
);
/**
* The logic for the default is different based on the call type.
* For example for a voice call we want to default to the earpiece if it's available,
* but for a video call we want to default to the speaker.
* If the user is using a BT headset we want to default to that, as it's likely what they want to use for both video and voice calls.
*
* @param available the available audio output devices to choose from, keyed by their id, sorted by likelihood of it being used for communication.
*
*/
private chooseDefaultDeviceId(
available: Map<string, AudioOutputDeviceLabel>,
): string | undefined {
this.logger.debug(
`Android routing logic intent: ${this.initialIntent} finding best default...`,
);
if (this.initialIntent === "audio") {
const systemProposed = available.keys().next().value;
// If no headset is connected, android will route to the speaker by default,
// but for a voice call we want to route to the earpiece instead,
// so override the system proposed routing in that case.
if (systemProposed && available.get(systemProposed)?.type === "speaker") {
// search for the headset
const headsetEntry = Array.from(available.entries()).find(
([_, label]) => label.type === "earpiece",
);
if (headsetEntry) {
this.logger.debug(
`Android routing: Switch to earpiece instead of speaker for voice call`,
);
return headsetEntry[0];
} else {
this.logger.debug(
`Android routing: no earpiece found, cannot switch, use system proposed routing`,
);
return systemProposed;
}
} else {
this.logger.debug(`Android routing: Use system proposed routing`);
return systemProposed;
}
} else {
// Use the system best proposed best routing.
return available.keys().next().value;
}
}
}
// Utilities
function mapDeviceToLabel(device: OutputDevice): AudioOutputDeviceLabel {
const { name, isEarpiece, isSpeaker } = device;
if (isEarpiece) return { type: "earpiece" };
else if (isSpeaker) return { type: "speaker" };
else return { type: "name", name };
}