Bug 1958963 Expose all speaker devices, irrespective of groupId, upon microphone access. r=karlt

Relaxes exposure criteria for speakers in the navigator.mediaDevices.enumerateDevices() API,
to match the recent spec change in w3c/mediacapture-output#150 and other implementations for
web compatibility.

The old behavior was to only expose speakers whose groupId is the same as that of any microphone,
once the document is using a microphone.

The new behavior is to expose all speakers once the document is using a microphone.

The old behavior is retained for RFPTarget::MediaDevices, to avoid any new exposure there.

Also fixes an assumption in WPT test
MediaDevices-enumerateDevices-per-origin-ids.sub.https.html broken by this patch
about the total number of devices exposed after gUM in an iframe vs the top frame.

Differential Revision: https://phabricator.services.mozilla.com/D246001
This commit is contained in:
Jan-Ivar Bruaroey
2025-04-29 17:54:12 +00:00
parent af4c9168f2
commit d61ba41f34
10 changed files with 96 additions and 36 deletions

View File

@@ -264,7 +264,8 @@ RefPtr<MediaDeviceSetRefCnt> MediaDevices::FilterExposedDevices(
bool dropSpeakers =
!Preferences::GetBool("media.setsinkid.enabled") ||
!FeaturePolicyUtils::IsFeatureAllowed(doc, u"speaker-selection"_ns);
bool shouldResistFingerprinting =
window->AsGlobal()->ShouldResistFingerprinting(RFPTarget::MediaDevices);
bool legacy = IsLegacyMode(window);
bool outputIsDefault = true; // First output is the default.
bool haveDefaultOutput = false;
@@ -293,8 +294,10 @@ RefPtr<MediaDeviceSetRefCnt> MediaDevices::FilterExposedDevices(
case MediaDeviceKind::Audiooutput:
if (dropSpeakers ||
(!mExplicitlyGrantedAudioOutputRawIds.Contains(device->mRawID) &&
// Assumes aDevices order has microphones before speakers.
!exposedMicrophoneGroupIds.Contains(device->mRawGroupID))) {
(!mCanExposeMicrophoneInfo ||
(shouldResistFingerprinting &&
// Assumes aDevices order has microphones before speakers.
!exposedMicrophoneGroupIds.Contains(device->mRawGroupID))))) {
outputIsDefault = false;
continue;
}

View File

@@ -161,5 +161,6 @@ async function testLegacyEnumerateDevices() {
["media.video_loopback_dev", "none"]
);
devices = await navigator.mediaDevices.enumerateDevices();
devices = devices.filter(({ kind }) => kind != "audiooutput");
is(devices.length, 0, "No devices");
}

View File

@@ -133,6 +133,7 @@ runTest(async () => {
["media.audio_loopback_dev", "none"],
["media.video_loopback_dev", "none"]);
devices = await navigator.mediaDevices.enumerateDevices();
devices = devices.filter(({kind}) => kind != "audiooutput");
is(devices.length, 0, "No devices");
});
</script>

View File

