Merge pull request #4064 from element-hq/ios/default_voice_call_to_earpiece

fix(voice): Default to earpiece for voice only call
This commit is contained in:
Valere Fedronic
2026-06-25 16:32:59 +02:00
committed by GitHub
4 changed files with 203 additions and 3 deletions

View File

@@ -0,0 +1,162 @@
/*
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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { type Observable, of } from "rxjs";
import { ObservableScope } from "./ObservableScope";
import { constant } from "./Behavior";
import { type SelectedAudioOutputDevice } from "./MediaDevices";
import {
availableOutputDevices$,
type Controls,
type OutputDevice,
outputDevice$,
} from "../controls";
import {
EARPIECE_CONFIG_ID,
IOSControlledAudioOutput,
} from "./IOSControlledAudioOutput";
// `vi.mock` calls are hoisted above all imports, so the static imports below
// already see these mocks. Force the iOS platform so that the virtual earpiece
// is available, and stub the livekit device observer (only subscribed for its
// side effects).
vi.mock("../Platform", () => ({ platform: "ios" }));
vi.mock("@livekit/components-core", () => ({
createMediaDeviceObserver: (): Observable<MediaDeviceInfo[]> => of([]),
}));
// On iOS the host reports a single device for the current route. When output is
// on the loudspeaker it is flagged `forEarpiece`, which makes the controller
// expose a virtual earpiece device.
const SPEAKER: OutputDevice = {
id: "speaker",
name: "Speaker",
isSpeaker: true,
forEarpiece: true,
};
// A connected headset (e.g. Bluetooth) is reported as a plain named device,
// with neither the speaker nor earpiece flag set.
const HEADSET: OutputDevice = {
id: "bt",
name: "AirPods",
};
let testScope: ObservableScope;
beforeEach(() => {
testScope = new ObservableScope();
window.controls = {
onAudioDeviceSelect: vi.fn(),
onOutputDeviceSelect: vi.fn(),
} as unknown as Controls;
});
afterEach(() => {
testScope.end();
});
/**
* Subscribe to the controller's `selected$` and return a getter for the latest
* emitted value.
*/
function latestSelection(
output: InstanceType<typeof IOSControlledAudioOutput>,
): () => SelectedAudioOutputDevice | undefined {
let latest: SelectedAudioOutputDevice | undefined;
output.selected$.subscribe((s) => {
latest = s;
});
return () => latest;
}
describe("Default selection", () => {
it("defaults to the earpiece for voice (audio) calls", () => {
const output = new IOSControlledAudioOutput(
constant(false),
testScope,
"audio",
);
const selected = latestSelection(output);
availableOutputDevices$.next([SPEAKER]);
expect(selected()).toEqual({
id: EARPIECE_CONFIG_ID,
virtualEarpiece: true,
});
expect(window.controls.onAudioDeviceSelect).toHaveBeenLastCalledWith(
EARPIECE_CONFIG_ID,
);
});
it("defaults to the speaker for video calls", () => {
const output = new IOSControlledAudioOutput(
constant(false),
testScope,
"video",
);
const selected = latestSelection(output);
availableOutputDevices$.next([SPEAKER]);
expect(selected()).toEqual({ id: SPEAKER.id, virtualEarpiece: false });
});
it("keeps a headset for voice calls instead of forcing the earpiece", () => {
const output = new IOSControlledAudioOutput(
constant(false),
testScope,
"audio",
);
const selected = latestSelection(output);
// The host proposes the headset as the route (listed first), even though a
// forEarpiece device is also present so the virtual earpiece exists.
availableOutputDevices$.next([HEADSET, SPEAKER]);
expect(selected()).toEqual({ id: HEADSET.id, virtualEarpiece: false });
});
});
describe("Explicit selection", () => {
it("an explicit user selection overrides the earpiece default", () => {
const output = new IOSControlledAudioOutput(
constant(false),
testScope,
"audio",
);
const selected = latestSelection(output);
availableOutputDevices$.next([SPEAKER]);
// Earpiece by default for a voice call...
expect(selected()).toEqual({
id: EARPIECE_CONFIG_ID,
virtualEarpiece: true,
});
// ...until the user explicitly picks the speaker.
output.select(SPEAKER.id);
expect(selected()).toEqual({ id: SPEAKER.id, virtualEarpiece: false });
});
it("a host selection overrides the earpiece default", () => {
const output = new IOSControlledAudioOutput(
constant(false),
testScope,
"audio",
);
const selected = latestSelection(output);
availableOutputDevices$.next([SPEAKER]);
outputDevice$.next(SPEAKER.id);
expect(selected()).toEqual({ id: SPEAKER.id, virtualEarpiece: false });
});
});

View File

@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { combineLatest, merge, startWith, Subject, tap } from "rxjs";
import type { RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
import {
availableOutputDevices$ as controlledAvailableOutputDevices$,
outputDevice$ as controlledOutputSelection$,
@@ -24,7 +25,7 @@ import {
// This hardcoded id is used in EX ios! It can only be changed in coordination with
// the ios swift team.
const EARPIECE_CONFIG_ID = "earpiece-id";
export const EARPIECE_CONFIG_ID = "earpiece-id";
/**
* A special implementation of audio output that allows the hosting application
@@ -94,7 +95,7 @@ export class IOSControlledAudioOutput implements MediaDevice<
),
],
(available, preferredId) => {
const id = preferredId ?? available.keys().next().value;
const id = preferredId ?? this.chooseDefaultId(available);
return id === undefined
? undefined
: { id, virtualEarpiece: id === EARPIECE_CONFIG_ID };
@@ -106,9 +107,41 @@ export class IOSControlledAudioOutput implements MediaDevice<
),
);
/**
* Chooses the default output device when no explicit selection (from the user
* or the hosting application) has been made yet.
*
* For voice calls (`initialIntent === "audio"`) we want to start on the
* earpiece rather than the speaker, like a regular phone call. We only
* override when the device that would otherwise be the default is the
* speaker: if the host already routed to a headset (e.g. Bluetooth) — which
* is reported as a plain named device, not "speaker"/"earpiece" — we keep it.
* This mirrors the Android behaviour in {@link AndroidControlledAudioOutput}.
*/
private chooseDefaultId(
available: Map<string, AudioOutputDeviceLabel>,
): string | undefined {
const firstId = available.keys().next().value;
if (this.initialIntent === "audio") {
const firstLabel =
firstId !== undefined ? available.get(firstId) : undefined;
if (firstLabel?.type === "speaker") {
for (const [id, label] of available)
if (label.type === "earpiece") {
this.logger.info(
`IOS routing: default to earpiece ${id} instead of speaker for voice call`,
);
return id;
}
}
}
return firstId;
}
public constructor(
private readonly usingNames$: Behavior<boolean>,
private readonly scope: ObservableScope,
private readonly initialIntent: RTCCallIntent | undefined = undefined,
) {
this.selected$.subscribe((device) => {
// Let the hosting application know which output device has been selected.

View File

@@ -376,7 +376,11 @@ export class MediaDevices {
getUrlParams().callIntent,
window.controls,
)
: new IOSControlledAudioOutput(this.usingNames$, this.scope)
: new IOSControlledAudioOutput(
this.usingNames$,
this.scope,
getUrlParams().callIntent,
)
: new AudioOutput(this.usingNames$, this.scope);
public readonly videoInput: MediaDevice<DeviceLabel, SelectedDevice> =