diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 40a69a57938..b90aa136417 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -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:^", diff --git a/apps/webapp/src/script/repositories/calling/CallingRepository.test.ts b/apps/webapp/src/script/repositories/calling/CallingRepository.test.ts index 65b6a50d3ff..aa1a50d3fe7 100644 --- a/apps/webapp/src/script/repositories/calling/CallingRepository.test.ts +++ b/apps/webapp/src/script/repositories/calling/CallingRepository.test.ts @@ -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'; @@ -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(); @@ -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(); diff --git a/apps/webapp/src/script/repositories/calling/CallingRepository.ts b/apps/webapp/src/script/repositories/calling/CallingRepository.ts index 3308b1e0b07..bb745a6f79f 100644 --- a/apps/webapp/src/script/repositories/calling/CallingRepository.ts +++ b/apps/webapp/src/script/repositories/calling/CallingRepository.ts @@ -32,6 +32,7 @@ import axios from 'axios'; import ko from 'knockout'; import {container} from 'tsyringe'; import 'webrtc-adapter'; +import {z} from 'zod'; import { AUDIO_STATE, @@ -59,6 +60,7 @@ import {useCallAlertState} from 'Components/calling/useCallAlertState'; 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'; @@ -619,8 +621,25 @@ export class CallingRepository { 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; + const call = this.findCall(this.parseQualifiedId(conversationId)); if (!call) { return; @@ -637,7 +656,7 @@ export class CallingRepository { 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); @@ -668,6 +687,12 @@ export class CallingRepository { ); break; } + case QUALITY.RECONNECTING: { + this.logger.warn( + `Reconnecting call with user "${userId}" and client "${clientId}" in conversation "${conversationId}".`, + ); + break; + } } }; diff --git a/apps/webapp/src/script/repositories/calling/calling.schema.ts b/apps/webapp/src/script/repositories/calling/calling.schema.ts new file mode 100644 index 00000000000..bb19a021fee --- /dev/null +++ b/apps/webapp/src/script/repositories/calling/calling.schema.ts @@ -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; diff --git a/yarn.lock b/yarn.lock index 29d1e3ac782..bc79c63d814 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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 @@ -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:^"