Call Quality Survey

This commit is contained in:
Elaine 2026-01-09 16:28:30 -05:00 committed by GitHub
parent e5974d7e59
commit c9e9f4142c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1060 additions and 126 deletions

View File

@ -1720,12 +1720,14 @@
B95967842D52976A00F9A800 /* NavigationPreviewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B95967832D52976A00F9A800 /* NavigationPreviewController.swift */; };
B95A765C2B76C5BB00AA7E97 /* AvatarViewPresentationContextProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B95A765B2B76C5BB00AA7E97 /* AvatarViewPresentationContextProvider.swift */; };
B95A765E2B76E93500AA7E97 /* FindByUsernameViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B95A765D2B76E93500AA7E97 /* FindByUsernameViewController.swift */; };
B965716E2F0DC91900690CE9 /* CallQualitySurvey.swift in Sources */ = {isa = PBXBuildFile; fileRef = B965716C2F0CAA3600690CE9 /* CallQualitySurvey.swift */; };
B96D6D792B9F83270039EB99 /* SignalSymbols-Regular.otf in Resources */ = {isa = PBXBuildFile; fileRef = B96D6D782B9F83270039EB99 /* SignalSymbols-Regular.otf */; };
B9754F542C73AD49000000E4 /* ConversationAvatarView+SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9754F532C73AD49000000E4 /* ConversationAvatarView+SwiftUI.swift */; };
B97803012DD65B0A00E9FC82 /* LinearProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B97803002DD65B0A00E9FC82 /* LinearProgressView.swift */; };
B982ACFF2BA8FD2A00AD7E81 /* SignalSymbols-Bold.otf in Resources */ = {isa = PBXBuildFile; fileRef = B982ACFE2BA8FD2A00AD7E81 /* SignalSymbols-Bold.otf */; };
B982AD012BA8FD3200AD7E81 /* SignalSymbols-Light.otf in Resources */ = {isa = PBXBuildFile; fileRef = B982AD002BA8FD3100AD7E81 /* SignalSymbols-Light.otf */; };
B985D29A2EE7E58700E3BF5F /* CallQualitySurveyCustomIssueViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B985D2992EE7E58700E3BF5F /* CallQualitySurveyCustomIssueViewController.swift */; };
B985D29E2EEA913000E3BF5F /* CallQualitySurvey.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = B985D29D2EEA913000E3BF5F /* CallQualitySurvey.pb.swift */; };
B9921F882CC6FDB200AB667F /* HeroSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9921F872CC6FDB200AB667F /* HeroSheetViewController.swift */; };
B99287FB2CF0FE8D000D62C4 /* LinkedDevicesEducationSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B99287FA2CF0FE8D000D62C4 /* LinkedDevicesEducationSheet.swift */; };
B99288002CF124AC000D62C4 /* Text+Links.swift in Sources */ = {isa = PBXBuildFile; fileRef = B99287FF2CF124AC000D62C4 /* Text+Links.swift */; };
@ -5909,6 +5911,7 @@
B95A765B2B76C5BB00AA7E97 /* AvatarViewPresentationContextProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarViewPresentationContextProvider.swift; sourceTree = "<group>"; };
B95A765D2B76E93500AA7E97 /* FindByUsernameViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindByUsernameViewController.swift; sourceTree = "<group>"; };
B95BBAC12BB36025009EFB4A /* ProfileName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileName.swift; sourceTree = "<group>"; };
B965716C2F0CAA3600690CE9 /* CallQualitySurvey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallQualitySurvey.swift; sourceTree = "<group>"; };
B96D6D782B9F83270039EB99 /* SignalSymbols-Regular.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "SignalSymbols-Regular.otf"; sourceTree = "<group>"; };
B96FEE2E2CDC297500836191 /* User.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = User.xcconfig; sourceTree = "<group>"; };
B9754F532C73AD49000000E4 /* ConversationAvatarView+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConversationAvatarView+SwiftUI.swift"; sourceTree = "<group>"; };
@ -5916,6 +5919,8 @@
B982ACFE2BA8FD2A00AD7E81 /* SignalSymbols-Bold.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "SignalSymbols-Bold.otf"; sourceTree = "<group>"; };
B982AD002BA8FD3100AD7E81 /* SignalSymbols-Light.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "SignalSymbols-Light.otf"; sourceTree = "<group>"; };
B985D2992EE7E58700E3BF5F /* CallQualitySurveyCustomIssueViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallQualitySurveyCustomIssueViewController.swift; sourceTree = "<group>"; };
B985D29C2EEA8FE700E3BF5F /* CallQualitySurvey.proto */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.protobuf; path = CallQualitySurvey.proto; sourceTree = "<group>"; };
B985D29D2EEA913000E3BF5F /* CallQualitySurvey.pb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallQualitySurvey.pb.swift; sourceTree = "<group>"; };
B9921F872CC6FDB200AB667F /* HeroSheetViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeroSheetViewController.swift; sourceTree = "<group>"; };
B99287FA2CF0FE8D000D62C4 /* LinkedDevicesEducationSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkedDevicesEducationSheet.swift; sourceTree = "<group>"; };
B99287FF2CF124AC000D62C4 /* Text+Links.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Text+Links.swift"; sourceTree = "<group>"; };
@ -13636,6 +13641,7 @@
D9D321792A8FEA7A004FC110 /* Specifications */ = {
isa = PBXGroup;
children = (
B985D29C2EEA8FE700E3BF5F /* CallQualitySurvey.proto */,
D9D3217C2A8FEA9B004FC110 /* DeviceTransfer.proto */,
D9D3217D2A8FEA9C004FC110 /* Fingerprint.proto */,
D9D3217B2A8FEA9B004FC110 /* Groups.proto */,
@ -13757,6 +13763,7 @@
D9E43BCC2CC194140001536E /* CallLinkManager.swift */,
D9E43BCD2CC194140001536E /* CallLinkStateUpdater.swift */,
D9E43BCE2CC194140001536E /* CallLinkUpdateMessageSender.swift */,
B965716C2F0CAA3600690CE9 /* CallQualitySurvey.swift */,
D9E43BCF2CC194140001536E /* CallRecordLoader.swift */,
D9E43BD02CC194140001536E /* CallService.swift */,
D9E43BD12CC194140001536E /* CallServiceState.swift */,
@ -14715,6 +14722,7 @@
F9C5C9A2289453B100548EEE /* Generated */ = {
isa = PBXGroup;
children = (
B985D29D2EEA913000E3BF5F /* CallQualitySurvey.pb.swift */,
F9C5C9A9289453B100548EEE /* DeviceTransfer.pb.swift */,
F9C5C9B5289453B100548EEE /* DeviceTransferProto.swift */,
F9C5C9A6289453B100548EEE /* Fingerprint.pb.swift */,
@ -17799,6 +17807,7 @@
D9E43BF32CC194140001536E /* CallMemberVideoView.swift in Sources */,
D9E43BF42CC194140001536E /* CallMemberView.swift in Sources */,
D9E43BF52CC194140001536E /* CallMemberWaitingAndErrorView.swift in Sources */,
B965716E2F0DC91900690CE9 /* CallQualitySurvey.swift in Sources */,
B985D29A2EE7E58700E3BF5F /* CallQualitySurveyCustomIssueViewController.swift in Sources */,
B924DFAC2EE7C8E000B88147 /* CallQualitySurveyDebugLogViewController.swift in Sources */,
B924DFAA2EE7C6F900B88147 /* CallQualitySurveyIssuesViewController.swift in Sources */,
@ -18742,6 +18751,7 @@
50C831762BAA3A8000BEBF25 /* CallMessageHandler.swift in Sources */,
72C9058E2B9AC8E600E586B8 /* CallMessageRelay.swift in Sources */,
5021B0332C0106470028AC87 /* CallOfferHandler.swift in Sources */,
B985D29E2EEA913000E3BF5F /* CallQualitySurvey.pb.swift in Sources */,
D9C544292B8578B50036F274 /* CallRecord+CallStatus.swift in Sources */,
D9E7C8772B9A4A9C005BD3B9 /* CallRecord+Sorting.swift in Sources */,
D99A2A852AAB9AB9003388D1 /* CallRecord.swift in Sources */,

View File

@ -0,0 +1,265 @@
//
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalRingRTC
import SignalServiceKit
enum CallQualitySurvey {
enum CallType: String {
case individualAudio = "direct_voice"
case individualVideo = "direct_video"
case group = "group"
case link = "call_link"
}
enum Rating {
case satisfied
case hadIssues(Set<Issue>, customIssue: String?)
}
enum Issue: String {
case audio = "audio"
case audioStuttering = "audio_stuttering"
case audioLocalEcho = "audio_local_echo"
case audioRemoteEcho = "audio_remote_echo"
case audioDrop = "audio_drop"
case video = "video"
case videoNoCamera = "video_no_camera"
case videoLowQuality = "video_low_quality"
case videoLowResolution = "video_low_resolution"
case callDropped = "call_dropped"
case other = "other"
}
}
class CallQualitySurveyManager {
private typealias Proto = CallQualitySurveyProtos_SubmitCallQualitySurveyRequest
private enum StoreKeys {
static let lastFailureSubmittedDate = "lastFailureSubmittedDate"
static let lastPromptDate = "lastPromptDate"
}
private let kvStore = NewKeyValueStore(collection: "CallQualitySurveyStore")
private let logger = PrefixedLogger(prefix: "[CallQualitySurvey]")
struct Deps {
let db: DB
let accountManager: TSAccountManager
let networkManager: NetworkManager
}
private let deps: Deps
private let callSummary: CallSummary
private let callType: CallQualitySurvey.CallType
init(
callSummary: CallSummary,
callType: CallQualitySurvey.CallType,
deps: Deps,
) {
self.callSummary = callSummary
self.callType = callType
self.deps = deps
}
func showIfNeeded() {
guard deps.db.read(block: shouldShowSurvey(tx:)) else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
UIApplication.shared.frontmostViewController?.present(
CallQualitySurveyNavigationController(callQualitySurveyManager: self),
animated: true,
)
self.deps.db.write { tx in
self.kvStore.writeValue(
Date(),
forKey: StoreKeys.lastPromptDate,
tx: tx,
)
}
}
}
private func shouldShowSurvey(tx: DBReadTransaction) -> Bool {
if InMemorySettings.forceCallQualitySurvey {
return true
}
guard callSummary.isSurveyCandidate else { return false }
let minimumTimeInterval: TimeInterval = .day
if callSummary.isFailure {
if
let lastFailureSubmittedDate = kvStore.fetchValue(
Date.self,
forKey: StoreKeys.lastFailureSubmittedDate,
tx: tx,
),
lastFailureSubmittedDate.addingTimeInterval(minimumTimeInterval).isAfterNow
{
// Last failure was submitted within the past 24 hours
return false
}
// No failures have been submitted within the past 24 hours
return true
}
if
let lastPromptDate = kvStore.fetchValue(
Date.self,
forKey: StoreKeys.lastPromptDate,
tx: tx,
),
lastPromptDate.addingTimeInterval(minimumTimeInterval).isAfterNow
{
// Prompt was shown within the past 24 hours
return false
}
let startDate = Date(millisecondsSince1970: callSummary.startTime)
let endDate = Date(millisecondsSince1970: callSummary.endTime)
let callWasShort = startDate.addingTimeInterval(.minute) > endDate
let callWasLong = startDate.addingTimeInterval(25 * .minute) < endDate
let callLengthWasOutsideNormalRange = callWasShort || callWasLong
guard let localIdentifiers = deps.accountManager.localIdentifiers(tx: tx) else {
owsFailBeta("No local identifiers", logger: logger)
return callLengthWasOutsideNormalRange
}
let odds = RemoteConfig.current.callQualitySurveyPPM(localIdentifiers: localIdentifiers)
let passedRNGCheck = UInt32.random(in: 0..<1_000_000) < odds
return callLengthWasOutsideNormalRange || passedRNGCheck
}
func submit(rating: CallQualitySurvey.Rating, shouldSubmitDebugLogs: Bool) {
var proto = Proto()
proto.callType = callType.rawValue
setCallSummary(proto: &proto, summary: callSummary)
setRating(proto: &proto, rating: rating)
Task {
if shouldSubmitDebugLogs {
do {
let debugLogURL = try await DebugLogs.uploadLogs(dumper: .fromGlobals())
proto.debugLogURL = debugLogURL.absoluteString
} catch {
logger.error("Failed to submit debug logs: \(error)")
}
}
do {
let data = try proto.serializedData()
let request = createRequest(data: data)
let response = try await deps.networkManager.asyncRequest(
request,
retryPolicy: .hopefullyRecoverable,
)
if response.responseStatusCode != 204 {
throw response.asError()
}
logger.info("Call quality survey submitted")
} catch {
logger.error("Failed to submit call quality survey: \(error)")
}
if callSummary.isFailure {
deps.db.write { tx in
kvStore.writeValue(
Date(),
forKey: StoreKeys.lastFailureSubmittedDate,
tx: tx,
)
}
}
}
}
private func setRating(proto: inout Proto, rating: CallQualitySurvey.Rating) {
switch rating {
case .satisfied:
proto.userSatisfied = true
proto.callQualityIssues = []
case let .hadIssues(issues, customIssue):
proto.userSatisfied = false
proto.callQualityIssues = issues.map(\.rawValue)
if let customIssue {
proto.additionalIssuesDescription = customIssue
}
}
}
private func setCallSummary(proto: inout Proto, summary: CallSummary) {
proto.startTimestamp = Int64(summary.startTime)
proto.endTimestamp = Int64(summary.endTime)
proto.callEndReason = summary.callEndReasonText
proto.success = !summary.isFailure
if let value = summary.qualityStats.rttMedianConnectionMillis {
proto.connectionRttMedian = value
}
if let value = summary.qualityStats.audioStats.rttMedianMillis {
proto.audioRttMedian = value
}
if let value = summary.qualityStats.videoStats.rttMedianMillis {
proto.videoRttMedian = value
}
if let value = summary.qualityStats.audioStats.jitterMedianReceiveMillis {
proto.audioRecvJitterMedian = value
}
if let value = summary.qualityStats.videoStats.jitterMedianReceiveMillis {
proto.videoRecvJitterMedian = value
}
if let value = summary.qualityStats.audioStats.jitterMedianSendMillis {
proto.audioSendJitterMedian = value
}
if let value = summary.qualityStats.videoStats.jitterMedianSendMillis {
proto.videoSendJitterMedian = value
}
if let value = summary.qualityStats.audioStats.packetLossFractionReceive {
proto.audioRecvPacketLossFraction = value
}
if let value = summary.qualityStats.videoStats.packetLossFractionReceive {
proto.videoRecvPacketLossFraction = value
}
if let value = summary.qualityStats.audioStats.packetLossFractionSend {
proto.audioSendPacketLossFraction = value
}
if let value = summary.qualityStats.videoStats.packetLossFractionSend {
proto.videoSendPacketLossFraction = value
}
if let value = summary.rawStats {
proto.callTelemetry = value
}
}
private func createRequest(data: Data) -> TSRequest {
var request = TSRequest(
url: URL(string: "v1/call_quality_survey")!,
method: "PUT",
body: .data(data),
logger: logger,
)
request.auth = .anonymous
return request
}
}
private extension CallSummary {
var isFailure: Bool {
[
"internalFailure",
"signalingFailure",
"connectionFailure",
"iceFailedAfterConnected",
].contains(callEndReasonText)
}
}

View File

@ -221,7 +221,21 @@ class GroupCall: SignalRingRTC.GroupCallDelegate {
func groupCall(onEnded groupCall: SignalRingRTC.GroupCall, reason: CallEndReason, summary: CallSummary) {
self.hasInvokedConnectMethod = false
// TODO: Handle the call summary.
CallQualitySurveyManager(
callSummary: summary,
callType: {
switch groupCall.kind {
case .signalGroup: .group
case .callLink: .link
}
}(),
deps: .init(
db: DependenciesBridge.shared.db,
accountManager: DependenciesBridge.shared.tsAccountManager,
networkManager: SSKEnvironment.shared.networkManagerRef,
),
).showIfNeeded()
observers.elements.forEach { $0.groupCallEnded(self, reason: reason) }
}

View File

@ -458,7 +458,20 @@ final class IndividualCallService: CallServiceStateObserver {
func callManager(_ callManager: CallService.CallManagerType, onCallEnded call: SignalCall, callId: UInt64, reason: CallEndReason, summary: CallSummary) {
Logger.info("call: \(call), onCallEnded: \(reason)")
// TODO: Handle the call summary.
CallQualitySurveyManager(
callSummary: summary,
callType: {
switch call.individualCall.offerMediaType {
case .audio: .individualAudio
case .video: .individualVideo
}
}(),
deps: .init(
db: DependenciesBridge.shared.db,
accountManager: tsAccountManager,
networkManager: networkManager,
),
).showIfNeeded()
switch reason {
case .localHangup:

View File

@ -15,7 +15,19 @@ final class SurveyDebugLogViewController: CallQualitySurveySheetViewController {
private let tableViewController = OWSTableViewController2()
private var shouldSubmitDebugLog = false
private var shouldSubmitDebugLogs = false
private let rating: CallQualitySurvey.Rating
init(rating: CallQualitySurvey.Rating) {
self.rating = rating
super.init(nibName: nil, bundle: nil)
}
@MainActor
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
@ -63,10 +75,10 @@ final class SurveyDebugLogViewController: CallQualitySurveySheetViewController {
comment: "Label for the toggle to share debug log in the call quality survey",
),
isOn: { [weak self] in
self?.shouldSubmitDebugLog ?? false
self?.shouldSubmitDebugLogs ?? false
},
actionBlock: { [weak self] control in
self?.shouldSubmitDebugLog = control.isOn
self?.shouldSubmitDebugLogs = control.isOn
},
),
],
@ -87,8 +99,7 @@ final class SurveyDebugLogViewController: CallQualitySurveySheetViewController {
bottomStackView.autoPinEdges(toSuperviewMarginsExcludingEdge: .top)
let continueButton = UIButton(primaryAction: .init { [weak self] _ in
// [Call Quality Survey] TODO: Implement
self?.dismiss(animated: true)
self?.submit()
})
continueButton.configuration = .largePrimary(title: OWSLocalizedString(
"CALL_QUALITY_SURVEY_SUBMIT_BUTTON",
@ -114,4 +125,11 @@ final class SurveyDebugLogViewController: CallQualitySurveySheetViewController {
let bottomStackHeight = bottomStackView.height
return headerHeight + collectionViewHeight + bottomStackHeight
}
private func submit() {
sheetNav?.submit(
rating: self.rating,
shouldSubmitDebugLogs: self.shouldSubmitDebugLogs,
)
}
}

View File

@ -16,8 +16,8 @@ final class CallQualitySurveyIssuesViewController: CallQualitySurveySheetViewCon
private let bottomStackView = UIStackView()
private lazy var continueButton = UIButton(
configuration: .largePrimary(title: CommonStrings.continueButton),
primaryAction: .init { [weak sheetNav] _ in
sheetNav?.doneSelectingIssues()
primaryAction: .init { [weak self] _ in
self?.submit()
},
)
private lazy var customIssueEntry = UIButton(
@ -36,7 +36,7 @@ final class CallQualitySurveyIssuesViewController: CallQualitySurveySheetViewCon
return UICollectionView(frame: .zero, collectionViewLayout: layout)
}()
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>?
private var dataSource: DataSource?
private var selectedItems = Set<Item>()
private var customIssue: String? {
@ -115,11 +115,10 @@ final class CallQualitySurveyIssuesViewController: CallQualitySurveySheetViewCon
}
let cellRegistration = UICollectionView.CellRegistration<CapsuleCell, Item> { cell, _, item in
// TODO: Account for selected icons
cell.configure(title: item.title, image: item.image)
}
dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
dataSource = DataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: itemIdentifier)
}
@ -135,15 +134,14 @@ final class CallQualitySurveyIssuesViewController: CallQualitySurveySheetViewCon
}
private func loadInitialSnapshot() {
// TODO: Typealiases?
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
var snapshot = Snapshot()
snapshot.appendSections([.main])
snapshot.appendItems([.audio, .video, .callDropped, .other])
dataSource?.apply(snapshot, animatingDifferences: false)
}
private func updateSnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
var snapshot = Snapshot()
snapshot.appendSections([.main])
// Audio
@ -233,98 +231,97 @@ final class CallQualitySurveyIssuesViewController: CallQualitySurveySheetViewCon
present(OWSNavigationController(rootViewController: vc), animated: true)
}
private enum Item: Hashable {
case audio
case audioStuttering
case audioLocalEcho
case audioRemoteEcho
case audioDrop
case video
case videoNoCamera
case videoLowQuality
case videoLowResolution
case callDropped
case other
var title: String {
switch self {
case .audio:
OWSLocalizedString(
"CALL_QUALITY_SURVEY_ISSUE_AUDIO",
comment: "Label for audio issue option in call quality survey",
)
case .audioStuttering:
OWSLocalizedString(
"CALL_QUALITY_SURVEY_ISSUE_AUDIO_STUTTERING",
comment: "Label for audio stuttering issue option in call quality survey",
)
case .audioLocalEcho:
OWSLocalizedString(
"CALL_QUALITY_SURVEY_ISSUE_AUDIO_LOCAL_ECHO",
comment: "Label for local echo issue option in call quality survey, indicating the user heard an echo",
)
case .audioRemoteEcho:
OWSLocalizedString(
"CALL_QUALITY_SURVEY_ISSUE_AUDIO_REMOTE_ECHO",
comment: "Label for remote echo issue option in call quality survey, indicating other participants heard an echo",
)
case .audioDrop:
OWSLocalizedString(
"CALL_QUALITY_SURVEY_ISSUE_AUDIO_DROP",
comment: "Label for audio dropout issue option in call quality survey",
)
case .video:
OWSLocalizedString(
"CALL_QUALITY_SURVEY_ISSUE_VIDEO",
comment: "Label for video issue option in call quality survey",
)
case .videoNoCamera:
OWSLocalizedString(
"CALL_QUALITY_SURVEY_ISSUE_VIDEO_NO_CAMERA",
comment: "Label for camera not working issue option in call quality survey",
)
case .videoLowQuality:
OWSLocalizedString(
"CALL_QUALITY_SURVEY_ISSUE_VIDEO_LOW_QUALITY",
comment: "Label for poor video quality issue option in call quality survey",
)
case .videoLowResolution:
OWSLocalizedString(
"CALL_QUALITY_SURVEY_ISSUE_VIDEO_LOW_RESOLUTION",
comment: "Label for low resolution video issue option in call quality survey",
)
case .callDropped:
OWSLocalizedString(
"CALL_QUALITY_SURVEY_ISSUE_CALL_DROPPED",
comment: "Label for call dropped issue option in call quality survey",
)
case .other:
OWSLocalizedString(
"CALL_QUALITY_SURVEY_ISSUE_OTHER",
comment: "Label for custom issue option in call quality survey",
)
}
}
var image: ImageResource {
switch self {
case .audio, .audioStuttering, .audioLocalEcho, .audioRemoteEcho, .audioDrop:
.speaker
case .video, .videoNoCamera, .videoLowQuality, .videoLowResolution:
.video
case .callDropped:
.xCircle
case .other:
.errorCircle
}
}
private func submit() {
sheetNav?.doneSelectingIssues(
rating: .hadIssues(selectedItems, customIssue: customIssue),
)
}
private typealias DataSource = UICollectionViewDiffableDataSource<Section, Item>
private typealias Snapshot = NSDiffableDataSourceSnapshot<Section, Item>
private typealias Item = CallQualitySurvey.Issue
private enum Section: Hashable {
case main
}
}
private extension CallQualitySurvey.Issue {
var title: String {
switch self {
case .audio:
OWSLocalizedString(
"CALL_QUALITY_SURVEY_ISSUE_AUDIO",
comment: "Label for audio issue option in call quality survey",
)
case .audioStuttering:
OWSLocalizedString(
"CALL_QUALITY_SURVEY_ISSUE_AUDIO_STUTTERING",
comment: "Label for audio stuttering issue option in call quality survey",
)
case .audioLocalEcho:
OWSLocalizedString(
"CALL_QUALITY_SURVEY_ISSUE_AUDIO_LOCAL_ECHO",
comment: "Label for local echo issue option in call quality survey, indicating the user heard an echo",
)
case .audioRemoteEcho:
OWSLocalizedString(
"CALL_QUALITY_SURVEY_ISSUE_AUDIO_REMOTE_ECHO",
comment: "Label for remote echo issue option in call quality survey, indicating other participants heard an echo",
)
case .audioDrop:
OWSLocalizedString(
"CALL_QUALITY_SURVEY_ISSUE_AUDIO_DROP",
comment: "Label for audio dropout issue option in call quality survey",
)
case .video:
OWSLocalizedString(
"CALL_QUALITY_SURVEY_ISSUE_VIDEO",
comment: "Label for video issue option in call quality survey",
)
case .videoNoCamera:
OWSLocalizedString(
"CALL_QUALITY_SURVEY_ISSUE_VIDEO_NO_CAMERA",
comment: "Label for camera not working issue option in call quality survey",
)
case .videoLowQuality:
OWSLocalizedString(
"CALL_QUALITY_SURVEY_ISSUE_VIDEO_LOW_QUALITY",
comment: "Label for poor video quality issue option in call quality survey",
)
case .videoLowResolution:
OWSLocalizedString(
"CALL_QUALITY_SURVEY_ISSUE_VIDEO_LOW_RESOLUTION",
comment: "Label for low resolution video issue option in call quality survey",
)
case .callDropped:
OWSLocalizedString(
"CALL_QUALITY_SURVEY_ISSUE_CALL_DROPPED",
comment: "Label for call dropped issue option in call quality survey",
)
case .other:
OWSLocalizedString(
"CALL_QUALITY_SURVEY_ISSUE_OTHER",
comment: "Label for custom issue option in call quality survey",
)
}
}
var image: ImageResource {
switch self {
case .audio, .audioStuttering, .audioLocalEcho, .audioRemoteEcho, .audioDrop:
.speaker
case .video, .videoNoCamera, .videoLowQuality, .videoLowResolution:
.video
case .callDropped:
.xCircle
case .other:
.errorCircle
}
}
}
// MARK: - CallQualitySurveyCustomIssueViewController.Delegate
extension CallQualitySurveyIssuesViewController: CallQualitySurveyCustomIssueViewController.Delegate {

View File

@ -9,7 +9,10 @@ import SignalUI
// MARK: - CallQualitySurveyNavigationController
final class CallQualitySurveyNavigationController: UINavigationController {
init() {
private let callQualitySurveyManager: CallQualitySurveyManager
init(callQualitySurveyManager: CallQualitySurveyManager) {
self.callQualitySurveyManager = callQualitySurveyManager
let vc = CallQualitySurveyRatingViewController()
super.init(rootViewController: vc)
vc.navigationItem.rightBarButtonItem = .cancelButton(dismissingFrom: self)
@ -71,15 +74,28 @@ final class CallQualitySurveyNavigationController: UINavigationController {
pushViewController(vc, animated: false)
}
// [Call Quality Survey] TODO: Pass state through
func doneSelectingIssues() {
let vc = SurveyDebugLogViewController()
func doneSelectingIssues(rating: CallQualitySurvey.Rating) {
let vc = SurveyDebugLogViewController(rating: rating)
vc.navigationItem.rightBarButtonItem = .cancelButton(dismissingFrom: self)
vc.navigationItem.leftBarButtonItem = makeBackButton()
addFadeTransition()
pushViewController(vc, animated: false)
}
func submit(rating: CallQualitySurvey.Rating, shouldSubmitDebugLogs: Bool) {
callQualitySurveyManager.submit(
rating: rating,
shouldSubmitDebugLogs: shouldSubmitDebugLogs,
)
let host = presentingViewController
dismiss(animated: true) {
host?.presentToast(text: OWSLocalizedString(
"CALL_QUALITY_SURVEY_COMPLETION_TOAST",
comment: "Title for toast which appears after submitting a call quality survey",
))
}
}
private func makeBackButton() -> UIBarButtonItem {
UIBarButtonItem.button(
image: UIImage(resource: .chevronLeftBold28),

View File

@ -58,8 +58,7 @@ final class CallQualitySurveyRatingViewController: CallQualitySurveySheetViewCon
comment: "Button label for indicating the call did not have issues in the call quality survey",
),
) { [weak sheetNav] in
// [Call Quality Survey] TODO: Pass selected items
sheetNav?.doneSelectingIssues()
sheetNav?.doneSelectingIssues(rating: .satisfied)
}
// Zero-width spacer views with .equalSpacing distribution makes the

View File

@ -327,10 +327,17 @@ class InternalSettingsViewController: OWSTableViewController2 {
otherSection.add(.copyableItem(label: "Audio Category", value: AVAudioSession.sharedInstance().category.rawValue.replacingOccurrences(of: "AVAudioSessionCategory", with: "")))
otherSection.add(.switch(
withText: "Spinning checkmarks",
isOn: { SpinningCheckmarks.shouldSpin },
isOn: { InMemorySettings.spinningCheckmarks },
target: self,
selector: #selector(spinCheckmarks(_:)),
))
otherSection.add(.switch(
withText: "Force call quality survey",
isOn: { InMemorySettings.forceCallQualitySurvey },
actionBlock: { _ in
InMemorySettings.forceCallQualitySurvey.toggle()
},
))
if #available(iOS 26, *) {
otherSection.add(.switch(
withText: "Disable Content Tracking in Chat Header",
@ -339,9 +346,6 @@ class InternalSettingsViewController: OWSTableViewController2 {
selector: #selector(toggleChatHeaderContentTrackingDisabled(sender:)),
))
}
otherSection.add(.disclosureItem(withText: "Show Call Quality Survey") { [weak self] in
self?.present(CallQualitySurveyNavigationController(), animated: true)
})
contents.add(otherSection)
if mode != .registration {
@ -366,6 +370,11 @@ class InternalSettingsViewController: OWSTableViewController2 {
// MARK: -
public enum InMemorySettings {
static var spinningCheckmarks = false
static var forceCallQualitySurvey = false
}
private extension InternalSettingsViewController {
var isChatHeaderContentTrackingDisabled: Bool {
@ -380,17 +389,11 @@ private extension InternalSettingsViewController {
}
}
// MARK: -
public enum SpinningCheckmarks {
static var shouldSpin = false
}
private extension InternalSettingsViewController {
@objc
func spinCheckmarks(_ sender: Any) {
let wasSpinning = SpinningCheckmarks.shouldSpin
let wasSpinning = InMemorySettings.spinningCheckmarks
if let view = sender as? UIView {
if wasSpinning {
view.layer.removeAnimation(forKey: "spin")
@ -403,7 +406,7 @@ private extension InternalSettingsViewController {
view.layer.add(animation, forKey: "spin")
}
}
SpinningCheckmarks.shouldSpin = !wasSpinning
InMemorySettings.spinningCheckmarks = !wasSpinning
}
func exportMessageBackupProto() async {

View File

@ -673,7 +673,7 @@ class ChatListCell: UITableViewCell, ReusableTableViewCell {
messageStatusIconView.image = token.image.withRenderingMode(.alwaysTemplate)
messageStatusIconView.tintColor = token.tintColor
if token.shouldAnimateStatusIcon || SpinningCheckmarks.shouldSpin {
if token.shouldAnimateStatusIcon || InMemorySettings.spinningCheckmarks {
messageStatusIconView.startSpinning()
} else {
messageStatusIconView.stopSpinning()

View File

@ -1381,6 +1381,9 @@
/* notification body */
"CALL_MISSED_BECAUSE_OF_IDENTITY_CHANGE_NOTIFICATION_BODY" = "☎️ Missed call because the caller's safety number changed.";
/* Title for toast which appears after submitting a call quality survey */
"CALL_QUALITY_SURVEY_COMPLETION_TOAST" = "Thanks for your feedback!";
/* Footer text explaining custom issue descriptions in the call quality survey */
"CALL_QUALITY_SURVEY_CUSTOM_ISSUE_FOOTER" = "Please include any details relevant to the issue. Anything you share here will be kept private and will only be used to help improve calls in Signal.";

View File

@ -9,14 +9,15 @@ import Foundation
@inlinable
public func owsFailBeta(
_ logMessage: String,
logger: PrefixedLogger = .empty(),
file: String = #fileID,
function: String = #function,
line: Int = #line,
) {
if BuildFlags.isPrerelease {
owsFail(logMessage, file: file, function: function, line: line)
owsFail(logMessage, logger: logger, file: file, function: function, line: line)
} else {
owsFailDebug(logMessage, file: file, function: function, line: line)
owsFailDebug(logMessage, logger: logger, file: file, function: function, line: line)
}
}

View File

@ -253,6 +253,20 @@ public class RemoteConfig {
return TimeInterval(intervalMs) / 1000
}
/// How many successful calls per million should show a call quality survey for the user's region
public func callQualitySurveyPPM(localIdentifiers: LocalIdentifiers) -> UInt64 {
let defaultValue: UInt64 = 10_000
let string = Self.countryCodeBucketValue(
csvString: getStringConvertibleValue(
forFlag: .callQualitySurveyPPM,
defaultValue: "*:\(defaultValue)",
),
localIdentifiers: localIdentifiers,
)
guard let string else { return defaultValue }
return UInt64(string) ?? defaultValue
}
public var mediaTierFallbackCdnNumber: UInt32 {
getUInt32Value(forFlag: .mediaTierFallbackCdnNumber, defaultValue: 3)
}
@ -386,16 +400,23 @@ public class RemoteConfig {
// MARK: - Country code buckets
private static func countryCodeBucketValue(csvString: String, localIdentifiers: LocalIdentifiers) -> String? {
let phoneNumberUtil = SSKEnvironment.shared.phoneNumberUtilRef
let callingCode = phoneNumberUtil.parseE164(localIdentifiers.phoneNumber)?.getCallingCode()
return countryCodeValue(csvString: csvString, callingCode: callingCode)
}
/// Determine if a country-code-dependent flag is enabled for the current
/// user, given a country-code CSV and key.
///
/// - Parameter csvString: a CSV containing `<country-code>:<parts-per-million>` pairs
/// - Parameter key: a key to use as part of bucketing
static func isCountryCodeBucketEnabled(csvString: String, key: String, localIdentifiers: LocalIdentifiers) -> Bool {
let phoneNumberUtil = SSKEnvironment.shared.phoneNumberUtilRef
let callingCode = phoneNumberUtil.parseE164(localIdentifiers.phoneNumber)?.getCallingCode()
guard
let countryCodeValue = countryCodeValue(csvString: csvString, callingCode: callingCode),
let countryCodeValue = countryCodeBucketValue(
csvString: csvString,
localIdentifiers: localIdentifiers,
),
let countEnabled = UInt64(countryCodeValue)
else {
return false
@ -557,6 +578,7 @@ private enum ValueFlag: String, FlagType {
case attachmentMaxEncryptedBytes = "global.attachments.maxBytes"
case automaticSessionResetAttemptInterval = "ios.automaticSessionResetAttemptInterval"
case backgroundRefreshInterval = "ios.backgroundRefreshInterval"
case callQualitySurveyPPM = "ios.callQualitySurveyPPM"
case cdsSyncInterval = "cds.syncInterval.seconds"
case clientExpiration = "ios.clientExpiration"
case creditAndDebitCardDisabledRegions = "global.donations.ccDisabledRegions"
@ -591,6 +613,7 @@ private enum ValueFlag: String, FlagType {
case .attachmentMaxEncryptedBytes: false
case .automaticSessionResetAttemptInterval: true
case .backgroundRefreshInterval: true
case .callQualitySurveyPPM: true
case .cdsSyncInterval: false
case .clientExpiration: true
case .creditAndDebitCardDisabledRegions: true

View File

@ -0,0 +1,463 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
// DO NOT EDIT.
// swift-format-ignore-file
// swiftlint:disable all
//
// Generated by the Swift generator plugin for the protocol buffer compiler.
// Source: CallQualitySurvey.proto
//
// For information on using the generated types, please see the documentation:
// https://github.com/apple/swift-protobuf/
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
public import Foundation
public import SwiftProtobuf
// If the compiler emits an error on this type, it is because this file
// was generated by a version of the `protoc` Swift plug-in that is
// incompatible with the version of SwiftProtobuf to which you are linking.
// Please ensure that you are building against the same version of the API
// that was used to generate this file.
fileprivate struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck {
struct _2: SwiftProtobuf.ProtobufAPIVersion_2 {}
typealias Version = _2
}
public struct CallQualitySurveyProtos_SubmitCallQualitySurveyRequest: @unchecked Sendable {
// SwiftProtobuf.Message conformance is added in an extension below. See the
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
// methods supported on all messages.
/// Indicates whether the caller was generally satisfied with the quality of
/// the call
public var userSatisfied: Bool {
get {return _storage._userSatisfied}
set {_uniqueStorage()._userSatisfied = newValue}
}
/// A list of call quality issues selected by the caller
public var callQualityIssues: [String] {
get {return _storage._callQualityIssues}
set {_uniqueStorage()._callQualityIssues = newValue}
}
/// A free-form description of any additional issues as written by the caller
public var additionalIssuesDescription: String {
get {return _storage._additionalIssuesDescription ?? String()}
set {_uniqueStorage()._additionalIssuesDescription = newValue}
}
/// Returns true if `additionalIssuesDescription` has been explicitly set.
public var hasAdditionalIssuesDescription: Bool {return _storage._additionalIssuesDescription != nil}
/// Clears the value of `additionalIssuesDescription`. Subsequent reads from it will return its default value.
public mutating func clearAdditionalIssuesDescription() {_uniqueStorage()._additionalIssuesDescription = nil}
/// A URL for a set of debug logs associated with the call if the caller chose
/// to submit debug logs
public var debugLogURL: String {
get {return _storage._debugLogURL ?? String()}
set {_uniqueStorage()._debugLogURL = newValue}
}
/// Returns true if `debugLogURL` has been explicitly set.
public var hasDebugLogURL: Bool {return _storage._debugLogURL != nil}
/// Clears the value of `debugLogURL`. Subsequent reads from it will return its default value.
public mutating func clearDebugLogURL() {_uniqueStorage()._debugLogURL = nil}
/// The time at which the call started in milliseconds since the epoch
public var startTimestamp: Int64 {
get {return _storage._startTimestamp}
set {_uniqueStorage()._startTimestamp = newValue}
}
/// The time at which the call ended in milliseconds since the epoch
public var endTimestamp: Int64 {
get {return _storage._endTimestamp}
set {_uniqueStorage()._endTimestamp = newValue}
}
/// The type of call; note that direct voice calls can become video calls and
/// vice versa, and this field indicates which mode was selected at call
/// initiation time. At the time of writing, expected call types are
/// "direct_voice", "direct_video", "group", and "call_link".
public var callType: String {
get {return _storage._callType}
set {_uniqueStorage()._callType = newValue}
}
/// Indicates whether the call completed without error or if it terminated
/// abnormally
public var success: Bool {
get {return _storage._success}
set {_uniqueStorage()._success = newValue}
}
/// A client-defined, but human-readable reason for call termination
public var callEndReason: String {
get {return _storage._callEndReason}
set {_uniqueStorage()._callEndReason = newValue}
}
/// The median round-trip time, measured in milliseconds, for STUN/ICE packets
/// (i.e. connection maintenance and establishment)
public var connectionRttMedian: Float {
get {return _storage._connectionRttMedian ?? 0}
set {_uniqueStorage()._connectionRttMedian = newValue}
}
/// Returns true if `connectionRttMedian` has been explicitly set.
public var hasConnectionRttMedian: Bool {return _storage._connectionRttMedian != nil}
/// Clears the value of `connectionRttMedian`. Subsequent reads from it will return its default value.
public mutating func clearConnectionRttMedian() {_uniqueStorage()._connectionRttMedian = nil}
/// The median round-trip time, measured in milliseconds, for RTP/RTCP packets
/// for audio streams
public var audioRttMedian: Float {
get {return _storage._audioRttMedian ?? 0}
set {_uniqueStorage()._audioRttMedian = newValue}
}
/// Returns true if `audioRttMedian` has been explicitly set.
public var hasAudioRttMedian: Bool {return _storage._audioRttMedian != nil}
/// Clears the value of `audioRttMedian`. Subsequent reads from it will return its default value.
public mutating func clearAudioRttMedian() {_uniqueStorage()._audioRttMedian = nil}
/// The median round-trip time, measured in milliseconds, for RTP/RTCP packets
/// for video streams
public var videoRttMedian: Float {
get {return _storage._videoRttMedian ?? 0}
set {_uniqueStorage()._videoRttMedian = newValue}
}
/// Returns true if `videoRttMedian` has been explicitly set.
public var hasVideoRttMedian: Bool {return _storage._videoRttMedian != nil}
/// Clears the value of `videoRttMedian`. Subsequent reads from it will return its default value.
public mutating func clearVideoRttMedian() {_uniqueStorage()._videoRttMedian = nil}
/// The median jitter for audio streams, measured in milliseconds, for the
/// duration of the call as measured by the client submitting the survey
public var audioRecvJitterMedian: Float {
get {return _storage._audioRecvJitterMedian ?? 0}
set {_uniqueStorage()._audioRecvJitterMedian = newValue}
}
/// Returns true if `audioRecvJitterMedian` has been explicitly set.
public var hasAudioRecvJitterMedian: Bool {return _storage._audioRecvJitterMedian != nil}
/// Clears the value of `audioRecvJitterMedian`. Subsequent reads from it will return its default value.
public mutating func clearAudioRecvJitterMedian() {_uniqueStorage()._audioRecvJitterMedian = nil}
/// The median jitter for video streams, measured in milliseconds, for the
/// duration of the call as measured by the client submitting the survey
public var videoRecvJitterMedian: Float {
get {return _storage._videoRecvJitterMedian ?? 0}
set {_uniqueStorage()._videoRecvJitterMedian = newValue}
}
/// Returns true if `videoRecvJitterMedian` has been explicitly set.
public var hasVideoRecvJitterMedian: Bool {return _storage._videoRecvJitterMedian != nil}
/// Clears the value of `videoRecvJitterMedian`. Subsequent reads from it will return its default value.
public mutating func clearVideoRecvJitterMedian() {_uniqueStorage()._videoRecvJitterMedian = nil}
/// The median jitter for audio streams, measured in milliseconds, for the
/// duration of the call as measured by the remote endpoint in the call (either
/// the peer of the client submitting the survey in a direct call or the SFU in
/// a group call)
public var audioSendJitterMedian: Float {
get {return _storage._audioSendJitterMedian ?? 0}
set {_uniqueStorage()._audioSendJitterMedian = newValue}
}
/// Returns true if `audioSendJitterMedian` has been explicitly set.
public var hasAudioSendJitterMedian: Bool {return _storage._audioSendJitterMedian != nil}
/// Clears the value of `audioSendJitterMedian`. Subsequent reads from it will return its default value.
public mutating func clearAudioSendJitterMedian() {_uniqueStorage()._audioSendJitterMedian = nil}
/// The median jitter for video streams, measured in milliseconds, for the
/// duration of the call as measured by the remote endpoint in the call (either
/// the peer of the client submitting the survey in a direct call or the SFU in
/// a group call)
public var videoSendJitterMedian: Float {
get {return _storage._videoSendJitterMedian ?? 0}
set {_uniqueStorage()._videoSendJitterMedian = newValue}
}
/// Returns true if `videoSendJitterMedian` has been explicitly set.
public var hasVideoSendJitterMedian: Bool {return _storage._videoSendJitterMedian != nil}
/// Clears the value of `videoSendJitterMedian`. Subsequent reads from it will return its default value.
public mutating func clearVideoSendJitterMedian() {_uniqueStorage()._videoSendJitterMedian = nil}
/// The fraction of audio packets lost over the duration of the call as
/// measured by the client submitting the survey
public var audioRecvPacketLossFraction: Float {
get {return _storage._audioRecvPacketLossFraction ?? 0}
set {_uniqueStorage()._audioRecvPacketLossFraction = newValue}
}
/// Returns true if `audioRecvPacketLossFraction` has been explicitly set.
public var hasAudioRecvPacketLossFraction: Bool {return _storage._audioRecvPacketLossFraction != nil}
/// Clears the value of `audioRecvPacketLossFraction`. Subsequent reads from it will return its default value.
public mutating func clearAudioRecvPacketLossFraction() {_uniqueStorage()._audioRecvPacketLossFraction = nil}
/// The fraction of video packets lost over the duration of the call as
/// measured by the client submitting the survey
public var videoRecvPacketLossFraction: Float {
get {return _storage._videoRecvPacketLossFraction ?? 0}
set {_uniqueStorage()._videoRecvPacketLossFraction = newValue}
}
/// Returns true if `videoRecvPacketLossFraction` has been explicitly set.
public var hasVideoRecvPacketLossFraction: Bool {return _storage._videoRecvPacketLossFraction != nil}
/// Clears the value of `videoRecvPacketLossFraction`. Subsequent reads from it will return its default value.
public mutating func clearVideoRecvPacketLossFraction() {_uniqueStorage()._videoRecvPacketLossFraction = nil}
/// The fraction of audio packets lost over the duration of the call as
/// measured by the remote endpoint in the call (either the peer of the client
/// submitting the survey in a direct call or the SFU in a group call)
public var audioSendPacketLossFraction: Float {
get {return _storage._audioSendPacketLossFraction ?? 0}
set {_uniqueStorage()._audioSendPacketLossFraction = newValue}
}
/// Returns true if `audioSendPacketLossFraction` has been explicitly set.
public var hasAudioSendPacketLossFraction: Bool {return _storage._audioSendPacketLossFraction != nil}
/// Clears the value of `audioSendPacketLossFraction`. Subsequent reads from it will return its default value.
public mutating func clearAudioSendPacketLossFraction() {_uniqueStorage()._audioSendPacketLossFraction = nil}
/// The fraction of video packets lost over the duration of the call as
/// measured by the remote endpoint in the call (either the peer of the client
/// submitting the survey in a direct call or the SFU in a group call)
public var videoSendPacketLossFraction: Float {
get {return _storage._videoSendPacketLossFraction ?? 0}
set {_uniqueStorage()._videoSendPacketLossFraction = newValue}
}
/// Returns true if `videoSendPacketLossFraction` has been explicitly set.
public var hasVideoSendPacketLossFraction: Bool {return _storage._videoSendPacketLossFraction != nil}
/// Clears the value of `videoSendPacketLossFraction`. Subsequent reads from it will return its default value.
public mutating func clearVideoSendPacketLossFraction() {_uniqueStorage()._videoSendPacketLossFraction = nil}
/// Machine-generated telemetry from the call; this is a serialized protobuf
/// entity generated (and, critically, explained to the user!) by the calling
/// library
public var callTelemetry: Data {
get {return _storage._callTelemetry ?? Data()}
set {_uniqueStorage()._callTelemetry = newValue}
}
/// Returns true if `callTelemetry` has been explicitly set.
public var hasCallTelemetry: Bool {return _storage._callTelemetry != nil}
/// Clears the value of `callTelemetry`. Subsequent reads from it will return its default value.
public mutating func clearCallTelemetry() {_uniqueStorage()._callTelemetry = nil}
public var unknownFields = SwiftProtobuf.UnknownStorage()
public init() {}
fileprivate var _storage = _StorageClass.defaultInstance
}
// MARK: - Code below here is support for the SwiftProtobuf runtime.
fileprivate let _protobuf_package = "CallQualitySurveyProtos"
extension CallQualitySurveyProtos_SubmitCallQualitySurveyRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
public static let protoMessageName: String = _protobuf_package + ".SubmitCallQualitySurveyRequest"
public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}user_satisfied\0\u{3}call_quality_issues\0\u{3}additional_issues_description\0\u{3}debug_log_url\0\u{3}start_timestamp\0\u{3}end_timestamp\0\u{3}call_type\0\u{1}success\0\u{3}call_end_reason\0\u{3}connection_rtt_median\0\u{3}audio_rtt_median\0\u{3}video_rtt_median\0\u{3}audio_recv_jitter_median\0\u{3}video_recv_jitter_median\0\u{3}audio_send_jitter_median\0\u{3}video_send_jitter_median\0\u{3}audio_recv_packet_loss_fraction\0\u{3}video_recv_packet_loss_fraction\0\u{3}audio_send_packet_loss_fraction\0\u{3}video_send_packet_loss_fraction\0\u{3}call_telemetry\0")
fileprivate class _StorageClass {
var _userSatisfied: Bool = false
var _callQualityIssues: [String] = []
var _additionalIssuesDescription: String? = nil
var _debugLogURL: String? = nil
var _startTimestamp: Int64 = 0
var _endTimestamp: Int64 = 0
var _callType: String = String()
var _success: Bool = false
var _callEndReason: String = String()
var _connectionRttMedian: Float? = nil
var _audioRttMedian: Float? = nil
var _videoRttMedian: Float? = nil
var _audioRecvJitterMedian: Float? = nil
var _videoRecvJitterMedian: Float? = nil
var _audioSendJitterMedian: Float? = nil
var _videoSendJitterMedian: Float? = nil
var _audioRecvPacketLossFraction: Float? = nil
var _videoRecvPacketLossFraction: Float? = nil
var _audioSendPacketLossFraction: Float? = nil
var _videoSendPacketLossFraction: Float? = nil
var _callTelemetry: Data? = nil
// This property is used as the initial default value for new instances of the type.
// The type itself is protecting the reference to its storage via CoW semantics.
// This will force a copy to be made of this reference when the first mutation occurs;
// hence, it is safe to mark this as `nonisolated(unsafe)`.
static nonisolated(unsafe) let defaultInstance = _StorageClass()
private init() {}
init(copying source: _StorageClass) {
_userSatisfied = source._userSatisfied
_callQualityIssues = source._callQualityIssues
_additionalIssuesDescription = source._additionalIssuesDescription
_debugLogURL = source._debugLogURL
_startTimestamp = source._startTimestamp
_endTimestamp = source._endTimestamp
_callType = source._callType
_success = source._success
_callEndReason = source._callEndReason
_connectionRttMedian = source._connectionRttMedian
_audioRttMedian = source._audioRttMedian
_videoRttMedian = source._videoRttMedian
_audioRecvJitterMedian = source._audioRecvJitterMedian
_videoRecvJitterMedian = source._videoRecvJitterMedian
_audioSendJitterMedian = source._audioSendJitterMedian
_videoSendJitterMedian = source._videoSendJitterMedian
_audioRecvPacketLossFraction = source._audioRecvPacketLossFraction
_videoRecvPacketLossFraction = source._videoRecvPacketLossFraction
_audioSendPacketLossFraction = source._audioSendPacketLossFraction
_videoSendPacketLossFraction = source._videoSendPacketLossFraction
_callTelemetry = source._callTelemetry
}
}
fileprivate mutating func _uniqueStorage() -> _StorageClass {
if !isKnownUniquelyReferenced(&_storage) {
_storage = _StorageClass(copying: _storage)
}
return _storage
}
public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
_ = _uniqueStorage()
try withExtendedLifetime(_storage) { (_storage: _StorageClass) in
while let fieldNumber = try decoder.nextFieldNumber() {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every case branch when no optimizations are
// enabled. https://github.com/apple/swift-protobuf/issues/1034
switch fieldNumber {
case 1: try { try decoder.decodeSingularBoolField(value: &_storage._userSatisfied) }()
case 2: try { try decoder.decodeRepeatedStringField(value: &_storage._callQualityIssues) }()
case 3: try { try decoder.decodeSingularStringField(value: &_storage._additionalIssuesDescription) }()
case 4: try { try decoder.decodeSingularStringField(value: &_storage._debugLogURL) }()
case 5: try { try decoder.decodeSingularInt64Field(value: &_storage._startTimestamp) }()
case 6: try { try decoder.decodeSingularInt64Field(value: &_storage._endTimestamp) }()
case 7: try { try decoder.decodeSingularStringField(value: &_storage._callType) }()
case 8: try { try decoder.decodeSingularBoolField(value: &_storage._success) }()
case 9: try { try decoder.decodeSingularStringField(value: &_storage._callEndReason) }()
case 10: try { try decoder.decodeSingularFloatField(value: &_storage._connectionRttMedian) }()
case 11: try { try decoder.decodeSingularFloatField(value: &_storage._audioRttMedian) }()
case 12: try { try decoder.decodeSingularFloatField(value: &_storage._videoRttMedian) }()
case 13: try { try decoder.decodeSingularFloatField(value: &_storage._audioRecvJitterMedian) }()
case 14: try { try decoder.decodeSingularFloatField(value: &_storage._videoRecvJitterMedian) }()
case 15: try { try decoder.decodeSingularFloatField(value: &_storage._audioSendJitterMedian) }()
case 16: try { try decoder.decodeSingularFloatField(value: &_storage._videoSendJitterMedian) }()
case 17: try { try decoder.decodeSingularFloatField(value: &_storage._audioRecvPacketLossFraction) }()
case 18: try { try decoder.decodeSingularFloatField(value: &_storage._videoRecvPacketLossFraction) }()
case 19: try { try decoder.decodeSingularFloatField(value: &_storage._audioSendPacketLossFraction) }()
case 20: try { try decoder.decodeSingularFloatField(value: &_storage._videoSendPacketLossFraction) }()
case 21: try { try decoder.decodeSingularBytesField(value: &_storage._callTelemetry) }()
default: break
}
}
}
}
public func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
try withExtendedLifetime(_storage) { (_storage: _StorageClass) in
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every if/case branch local when no optimizations
// are enabled. https://github.com/apple/swift-protobuf/issues/1034 and
// https://github.com/apple/swift-protobuf/issues/1182
if _storage._userSatisfied != false {
try visitor.visitSingularBoolField(value: _storage._userSatisfied, fieldNumber: 1)
}
if !_storage._callQualityIssues.isEmpty {
try visitor.visitRepeatedStringField(value: _storage._callQualityIssues, fieldNumber: 2)
}
try { if let v = _storage._additionalIssuesDescription {
try visitor.visitSingularStringField(value: v, fieldNumber: 3)
} }()
try { if let v = _storage._debugLogURL {
try visitor.visitSingularStringField(value: v, fieldNumber: 4)
} }()
if _storage._startTimestamp != 0 {
try visitor.visitSingularInt64Field(value: _storage._startTimestamp, fieldNumber: 5)
}
if _storage._endTimestamp != 0 {
try visitor.visitSingularInt64Field(value: _storage._endTimestamp, fieldNumber: 6)
}
if !_storage._callType.isEmpty {
try visitor.visitSingularStringField(value: _storage._callType, fieldNumber: 7)
}
if _storage._success != false {
try visitor.visitSingularBoolField(value: _storage._success, fieldNumber: 8)
}
if !_storage._callEndReason.isEmpty {
try visitor.visitSingularStringField(value: _storage._callEndReason, fieldNumber: 9)
}
try { if let v = _storage._connectionRttMedian {
try visitor.visitSingularFloatField(value: v, fieldNumber: 10)
} }()
try { if let v = _storage._audioRttMedian {
try visitor.visitSingularFloatField(value: v, fieldNumber: 11)
} }()
try { if let v = _storage._videoRttMedian {
try visitor.visitSingularFloatField(value: v, fieldNumber: 12)
} }()
try { if let v = _storage._audioRecvJitterMedian {
try visitor.visitSingularFloatField(value: v, fieldNumber: 13)
} }()
try { if let v = _storage._videoRecvJitterMedian {
try visitor.visitSingularFloatField(value: v, fieldNumber: 14)
} }()
try { if let v = _storage._audioSendJitterMedian {
try visitor.visitSingularFloatField(value: v, fieldNumber: 15)
} }()
try { if let v = _storage._videoSendJitterMedian {
try visitor.visitSingularFloatField(value: v, fieldNumber: 16)
} }()
try { if let v = _storage._audioRecvPacketLossFraction {
try visitor.visitSingularFloatField(value: v, fieldNumber: 17)
} }()
try { if let v = _storage._videoRecvPacketLossFraction {
try visitor.visitSingularFloatField(value: v, fieldNumber: 18)
} }()
try { if let v = _storage._audioSendPacketLossFraction {
try visitor.visitSingularFloatField(value: v, fieldNumber: 19)
} }()
try { if let v = _storage._videoSendPacketLossFraction {
try visitor.visitSingularFloatField(value: v, fieldNumber: 20)
} }()
try { if let v = _storage._callTelemetry {
try visitor.visitSingularBytesField(value: v, fieldNumber: 21)
} }()
}
try unknownFields.traverse(visitor: &visitor)
}
public static func ==(lhs: CallQualitySurveyProtos_SubmitCallQualitySurveyRequest, rhs: CallQualitySurveyProtos_SubmitCallQualitySurveyRequest) -> Bool {
if lhs._storage !== rhs._storage {
let storagesAreEqual: Bool = withExtendedLifetime((lhs._storage, rhs._storage)) { (_args: (_StorageClass, _StorageClass)) in
let _storage = _args.0
let rhs_storage = _args.1
if _storage._userSatisfied != rhs_storage._userSatisfied {return false}
if _storage._callQualityIssues != rhs_storage._callQualityIssues {return false}
if _storage._additionalIssuesDescription != rhs_storage._additionalIssuesDescription {return false}
if _storage._debugLogURL != rhs_storage._debugLogURL {return false}
if _storage._startTimestamp != rhs_storage._startTimestamp {return false}
if _storage._endTimestamp != rhs_storage._endTimestamp {return false}
if _storage._callType != rhs_storage._callType {return false}
if _storage._success != rhs_storage._success {return false}
if _storage._callEndReason != rhs_storage._callEndReason {return false}
if _storage._connectionRttMedian != rhs_storage._connectionRttMedian {return false}
if _storage._audioRttMedian != rhs_storage._audioRttMedian {return false}
if _storage._videoRttMedian != rhs_storage._videoRttMedian {return false}
if _storage._audioRecvJitterMedian != rhs_storage._audioRecvJitterMedian {return false}
if _storage._videoRecvJitterMedian != rhs_storage._videoRecvJitterMedian {return false}
if _storage._audioSendJitterMedian != rhs_storage._audioSendJitterMedian {return false}
if _storage._videoSendJitterMedian != rhs_storage._videoSendJitterMedian {return false}
if _storage._audioRecvPacketLossFraction != rhs_storage._audioRecvPacketLossFraction {return false}
if _storage._videoRecvPacketLossFraction != rhs_storage._videoRecvPacketLossFraction {return false}
if _storage._audioSendPacketLossFraction != rhs_storage._audioSendPacketLossFraction {return false}
if _storage._videoSendPacketLossFraction != rhs_storage._videoSendPacketLossFraction {return false}
if _storage._callTelemetry != rhs_storage._callTelemetry {return false}
return true
}
if !storagesAreEqual {return false}
}
if lhs.unknownFields != rhs.unknownFields {return false}
return true
}
}

View File

@ -8,7 +8,7 @@
PROTOC=protoc --proto_path=Specifications
WRAPPER_SCRIPT=../../Scripts/protos/ProtoWrappers.py --proto-dir=Specifications --verbose
all: registration_protos signal_service_protos provisioning_protos fingerprint_protos websocket_protos signal_ios_protos storage_service_protos groups_protos device_transfer_protos session_record_protos svr_protos mobilecoin_protos backup_protos
all: registration_protos signal_service_protos provisioning_protos fingerprint_protos websocket_protos signal_ios_protos storage_service_protos groups_protos device_transfer_protos session_record_protos svr_protos mobilecoin_protos backup_protos call_quality_survey_protos
signal_service_protos: Specifications/SignalService.proto
$(PROTOC) --swift_out=Generated SignalService.proto
@ -56,3 +56,6 @@ registration_protos: Specifications/Registration.proto
backup_protos: Backups/Backup.proto
$(PROTOC) --swift_out=Backups --proto_path=Backups --swift_opt=Visibility=public --swift_opt=UseAccessLevelOnImports=true Backup.proto
call_quality_survey_protos: Specifications/CallQualitySurvey.proto
$(PROTOC) --swift_out=Generated CallQualitySurvey.proto --swift_opt=Visibility=public --swift_opt=UseAccessLevelOnImports=true

View File

@ -0,0 +1,102 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
syntax = "proto3";
// iOS - package name determines class prefix
package CallQualitySurveyProtos;
option java_multiple_files = true;
option java_package = "org.signal.chat.calling.quality";
message SubmitCallQualitySurveyRequest {
// Indicates whether the caller was generally satisfied with the quality of
// the call
bool user_satisfied = 1;
// A list of call quality issues selected by the caller
repeated string call_quality_issues = 2;
// A free-form description of any additional issues as written by the caller
optional string additional_issues_description = 3;
// A URL for a set of debug logs associated with the call if the caller chose
// to submit debug logs
optional string debug_log_url = 4;
// The time at which the call started in milliseconds since the epoch
int64 start_timestamp = 5;
// The time at which the call ended in milliseconds since the epoch
int64 end_timestamp = 6;
// The type of call; note that direct voice calls can become video calls and
// vice versa, and this field indicates which mode was selected at call
// initiation time. At the time of writing, expected call types are
// "direct_voice", "direct_video", "group", and "call_link".
string call_type = 7;
// Indicates whether the call completed without error or if it terminated
// abnormally
bool success = 8;
// A client-defined, but human-readable reason for call termination
string call_end_reason = 9;
// The median round-trip time, measured in milliseconds, for STUN/ICE packets
// (i.e. connection maintenance and establishment)
optional float connection_rtt_median = 10;
// The median round-trip time, measured in milliseconds, for RTP/RTCP packets
// for audio streams
optional float audio_rtt_median = 11;
// The median round-trip time, measured in milliseconds, for RTP/RTCP packets
// for video streams
optional float video_rtt_median = 12;
// The median jitter for audio streams, measured in milliseconds, for the
// duration of the call as measured by the client submitting the survey
optional float audio_recv_jitter_median = 13;
// The median jitter for video streams, measured in milliseconds, for the
// duration of the call as measured by the client submitting the survey
optional float video_recv_jitter_median = 14;
// The median jitter for audio streams, measured in milliseconds, for the
// duration of the call as measured by the remote endpoint in the call (either
// the peer of the client submitting the survey in a direct call or the SFU in
// a group call)
optional float audio_send_jitter_median = 15;
// The median jitter for video streams, measured in milliseconds, for the
// duration of the call as measured by the remote endpoint in the call (either
// the peer of the client submitting the survey in a direct call or the SFU in
// a group call)
optional float video_send_jitter_median = 16;
// The fraction of audio packets lost over the duration of the call as
// measured by the client submitting the survey
optional float audio_recv_packet_loss_fraction = 17;
// The fraction of video packets lost over the duration of the call as
// measured by the client submitting the survey
optional float video_recv_packet_loss_fraction = 18;
// The fraction of audio packets lost over the duration of the call as
// measured by the remote endpoint in the call (either the peer of the client
// submitting the survey in a direct call or the SFU in a group call)
optional float audio_send_packet_loss_fraction = 19;
// The fraction of video packets lost over the duration of the call as
// measured by the remote endpoint in the call (either the peer of the client
// submitting the survey in a direct call or the SFU in a group call)
optional float video_send_packet_loss_fraction = 20;
// Machine-generated telemetry from the call; this is a serialized protobuf
// entity generated (and, critically, explained to the user!) by the calling
// library
optional bytes call_telemetry = 21;
}

View File

@ -208,7 +208,11 @@ class ToastView: UIView {
label = UILabel()
super.init(frame: frame)
self.layer.cornerRadius = 12
if #available(iOS 26, *) {
self.cornerConfiguration = .capsule()
} else {
self.layer.cornerRadius = 12
}
self.clipsToBounds = true
self.layoutMargins = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)