Prevent Element appearing in system media controls (#10995)

* Use WebAudio API to play notification sound

So that it won't appear in system media control.

* Run prettier

* Chosse from mp3 and ogg

* Run prettier

* Use WebAudioAPI everywhere

There's still one remoteAudio. I'm not sure what it does. It seems it's
only used in tests...

* Run prettier

* Eliminate a stupid error

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update setupManualMocks.ts

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* delint

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* mocks

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* mocks

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Simplify

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* covg

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
SuperKenVery 2024-07-05 02:08:06 +08:00 committed by GitHub
parent c61eca8c24
commit e288f61f0a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 255 additions and 164 deletions

View file

@ -28,16 +28,18 @@ import { CallEvent, CallState, CallType, MatrixCall } from "matrix-js-sdk/src/we
import EventEmitter from "events";
import { mocked } from "jest-mock";
import { CallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/callEventHandler";
import fetchMock from "fetch-mock-jest";
import { waitFor } from "@testing-library/react";
import LegacyCallHandler, {
LegacyCallHandlerEvent,
AudioID,
LegacyCallHandlerEvent,
PROTOCOL_PSTN,
PROTOCOL_PSTN_PREFIXED,
PROTOCOL_SIP_NATIVE,
PROTOCOL_SIP_VIRTUAL,
} from "../src/LegacyCallHandler";
import { stubClient, mkStubRoom, untilDispatch } from "./test-utils";
import { mkStubRoom, stubClient, untilDispatch } from "./test-utils";
import { MatrixClientPeg } from "../src/MatrixClientPeg";
import DMRoomMap from "../src/utils/DMRoomMap";
import SdkConfig from "../src/SdkConfig";
@ -49,6 +51,7 @@ import { VoiceBroadcastInfoState, VoiceBroadcastPlayback, VoiceBroadcastRecordin
import { mkVoiceBroadcastInfoStateEvent } from "./voice-broadcast/utils/test-utils";
import { SdkContextClass } from "../src/contexts/SDKContext";
import Modal from "../src/Modal";
import { createAudioContext } from "../src/audio/compat";
jest.mock("../src/Modal");
@ -72,6 +75,11 @@ jest.mock("../src/utils/room/getFunctionalMembers", () => ({
getFunctionalMembers: jest.fn(),
}));
jest.mock("../src/audio/compat", () => ({
...jest.requireActual("../src/audio/compat"),
createAudioContext: jest.fn(),
}));
// The Matrix IDs that the user sees when talking to Alice & Bob
const NATIVE_ALICE = "@alice:example.org";
const NATIVE_BOB = "@bob:example.org";
@ -449,6 +457,20 @@ describe("LegacyCallHandler without third party protocols", () => {
let audioElement: HTMLAudioElement;
let fakeCall: MatrixCall | null;
const mockAudioBufferSourceNode = {
addEventListener: jest.fn(),
connect: jest.fn(),
start: jest.fn(),
stop: jest.fn(),
};
const mockAudioContext = {
decodeAudioData: jest.fn().mockResolvedValue({}),
suspend: jest.fn(),
resume: jest.fn(),
createBufferSource: jest.fn().mockReturnValue(mockAudioBufferSourceNode),
currentTime: 1337,
};
beforeEach(() => {
stubClient();
fakeCall = null;
@ -464,6 +486,7 @@ describe("LegacyCallHandler without third party protocols", () => {
throw new Error("Endpoint unsupported.");
};
mocked(createAudioContext).mockReturnValue(mockAudioContext as unknown as AudioContext);
callHandler = new LegacyCallHandler();
callHandler.start();
@ -506,6 +529,12 @@ describe("LegacyCallHandler without third party protocols", () => {
SdkContextClass.instance.voiceBroadcastPlaybacksStore.clearCurrent();
SdkContextClass.instance.voiceBroadcastRecordingsStore.clearCurrent();
fetchMock.get(
"/media/ring.mp3",
{ body: new Blob(["1", "2", "3", "4"], { type: "audio/mpeg" }) },
{ sendAsJson: false },
);
});
afterEach(() => {
@ -520,6 +549,20 @@ describe("LegacyCallHandler without third party protocols", () => {
SdkConfig.reset();
});
it("should cache sounds between playbacks", async () => {
await callHandler.play(AudioID.Ring);
expect(mockAudioBufferSourceNode.start).toHaveBeenCalled();
expect(fetchMock.calls("/media/ring.mp3")).toHaveLength(1);
await callHandler.play(AudioID.Ring);
expect(fetchMock.calls("/media/ring.mp3")).toHaveLength(1);
});
it("should allow silencing an incoming call ring", async () => {
await callHandler.play(AudioID.Ring);
await callHandler.silenceCall("call123");
expect(mockAudioBufferSourceNode.stop).toHaveBeenCalled();
});
it("should still start a native call", async () => {
callHandler.placeCall(NATIVE_ROOM_ALICE, CallType.Voice);
@ -537,13 +580,6 @@ describe("LegacyCallHandler without third party protocols", () => {
describe("incoming calls", () => {
const roomId = "test-room-id";
const mockAudioElement = {
play: jest.fn(),
pause: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
muted: false,
} as unknown as HTMLMediaElement;
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => setting === UIFeature.Voip);
@ -571,8 +607,6 @@ describe("LegacyCallHandler without third party protocols", () => {
},
};
jest.spyOn(document, "getElementById").mockReturnValue(mockAudioElement);
// silence local notifications by default
jest.spyOn(MatrixClientPeg.safeGet(), "getAccountData").mockImplementation((eventType) => {
if (eventType.includes(LOCAL_NOTIFICATION_SETTINGS_PREFIX.name)) {
@ -586,19 +620,6 @@ describe("LegacyCallHandler without third party protocols", () => {
});
});
it("should unmute <audio> before playing", () => {
// Test setup: set the audio element as muted
mockAudioElement.muted = true;
expect(mockAudioElement.muted).toStrictEqual(true);
callHandler.play(AudioID.Ring);
// Ensure audio is no longer muted
expect(mockAudioElement.muted).toStrictEqual(false);
// Ensure the audio was played
expect(mockAudioElement.play).toHaveBeenCalled();
});
it("listens for incoming call events when voip is enabled", () => {
const call = new MatrixCall({
client: MatrixClientPeg.safeGet(),
@ -612,7 +633,7 @@ describe("LegacyCallHandler without third party protocols", () => {
expect(callHandler.getCallForRoom(roomId)).toEqual(call);
});
it("rings when incoming call state is ringing and notifications set to ring", () => {
it("rings when incoming call state is ringing and notifications set to ring", async () => {
// remove local notification silencing mock for this test
jest.spyOn(MatrixClientPeg.safeGet(), "getAccountData").mockReturnValue(undefined);
const call = new MatrixCall({
@ -627,8 +648,8 @@ describe("LegacyCallHandler without third party protocols", () => {
expect(callHandler.getCallForRoom(roomId)).toEqual(call);
call.emit(CallEvent.State, CallState.Ringing, CallState.Connected, fakeCall!);
// ringer audio element started
expect(mockAudioElement.play).toHaveBeenCalled();
// ringer audio started
await waitFor(() => expect(mockAudioBufferSourceNode.start).toHaveBeenCalled());
});
it("does not ring when incoming call state is ringing but local notifications are silenced", () => {
@ -645,7 +666,7 @@ describe("LegacyCallHandler without third party protocols", () => {
call.emit(CallEvent.State, CallState.Ringing, CallState.Connected, fakeCall!);
// ringer audio element started
expect(mockAudioElement.play).not.toHaveBeenCalled();
expect(mockAudioBufferSourceNode.start).not.toHaveBeenCalled();
expect(callHandler.isCallSilenced(call.callId)).toEqual(true);
});
@ -679,7 +700,7 @@ describe("LegacyCallHandler without third party protocols", () => {
// call still silenced
expect(callHandler.isCallSilenced(call.callId)).toEqual(true);
// ringer not played
expect(mockAudioElement.play).not.toHaveBeenCalled();
expect(mockAudioBufferSourceNode.start).not.toHaveBeenCalled();
});
});
});

View file

@ -60,6 +60,11 @@ jest.mock("../src/utils/notifications", () => ({
createLocalNotificationSettingsIfNeeded: jest.fn(),
}));
jest.mock("../src/audio/compat", () => ({
...jest.requireActual("../src/audio/compat"),
createAudioContext: jest.fn(),
}));
describe("Notifier", () => {
const roomId = "!room1:server";
const testEvent = mkEvent({
@ -103,6 +108,19 @@ describe("Notifier", () => {
});
};
const mockAudioBufferSourceNode = {
addEventListener: jest.fn(),
connect: jest.fn(),
start: jest.fn(),
};
const mockAudioContext = {
decodeAudioData: jest.fn(),
suspend: jest.fn(),
resume: jest.fn(),
createBufferSource: jest.fn().mockReturnValue(mockAudioBufferSourceNode),
currentTime: 1337,
};
beforeEach(() => {
accountDataStore = {};
mockClient = getMockClientWithEventEmitter({
@ -144,6 +162,9 @@ describe("Notifier", () => {
if (id) return new Room(id, mockClient, mockClient.getSafeUserId());
return null;
});
// @ts-ignore
Notifier.backgroundAudio.audioContext = mockAudioContext;
});
describe("triggering notification from events", () => {

35
test/setup/mocks.ts Normal file
View file

@ -0,0 +1,35 @@
/*
Copyright 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export const mocks = {
AudioBufferSourceNode: {
connect: jest.fn(),
start: jest.fn(),
} as unknown as AudioBufferSourceNode,
AudioContext: {
close: jest.fn(),
createMediaElementSource: jest.fn(),
createMediaStreamDestination: jest.fn(),
createMediaStreamSource: jest.fn(),
createStreamTrackSource: jest.fn(),
createBufferSource: jest.fn((): AudioBufferSourceNode => ({ ...mocks.AudioBufferSourceNode })),
getOutputTimestamp: jest.fn(),
resume: jest.fn(),
setSinkId: jest.fn(),
suspend: jest.fn(),
decodeAudioData: jest.fn(),
},
};

View file

@ -18,6 +18,8 @@ import fetchMock from "fetch-mock-jest";
import { TextDecoder, TextEncoder } from "util";
import { Response } from "node-fetch";
import { mocks } from "./mocks";
// Stub ResizeObserver
// @ts-ignore - we know it's a duplicate (that's why we're stubbing it)
class ResizeObserver {
@ -76,6 +78,7 @@ global.TextDecoder = TextDecoder;
// prevent errors whenever a component tries to manually scroll.
window.HTMLElement.prototype.scrollIntoView = jest.fn();
window.HTMLAudioElement.prototype.canPlayType = jest.fn((format) => (format === "audio/mpeg" ? "probably" : ""));
// set up fetch API mock
fetchMock.config.overwriteRoutes = false;
@ -87,3 +90,6 @@ window.fetch = fetchMock.sandbox();
// @ts-ignore
window.Response = Response;
// set up AudioContext API mock
global.AudioContext = jest.fn().mockImplementation(() => ({ ...mocks.AudioContext }));

View file

@ -30,6 +30,7 @@ import {
SyncState,
} from "matrix-js-sdk/src/matrix";
import { EncryptedFile } from "matrix-js-sdk/src/types";
import fetchMock from "fetch-mock-jest";
import { uploadFile } from "../../../src/ContentMessages";
import { createVoiceMessageContent } from "../../../src/utils/createVoiceMessageContent";
@ -49,6 +50,7 @@ import {
import { mkEvent, mkStubRoom, stubClient } from "../../test-utils";
import dis from "../../../src/dispatcher/dispatcher";
import { VoiceRecording } from "../../../src/audio/VoiceRecording";
import { createAudioContext } from "../../../src/audio/compat";
jest.mock("../../../src/voice-broadcast/audio/VoiceBroadcastRecorder", () => ({
...(jest.requireActual("../../../src/voice-broadcast/audio/VoiceBroadcastRecorder") as object),
@ -79,6 +81,11 @@ jest.mock("../../../src/utils/createVoiceMessageContent", () => ({
createVoiceMessageContent: jest.fn(),
}));
jest.mock("../../../src/audio/compat", () => ({
...jest.requireActual("../../../src/audio/compat"),
createAudioContext: jest.fn(),
}));
describe("VoiceBroadcastRecording", () => {
const roomId = "!room:example.com";
const uploadedUrl = "mxc://example.com/vb";
@ -198,6 +205,19 @@ describe("VoiceBroadcastRecording", () => {
});
};
const mockAudioBufferSourceNode = {
addEventListener: jest.fn(),
connect: jest.fn(),
start: jest.fn(),
};
const mockAudioContext = {
decodeAudioData: jest.fn(),
suspend: jest.fn(),
resume: jest.fn(),
createBufferSource: jest.fn().mockReturnValue(mockAudioBufferSourceNode),
currentTime: 1337,
};
beforeEach(() => {
client = stubClient();
room = mkStubRoom(roomId, "Test Room", client);
@ -265,6 +285,8 @@ describe("VoiceBroadcastRecording", () => {
return null;
});
mocked(createAudioContext).mockReturnValue(mockAudioContext as unknown as AudioContext);
});
afterEach(() => {
@ -546,12 +568,13 @@ describe("VoiceBroadcastRecording", () => {
beforeEach(() => {
mocked(client.sendMessage).mockRejectedValue("Error");
emitFirsChunkRecorded();
fetchMock.get("media/error.mp3", 200);
});
itShouldBeInState("connection_error");
it("should play a notification", () => {
expect(audioElement.play).toHaveBeenCalled();
expect(mockAudioBufferSourceNode.start).toHaveBeenCalled();
});
describe("and the connection is back", () => {