@@ -8,16 +8,20 @@
<script>
/* global SimpleTest SpecialPowers */
async function testEnumerateDevices(expectDevices) {
let devices = await navigator.mediaDevices.enumerateDevices();
if (!expectDevices) {
SimpleTest.is(devices.length, 0, "testEnumerateDevices: No devices");
return;
async function testEnumerateDevices(header,
expectedCameras,
expectedMicrophones,
expectedSpeakers) {
const devices = await navigator.mediaDevices.enumerateDevices();
const cams = devices.filter(({kind}) => kind == "videoinput");
const mics = devices.filter(({kind}) => kind == "audioinput");
const spkr = devices.filter(({kind}) => kind == "audiooutput");
SimpleTest.is(cams.length, expectedCameras, `${header}: cameras`);
SimpleTest.is(mics.length, expectedMicrophones, `${header}: microphones`);
if (expectedSpeakers !== undefined) {
SimpleTest.is(spkr.length, expectedSpeakers, `${header}: speakers`);
}
let cams = devices.filter((device) => device.kind == "videoinput");
let mics = devices.filter((device) => device.kind == "audioinput");
SimpleTest.ok((cams.length == 1) && (mics.length == 1),
"testEnumerateDevices: a microphone and a camera");
}
async function testGetUserMedia(expectDevices) {
@@ -63,14 +67,14 @@ async function testGetUserMedia(expectDevices) {
}
}
async function testDevices() {
async function testPreGum() {
await SpecialPowers.pushPrefEnv({
set: [
["privacy.resistFingerprinting", true],
["media.navigator.streams.fake", true]
]
});
await testEnumerateDevices(true); // should list a microphone and a camera
await testEnumerateDevices("testPreGum w/resist", 1, 1, 0);
await testGetUserMedia(true); // should get audio and video streams
}
@@ -84,13 +88,13 @@ async function testNoDevices() {
["media.video_loopback_dev", "bar"]
]
});
await testEnumerateDevices(false); // should list nothing
await testEnumerateDevices("testNoDevices wo/resist", 0, 0);
await SpecialPowers.pushPrefEnv({
set: [
["privacy.resistFingerprinting", true]
]
});
await testEnumerateDevices(true); // should list a microphone and a camera
await testEnumerateDevices("testNoDevices w/resist", 1, 1, 1);
await testGetUserMedia(false); // should reject with NotAllowedError
}
@@ -102,7 +106,7 @@ createHTML({
runTest(async () => {
// Make sure enumerateDevices and getUserMedia work when
// privacy.resistFingerprinting is true.
await testDevices();
await testPreGum();
// Test that absence of devices can't be detected.
await testNoDevices();

View File

@@ -10,7 +10,6 @@ createHTML({ title: "Test group id of MediaDeviceInfo", bug: "1213453" });
async function getDefaultDevices() {
const devices = await navigator.mediaDevices.enumerateDevices();
is(devices.length, 2, "Two fake devices found.");
devices.forEach(d => isnot(d.groupId, "", "GroupId is included in every device"));

View File

@@ -1,4 +1,29 @@
[audiocontext-sinkid-constructor.https.html]
expected:
if (os == "android") and fission: [ERROR, TIMEOUT]
if (os == "mac"): [ERROR, OK]
ERROR
[Setting sinkId to the empty string at construction should succeed.]
bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1942786
expected: FAIL
[Setting sinkId with a valid device identifier at construction should succeed.]
bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1942786
expected: FAIL
[Setting sinkId with an AudioSinkOptions at construction should succeed.]
bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1942786
expected: FAIL
[Invalid sinkId argument with a wrong type should throw an appropriate exception.]
bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1942786
expected: FAIL
[Invalid sinkId argument with a wrong ID should dispatch an onerror event.]
bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1942786
expected: FAIL
[Invalid sinkId argument with a wrong type should throw an appropriate exception.]
bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1942786
expected: FAIL

View File

@@ -1,27 +1,53 @@
[audiocontext-sinkid-setsinkid.https.html]
expected:
if (os == "android") and fission: [ERROR, TIMEOUT]
if (os == "mac"): [ERROR, OK]
ERROR
[setSinkId() with a valid device identifier should succeeded.]
expected: NOTRUN
bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1942786
expected:
if (os == "mac"): [NOTRUN, FAIL]
NOTRUN
[setSinkId() with the same sink ID should resolve immediately.]
expected: NOTRUN
bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1942786
expected:
if (os == "mac"): [NOTRUN, FAIL]
NOTRUN
[setSinkId() with the same AudioSinkOptions.type value should resolve immediately.]
expected: NOTRUN
bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1942786
expected:
if (os == "mac"): [NOTRUN, FAIL]
NOTRUN
[setSinkId() should fail with TypeError on an invalid AudioSinkOptions.type value.]
expected: NOTRUN
bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1942786
expected:
if (os == "mac"): [NOTRUN, FAIL]
NOTRUN
[setSinkId() should fail with NotFoundError on an invalid device identifier.]
expected: NOTRUN
bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1942786
expected:
if (os == "mac"): [NOTRUN, FAIL]
NOTRUN
[setSinkId() should fail with InvalidStateError when calling from astopped AudioContext]
expected: NOTRUN
bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1942786
expected:
if (os == "mac"): [NOTRUN, FAIL]
NOTRUN
[setSinkId() should fail with InvalidStateError when calling from adetached document]
expected: NOTRUN
bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1942786
expected:
if (os == "mac"): [NOTRUN, FAIL]
NOTRUN
[pending setSinkId() should be rejected with InvalidStateError whenAudioContext is closed]
expected: NOTRUN
bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1942786
expected:
if (os == "mac"): [NOTRUN, FAIL]
NOTRUN

View File

@@ -1,9 +1,16 @@
[audiocontext-sinkid-state-change.https.html]
expected:
if (os == "android") and fission: [ERROR, TIMEOUT]
if (os == "mac"): [ERROR, OK]
ERROR
[Calling setSinkId() on a suspended AudioContext should fire only sink change events.]
expected: NOTRUN
bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1942786
expected:
if (os == "mac"): [NOTRUN, FAIL]
NOTRUN
[Calling setSinkId() on a running AudioContext should fire both state and sink change events.]
expected: NOTRUN
bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1942786
expected:
if (os == "mac"): [NOTRUN, TIMEOUT]
NOTRUN

View File

@@ -26,14 +26,8 @@ promise_test(async t => {
const list = await navigator.mediaDevices.enumerateDevices();
assert_greater_than(list.length, 0,
"media device list includes at least one device");
const audioInputList = list.filter(({kind}) => kind == "audioinput");
const outputDevicesList = list.filter(({kind}) => kind == "audiooutput");
// List of exposed microphone groupIds
const exposedGroupIds = new Set(audioInputList.map(device => device.groupId));
for (const { deviceId, groupId } of outputDevicesList) {
assert_true(exposedGroupIds.has(groupId),
"audiooutput device groupId must match an exposed microphone");
assert_greater_than(deviceId.length, 0, "deviceId.length");
const p1 = audio.setSinkId(deviceId);

View File

@@ -9,8 +9,8 @@
<script src=permission-helper.js></script>
</head>
<body>
<iframe allow="camera 'src';microphone 'src'" id=same src="/mediacapture-streams/iframe-enumerate.html"></iframe>
<iframe allow="camera 'src';microphone 'src'" id=cross src="https://{{hosts[][www1]}}:{{ports[https][0]}}/mediacapture-streams/iframe-enumerate.html"></iframe>
<iframe allow="camera 'src';microphone 'src';speaker-selection 'src'" id=same src="/mediacapture-streams/iframe-enumerate.html"></iframe>
<iframe allow="camera 'src';microphone 'src';speaker-selection 'src'" id=cross src="https://{{hosts[][www1]}}:{{ports[https][0]}}/mediacapture-streams/iframe-enumerate.html"></iframe>
<script>
let deviceList;