Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"@sindresorhus/is": "4.6.0",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.24",
"@wireapp/avs": "10.2.27",
"@wireapp/avs": "10.3.19",
"@wireapp/avs-debugger": "0.0.7",
"@wireapp/config": "workspace:^",
"@wireapp/core": "workspace:^",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import 'jsdom-worker';
import {Subscription} from 'knockout';
import {container} from 'tsyringe';

import {CALL_TYPE, CONV_TYPE, REASON, STATE as CALL_STATE, VIDEO_STATE, Wcall} from '@wireapp/avs';
import {CALL_TYPE, CONV_TYPE, QUALITY, REASON, STATE as CALL_STATE, VIDEO_STATE, Wcall} from '@wireapp/avs';
import {Runtime} from '@wireapp/commons';
import {WebAppEvents} from '@wireapp/webapp-events';

Expand All @@ -49,6 +49,8 @@ import {Participant} from './Participant';

import {buildMediaDevicesHandler, createConversation, createSelfParticipant} from '../../auth/util/test/TestUtil';
import {Core} from '../../service/CoreSingleton';
import {Warnings} from '../../view_model/WarningsContainer';
import {z} from 'zod';

describe('CallingRepository', () => {
const testFactory = new TestFactory();
Expand Down Expand Up @@ -468,6 +470,134 @@ describe('CallingRepository', () => {
});
});

describe('updateCallQuality', () => {
const conversationId = 'conversation-id';
const userId = 'user-id';
const remoteClientId = 'client-id';

const qualityInfo = (quality: QUALITY) =>
JSON.stringify({
quality,
rtt: 80,
loss: {tx: 1, rx: 2},
jitter: {
audio: {tx: 3, rx: 4},
video: {tx: 5, rx: 6},
},
connection: {
candidate: 'Relay',
protocol: 'UDP',
},
peer: 'User',
});

beforeEach(() => {
const conversation = createConversation();
conversation.id = conversationId;

const selfParticipant = createSelfParticipant();

const user = new User(userId);

const remoteParticipant = new Participant(user, remoteClientId);

const call = new Call(
callingRepository['selfUser']!.qualifiedId,
conversation,
CONV_TYPE.CONFERENCE,
selfParticipant,
CALL_TYPE.NORMAL,
buildMediaDevicesHandler(),
);

call.participants.push(remoteParticipant);

callingRepository['conversationState'].conversations.push(conversation);
callingRepository['callState'].calls([call]);

spyOn(Warnings, 'showWarning');
spyOn(Warnings, 'hideWarning');
});

it('shows poor call quality warning when parsed quality is medium', () => {
callingRepository['updateCallQuality'](conversationId, userId, remoteClientId, qualityInfo(QUALITY.MEDIUM));

expect(Warnings.showWarning).toHaveBeenCalledWith(Warnings.TYPE.CALL_QUALITY_POOR);
});

it('shows poor call quality warning when parsed quality is poor', () => {
callingRepository['updateCallQuality'](conversationId, userId, remoteClientId, qualityInfo(QUALITY.POOR));

expect(Warnings.showWarning).toHaveBeenCalledWith(Warnings.TYPE.CALL_QUALITY_POOR);
});

it('shows poor call quality warning when parsed quality is network problem', () => {
callingRepository['updateCallQuality'](
conversationId,
userId,
remoteClientId,
qualityInfo(QUALITY.NETWORK_PROBLEM),
);

expect(Warnings.showWarning).toHaveBeenCalledWith(Warnings.TYPE.CALL_QUALITY_POOR);
});

it('shows poor call quality warning when parsed quality is reconnecting', () => {
callingRepository['updateCallQuality'](conversationId, userId, remoteClientId, qualityInfo(QUALITY.RECONNECTING));

expect(Warnings.showWarning).toHaveBeenCalledWith(Warnings.TYPE.CALL_QUALITY_POOR);
});

it('hides poor call quality warning when parsed quality becomes normal', () => {
callingRepository['updateCallQuality'](conversationId, userId, remoteClientId, qualityInfo(QUALITY.POOR));

callingRepository['updateCallQuality'](conversationId, userId, remoteClientId, qualityInfo(QUALITY.NORMAL));

expect(Warnings.hideWarning).toHaveBeenCalledWith(Warnings.TYPE.CALL_QUALITY_POOR);
});

it('logs warning when JSON parsing fails', () => {
spyOn(callingRepository['logger'], 'warn');
const invalidJsonString = '{invalid-json';

callingRepository['updateCallQuality'](conversationId, userId, remoteClientId, invalidJsonString);

expect(callingRepository['logger'].warn).toHaveBeenCalledWith(
'Invalid network quality info JSON',
expect.any(Error),
);
});

it('logs warning when network quality info schema validation fails', () => {
spyOn(callingRepository['logger'], 'warn');

const invalidQualityInfo = JSON.stringify({
quality: 'invalid-quality',
});

callingRepository['updateCallQuality'](conversationId, userId, remoteClientId, invalidQualityInfo);

expect(callingRepository['logger'].warn).toHaveBeenCalledWith(
'Invalid network quality info schema',
expect.any(z.ZodError),
);

expect(Warnings.showWarning).not.toHaveBeenCalled();
expect(Warnings.hideWarning).not.toHaveBeenCalled();
});

it('handles partially missing fields in qualityInfo JSON', () => {
const json = JSON.stringify({
quality: QUALITY.POOR,
// missing jitter, connection, peer etc.
});

callingRepository['updateCallQuality'](conversationId, userId, remoteClientId, json);

expect(Warnings.showWarning).toHaveBeenCalledWith(Warnings.TYPE.CALL_QUALITY_POOR);
});
});

