From dc5b541b21c55f8598d0f00caa4cebda4c8e9b0c Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 25 Jun 2026 13:03:12 +0200 Subject: [PATCH 1/2] fix(voice): Default to earpiece for voice only call --- src/state/IOSControlledAudioOutput.test.ts | 162 +++++++++++++++++++++ src/state/IOSControlledAudioOutput.ts | 37 ++++- src/state/MediaDevices.ts | 6 +- 3 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 src/state/IOSControlledAudioOutput.test.ts diff --git a/src/state/IOSControlledAudioOutput.test.ts b/src/state/IOSControlledAudioOutput.test.ts new file mode 100644 index 000000000..a1e53c5d6 --- /dev/null +++ b/src/state/IOSControlledAudioOutput.test.ts @@ -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 => 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, +): () => 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 }); + }); +}); diff --git a/src/state/IOSControlledAudioOutput.ts b/src/state/IOSControlledAudioOutput.ts index 10d9199c4..c366c4fef 100644 --- a/src/state/IOSControlledAudioOutput.ts +++ b/src/state/IOSControlledAudioOutput.ts @@ -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 | 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, 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. diff --git a/src/state/MediaDevices.ts b/src/state/MediaDevices.ts index 04006b57e..70a676cf5 100644 --- a/src/state/MediaDevices.ts +++ b/src/state/MediaDevices.ts @@ -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 = From 41d0933d4d5af184b533ce5c535b2996d272c50e Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 25 Jun 2026 15:55:08 +0200 Subject: [PATCH 2/2] playwright: New toast --- playwright/widget/test-helpers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/playwright/widget/test-helpers.ts b/playwright/widget/test-helpers.ts index 512399ca1..023c9f2b4 100644 --- a/playwright/widget/test-helpers.ts +++ b/playwright/widget/test-helpers.ts @@ -160,6 +160,7 @@ export class TestHelpers { const expectedToasts = [ { title: "Failed to load service worker", button: "OK" }, { title: "Back up your chats", button: "Dismiss" }, + { title: "Turn on key storage", button: "Dismiss" }, { title: "Element does not support this browser", button: "Dismiss" }, ];