describe('stopMediaSource', () => {
it('releases media streams', () => {
const selfParticipant = createSelfParticipant();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import ko from 'knockout';
import {container} from 'tsyringe';
import 'webrtc-adapter';
import {z} from 'zod';

import {
AUDIO_STATE,
Expand Down Expand Up @@ -59,6 +60,7 @@
import {PrimaryModal} from 'Components/Modals/PrimaryModal';
import {CALL_QUALITY_FEEDBACK_KEY} from 'Components/Modals/QualityFeedbackModal/constants';
import {RatingListLabel} from 'Components/Modals/QualityFeedbackModal/typings';
import {NetworkQualityInfo, NetworkQualityInfoSchema} from 'Repositories/calling/calling.schema';
import {isMLSConversation, MLSConversation} from 'Repositories/conversation/ConversationSelectors';
import {ConversationState} from 'Repositories/conversation/ConversationState';
import {ConversationVerificationState} from 'Repositories/conversation/ConversationVerificationState';
Expand Down Expand Up @@ -494,7 +496,7 @@
try {
wCall.setBackground(this.wUser, 0);
} catch (e: unknown) {
this.logger.warn(`Informed AVS about background mode failed. ${e}`);

Check warning on line 499 in apps/webapp/src/script/repositories/calling/CallingRepository.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'e' will use Object's default stringification format ('[object Object]') when stringified.

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-webapp&issues=AZ3yoyPcd4sZi0WKvtgc&open=AZ3yoyPcd4sZi0WKvtgc&pullRequest=21190
}
} else {
this.logger.warn('Skipping AVS background notification because AVS user ID is not initialized.');
Expand Down Expand Up @@ -619,8 +621,25 @@
conversationId: SerializedConversationId,
userId: string,
clientId: string,
quality: number,
qualityInfoJson: string,
) => {
let qualityInfo: NetworkQualityInfo;

try {
const parsedQualityInfo = JSON.parse(qualityInfoJson);
qualityInfo = NetworkQualityInfoSchema.parse(parsedQualityInfo);
} catch (error) {
if (error instanceof z.ZodError) {
this.logger.warn('Invalid network quality info schema', error);
} else {
this.logger.warn('Invalid network quality info JSON', error);
}

return;
}

const {quality} = qualityInfo;
Comment thread
zskhan marked this conversation as resolved.

const call = this.findCall(this.parseQualifiedId(conversationId));
if (!call) {
return;
Expand All @@ -637,7 +656,7 @@
if (!isOldPoorCallQualityUser && quality !== QUALITY.NORMAL) {
users = [...users, userId];
}
if (users.length === call.participants.length - 1) {
if (users.length === call.participants().length - 1) {
Warnings.showWarning(Warnings.TYPE.CALL_QUALITY_POOR);
} else {
Warnings.hideWarning(Warnings.TYPE.CALL_QUALITY_POOR);
Expand Down Expand Up @@ -668,6 +687,12 @@
);
break;
}
case QUALITY.RECONNECTING: {
this.logger.warn(
`Reconnecting call with user "${userId}" and client "${clientId}" in conversation "${conversationId}".`,
);
break;
}
}
};

Expand Down Expand Up @@ -2592,7 +2617,7 @@
camera: boolean,
screen: boolean,
): Promise<MediaStream> => {
if (this.mediaStreamQuery) {

Check warning on line 2620 in apps/webapp/src/script/repositories/calling/CallingRepository.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Expected non-Promise value in a boolean conditional.

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-webapp&issues=AZ3yoyPcd4sZi0WKvtgd&open=AZ3yoyPcd4sZi0WKvtgd&pullRequest=21190
// if a query is already occurring, we will return the result of this query
return this.mediaStreamQuery;
}
Expand Down
68 changes: 68 additions & 0 deletions apps/webapp/src/script/repositories/calling/calling.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Wire
* Copyright (C) 2026 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*
*/

import {z} from 'zod';

import {QUALITY} from '@wireapp/avs';

const stringLiteralUnknown = z.literal('Unknown');

const CandidateSchema = z.union([
z.literal('Relay'),
z.literal('Host'),
z.literal('Srflx'),
z.literal('Prflx'),
stringLiteralUnknown,
]);
const ProtocolSchema = z.union([z.literal('UDP'), z.literal('TCP'), stringLiteralUnknown]);
const PeerSchema = z.union([z.literal('Server'), z.literal('User'), stringLiteralUnknown]);

const QualitySchema = z.union([
z.literal(QUALITY.NORMAL),
z.literal(QUALITY.MEDIUM),
z.literal(QUALITY.POOR),
z.literal(QUALITY.NETWORK_PROBLEM),
z.literal(QUALITY.RECONNECTING),
]);

const NetworkMetricSchema = z.object({
tx: z.number(),
rx: z.number(),
});

export const NetworkQualityInfoSchema = z.object({
quality: QualitySchema,
rtt: z.number().optional(),
loss: NetworkMetricSchema.optional(),
jitter: z
.object({
audio: NetworkMetricSchema,
video: NetworkMetricSchema,
})
.optional(),
connection: z
.object({
candidate: CandidateSchema,
protocol: ProtocolSchema,
})
.optional(),
peer: PeerSchema.optional(),
});

export type NetworkQualityInfo = z.infer<typeof NetworkQualityInfoSchema>;
10 changes: 5 additions & 5 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -9388,10 +9388,10 @@ __metadata:
languageName: node
linkType: hard

"@wireapp/avs@npm:10.2.27":
version: 10.2.27
resolution: "@wireapp/avs@npm:10.2.27"
checksum: 10/3beea97bc8a7f1f5bdf9aaf2cefd4f7b0dfd8820ae9ebbcb19d16b1d04b230e01f61a8b4582abfdfdf5e446f1a98ce322bc89d474cb45a072a0b76e510e531a5
"@wireapp/avs@npm:10.3.19":
version: 10.3.19
resolution: "@wireapp/avs@npm:10.3.19"
checksum: 10/0310f8c6cb7cbb663b36f3eda57e471661a32258b833a35f8cdf41834b73c86f21642f61c2841d2eab966a70506a287d72406f5fcf9619310dd2c22607bc9e19
languageName: node
linkType: hard

Expand Down Expand Up @@ -9752,7 +9752,7 @@ __metadata:
"@types/webpack-bundle-analyzer": "npm:4.7.0"
"@types/webpack-env": "npm:1.18.8"
"@types/wicg-file-system-access": "npm:2023.10.7"
"@wireapp/avs": "npm:10.2.27"
"@wireapp/avs": "npm:10.3.19"
"@wireapp/avs-debugger": "npm:0.0.7"
"@wireapp/config": "workspace:^"
"@wireapp/core": "workspace:^"
Expand Down
Loading