From 54fb7ff23fccbf47d6eabab2ada978f6bf9dfefc Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 26 Nov 2025 13:51:03 -0400 Subject: [PATCH] Call quality survey integration. --- .../thoughtcrime/securesms/MainActivity.kt | 31 ++ .../securesms/calls/quality/CallQuality.kt | 138 +++++++ .../quality/CallQualityBottomSheetFragment.kt | 20 +- .../quality/CallQualityScreenViewModel.kt | 26 +- .../calls/quality/CallQualityScreens.kt | 337 +++++++++++------- .../app/internal/InternalSettingsFragment.kt | 7 +- .../app/internal/InternalSettingsState.kt | 1 + .../app/internal/InternalSettingsViewModel.kt | 6 + .../securesms/dependencies/AppDependencies.kt | 2 +- .../ApplicationDependencyProvider.java | 4 +- .../dependencies/NetworkDependenciesModule.kt | 2 +- .../jobs/CallQualitySurveySubmissionJob.kt | 130 +++++++ .../securesms/jobs/JobManagerFactories.java | 1 + .../securesms/keyvalue/CallQualityValues.kt | 28 ++ .../securesms/keyvalue/InternalValues.kt | 3 + .../securesms/keyvalue/SignalStore.kt | 10 +- .../logsubmit/SubmitDebugLogRepository.java | 7 + .../securesms/main/MainBottomChrome.kt | 8 +- .../service/webrtc/SignalCallManager.java | 14 +- .../securesms/util/RemoteConfig.kt | 16 + app/src/main/protowire/JobData.proto | 6 + .../MockApplicationDependencyProvider.kt | 2 +- .../signalservice/api/calling/CallingApi.kt | 21 ++ .../main/protowire/CallQualitySurvey.proto | 111 +++++- 24 files changed, 772 insertions(+), 159 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQuality.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/CallQualitySurveySubmissionJob.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/keyvalue/CallQualityValues.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt index 76b7ae4293..64244e32b5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt @@ -46,6 +46,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.adaptive.layout.PaneAdaptedValue import androidx.compose.material3.adaptive.layout.PaneExpansionAnchor import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState @@ -92,6 +93,8 @@ import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity import org.thoughtcrime.securesms.calls.log.CallLogFilter import org.thoughtcrime.securesms.calls.log.CallLogFragment import org.thoughtcrime.securesms.calls.new.NewCallActivity +import org.thoughtcrime.securesms.calls.quality.CallQuality +import org.thoughtcrime.securesms.calls.quality.CallQualityBottomSheetFragment import org.thoughtcrime.securesms.components.DebugLogsPromptDialogFragment import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment import org.thoughtcrime.securesms.components.compose.ConnectivityWarningBottomSheet @@ -130,6 +133,7 @@ import org.thoughtcrime.securesms.main.MainNavigationDetailLocation import org.thoughtcrime.securesms.main.MainNavigationListLocation import org.thoughtcrime.securesms.main.MainNavigationRail import org.thoughtcrime.securesms.main.MainNavigationViewModel +import org.thoughtcrime.securesms.main.MainSnackbar import org.thoughtcrime.securesms.main.MainToolbar import org.thoughtcrime.securesms.main.MainToolbarCallback import org.thoughtcrime.securesms.main.MainToolbarMode @@ -306,6 +310,20 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner } } + supportFragmentManager.setFragmentResultListener( + CallQualityBottomSheetFragment.REQUEST_KEY, + this + ) { _, bundle -> + if (bundle.getBoolean(CallQualityBottomSheetFragment.REQUEST_KEY, false)) { + mainNavigationViewModel.setSnackbar( + SnackbarState( + message = getString(R.string.CallQualitySheet__thanks_for_your_feedback), + duration = SnackbarDuration.Short + ) + ) + } + } + shareDataTimestampViewModel.setTimestampFromActivityCreation(savedInstanceState, intent) setContent { @@ -526,6 +544,15 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner modifier = chatNavGraphState.writeContentToGraphicsLayer(), paneExpansionState = paneExpansionState, contentWindowInsets = WindowInsets(), + snackbarHost = { + if (wrappedNavigator.scaffoldValue.primary == PaneAdaptedValue.Expanded) { + MainSnackbar( + snackbarState = snackbar, + onDismissed = mainBottomChromeCallback::onSnackbarDismissed, + modifier = Modifier.navigationBarsPadding() + ) + } + }, bottomNavContent = { if (isNavigationBarVisible) { Column( @@ -827,6 +854,10 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner vitalsViewModel.checkSlowNotificationHeuristics() mainNavigationViewModel.refreshNavigationBarState() + + CallQuality.consumeQualityRequest()?.let { + CallQualityBottomSheetFragment.create(it).show(supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } } override fun onStop() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQuality.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQuality.kt new file mode 100644 index 0000000000..5fa10eed81 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQuality.kt @@ -0,0 +1,138 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.calls.quality + +import okio.ByteString.Companion.toByteString +import org.signal.ringrtc.CallSummary +import org.signal.ringrtc.GroupCall +import org.signal.storageservice.protos.calls.quality.SubmitCallQualitySurveyRequest +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.util.RemoteConfig +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes + +/** + * Helper object for dealing with call quality ux + */ +object CallQuality { + + private val errors = listOf( + "internalFailure", + "signalingFailure", + "connectionFailure", + "iceFailedAfterConnected" + ) + + @JvmStatic + fun handleOneToOneCallSummary(callSummary: CallSummary, isVideoCall: Boolean) { + val callType = if (isVideoCall) CallType.DIRECT_VIDEO else CallType.DIRECT_VOICE + + handleCallSummary(callSummary, callType) + } + + @JvmStatic + fun handleGroupCallSummary(callSummary: CallSummary, kind: GroupCall.Kind) { + val callType = when (kind) { + GroupCall.Kind.SIGNAL_GROUP -> CallType.GROUP + GroupCall.Kind.CALL_LINK -> CallType.CALL_LINK + } + + handleCallSummary(callSummary, callType) + } + + @JvmStatic + private fun handleCallSummary(callSummary: CallSummary, callType: CallType) { + if (isCallQualitySurveyRequired(callSummary)) { + SignalStore.callQuality.surveyRequest = SubmitCallQualitySurveyRequest.Builder() + .call_type(callType.code) + .start_timestamp(callSummary.startTime) + .end_timestamp(callSummary.endTime) + .success(isSuccess(callSummary)) + .call_end_reason(callSummary.callEndReasonText) + .connection_rtt_median(callSummary.qualityStats.rttMedianConnectionMillis) + .audio_rtt_median(callSummary.qualityStats.audioStats.rttMedianMillis) + .video_rtt_median(callSummary.qualityStats.videoStats.rttMedianMillis) + .audio_recv_jitter_median(callSummary.qualityStats.audioStats.jitterMedianRecvMillis) + .video_recv_jitter_median(callSummary.qualityStats.videoStats.jitterMedianRecvMillis) + .audio_send_jitter_median(callSummary.qualityStats.audioStats.jitterMedianSendMillis) + .video_send_jitter_median(callSummary.qualityStats.videoStats.jitterMedianSendMillis) + .audio_send_packet_loss_fraction(callSummary.qualityStats.audioStats.packetLossPercentageSend) + .video_send_packet_loss_fraction(callSummary.qualityStats.videoStats.packetLossPercentageSend) + .audio_recv_packet_loss_fraction(callSummary.qualityStats.audioStats.packetLossPercentageRecv) + .video_recv_packet_loss_fraction(callSummary.qualityStats.videoStats.packetLossPercentageRecv) + .call_telemetry(callSummary.rawStats?.toByteString()) + .build() + } else { + SignalStore.callQuality.surveyRequest = null + } + } + + fun consumeQualityRequest(): SubmitCallQualitySurveyRequest? { + val request = SignalStore.callQuality.surveyRequest + SignalStore.callQuality.surveyRequest = null + return if (isFeatureEnabled()) request else null + } + + private fun isCallQualitySurveyRequired(callSummary: CallSummary): Boolean { + if (!isFeatureEnabled() || !callSummary.isSurveyCandidate) { + return false + } + + val isSuccess = isSuccess(callSummary) + val now = System.currentTimeMillis().milliseconds + val lastFailure = SignalStore.callQuality.lastFailureReportTime ?: 0.milliseconds + val failureDelta = now - lastFailure + + if (!isSuccess && (failureDelta < 1.days)) { + SignalStore.callQuality.lastFailureReportTime = now + return true + } + + if (isSuccess) { + val lastSurveyPromptTime = SignalStore.callQuality.lastSurveyPromptTime ?: 0.milliseconds + val lastSurveyPromptDelta = now - lastSurveyPromptTime + val lastPromptWasTooRecent = lastSurveyPromptDelta < 1.days + + if (lastPromptWasTooRecent) { + return false + } + + val callLength = callSummary.endTime.milliseconds - callSummary.startTime.milliseconds + val isLongerThanTenMinutes = callLength > 10.minutes + val isLessThanOneMinute = callLength < 1.minutes + + if (isLongerThanTenMinutes || isLessThanOneMinute) { + return true + } + + val chance = RemoteConfig.callQualitySurveyPercent + val roll = (0 until 100).random() + + if (roll < chance) { + return true + } + } + + return false + } + + private fun isSuccess(callSummary: CallSummary): Boolean { + return callSummary.callEndReasonText !in errors + } + + private fun isFeatureEnabled(): Boolean { + return (RemoteConfig.callQualitySurvey || SignalStore.internal.callQualitySurveys) && SignalStore.callQuality.isQualitySurveyEnabled + } + + private enum class CallType(val code: String) { + // "direct_voice", "direct_video", "group", and "call_link". + DIRECT_VOICE("direct_voice"), + DIRECT_VIDEO("direct_video"), + GROUP("group"), + CALL_LINK("call_link") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualityBottomSheetFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualityBottomSheetFragment.kt index 7130a4d6d4..166b61bcc2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualityBottomSheetFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualityBottomSheetFragment.kt @@ -15,9 +15,12 @@ import androidx.core.os.bundleOf import androidx.fragment.app.setFragmentResult import androidx.fragment.app.setFragmentResultListener import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.signal.storageservice.protos.calls.quality.SubmitCallQualitySurveyRequest import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment +import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity import org.thoughtcrime.securesms.util.viewModel +import kotlin.time.Duration.Companion.milliseconds /** * Fragment which manages sheets for walking the user through collecting call @@ -27,14 +30,23 @@ class CallQualityBottomSheetFragment : ComposeBottomSheetDialogFragment() { companion object { const val REQUEST_KEY = "CallQualityBottomSheetRequestKey" + + fun create(request: SubmitCallQualitySurveyRequest): CallQualityBottomSheetFragment { + return CallQualityBottomSheetFragment().apply { + arguments = bundleOf(REQUEST_KEY to request.encode()) + } + } } private val viewModel: CallQualityScreenViewModel by viewModel { - CallQualityScreenViewModel() + val bytes = requireArguments().getByteArray(REQUEST_KEY)!! + + CallQualityScreenViewModel(SubmitCallQualitySurveyRequest.ADAPTER.decode(bytes)) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - setFragmentResultListener(CallQualitySomethingElseFragment.REQUEST_KEY) { key, bundle -> + SignalStore.callQuality.lastSurveyPromptTime = System.currentTimeMillis().milliseconds + setFragmentResultListener(CallQualitySomethingElseFragment.REQUEST_KEY) { _, bundle -> val result = bundle.getString(CallQualitySomethingElseFragment.REQUEST_KEY) ?: "" viewModel.onSomethingElseDescriptionChanged(result) @@ -64,6 +76,10 @@ class CallQualityBottomSheetFragment : ComposeBottomSheetDialogFragment() { ) } + override fun onUserSatisfiedWithCall(isUserSatisfiedWithCall: Boolean) { + viewModel.setUserSatisfiedWithCall(isUserSatisfiedWithCall) + } + override fun describeYourIssue() { CallQualitySomethingElseFragment.create( viewModel.state.value.somethingElseDescription diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualityScreenViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualityScreenViewModel.kt index 8270233410..cf9a5ac450 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualityScreenViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualityScreenViewModel.kt @@ -9,12 +9,21 @@ import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import org.signal.storageservice.protos.calls.quality.SubmitCallQualitySurveyRequest +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.jobs.CallQualitySurveySubmissionJob -class CallQualityScreenViewModel : ViewModel() { +class CallQualityScreenViewModel( + val initialRequest: SubmitCallQualitySurveyRequest +) : ViewModel() { private val internalState = MutableStateFlow(CallQualitySheetState()) val state: StateFlow = internalState + fun setUserSatisfiedWithCall(userSatisfiedWithCall: Boolean) { + internalState.update { it.copy(isUserSatisfiedWithCall = userSatisfiedWithCall) } + } + fun onCallQualityIssueSelectionChanged(selection: Set) { internalState.update { it.copy(selectedQualityIssues = selection) } } @@ -28,6 +37,19 @@ class CallQualityScreenViewModel : ViewModel() { } fun submit() { - // Enqueue job. + val stateSnapshot = state.value + val somethingElseDescription: String? = if (stateSnapshot.selectedQualityIssues.contains(CallQualityIssue.SOMETHING_ELSE)) { + stateSnapshot.somethingElseDescription.takeIf { it.isNotEmpty() } + } else { + null + } + + val requestToSubmitToJob = initialRequest.newBuilder() + .user_satisfied(stateSnapshot.isUserSatisfiedWithCall) + .call_quality_issues(stateSnapshot.selectedQualityIssues.map { it.code }) + .additional_issues_description(somethingElseDescription) + .build() + + AppDependencies.jobManager.add(CallQualitySurveySubmissionJob(requestToSubmitToJob, stateSnapshot.isShareDebugLogSelected)) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualityScreens.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualityScreens.kt index 85f6a06c74..791c165628 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualityScreens.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualityScreens.kt @@ -7,7 +7,14 @@ package org.thoughtcrime.securesms.calls.quality import androidx.annotation.DrawableRes import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -73,9 +80,11 @@ fun CallQualitySheet( when (navEntry) { CallQualitySheetNavEntry.HowWasYourCall -> HowWasYourCall( onGreatClick = { + callback.onUserSatisfiedWithCall(true) navEntry = CallQualitySheetNavEntry.HelpUsImprove }, onHadIssuesClick = { + callback.onUserSatisfiedWithCall(true) navEntry = CallQualitySheetNavEntry.WhatIssuesDidYouHave }, onCancelClick = callback::dismiss @@ -146,7 +155,6 @@ private fun WhatIssuesDidYouHave( SheetTitle(text = stringResource(R.string.CallQualitySheet__what_issues_did_you_have)) SheetSubtitle(text = stringResource(R.string.CallQualitySheet__select_all_that_apply)) - val qualityIssueDisplaySet = rememberQualityDisplaySet(selectedQualityIssues) val onCallQualityIssueClick: (CallQualityIssue) -> Unit = remember(selectedQualityIssues, onCallQualityIssueSelectionChanged) { { issue -> val isRemoving = issue in selectedQualityIssues @@ -172,6 +180,9 @@ private fun WhatIssuesDidYouHave( } } + val isAudioExpanded = CallQualityIssue.AUDIO_ISSUE in selectedQualityIssues + val isVideoExpanded = CallQualityIssue.VIDEO_ISSUE in selectedQualityIssues + FlowRow( modifier = Modifier .animateContentSize() @@ -179,104 +190,206 @@ private fun WhatIssuesDidYouHave( .horizontalGutters(), horizontalArrangement = Arrangement.Center ) { - qualityIssueDisplaySet.forEach { issue -> - val isIssueSelected = issue in selectedQualityIssues + IssueChip( + issue = CallQualityIssue.AUDIO_ISSUE, + isSelected = isAudioExpanded, + onClick = { onCallQualityIssueClick(CallQualityIssue.AUDIO_ISSUE) } + ) - InputChip( - selected = isIssueSelected, - onClick = { - onCallQualityIssueClick(issue) - }, - colors = InputChipDefaults.inputChipColors( - leadingIconColor = MaterialTheme.colorScheme.onSurface, - selectedLeadingIconColor = MaterialTheme.colorScheme.onSurface, - labelColor = MaterialTheme.colorScheme.onSurface - ), - leadingIcon = { - Icon( - imageVector = if (isIssueSelected) { - ImageVector.vectorResource(R.drawable.symbol_check_24) - } else { - ImageVector.vectorResource(issue.category.icon) - }, - contentDescription = null - ) - }, - label = { - Text(text = stringResource(issue.label)) - }, - modifier = Modifier.padding(horizontal = 4.dp) - ) + AnimatedIssueChip( + visible = isAudioExpanded, + issue = CallQualityIssue.AUDIO_STUTTERING, + isSelected = CallQualityIssue.AUDIO_STUTTERING in selectedQualityIssues, + onClick = { onCallQualityIssueClick(CallQualityIssue.AUDIO_STUTTERING) } + ) + + AnimatedIssueChip( + visible = isAudioExpanded, + issue = CallQualityIssue.AUDIO_CUT_OUT, + isSelected = CallQualityIssue.AUDIO_CUT_OUT in selectedQualityIssues, + onClick = { onCallQualityIssueClick(CallQualityIssue.AUDIO_CUT_OUT) } + ) + + AnimatedIssueChip( + visible = isAudioExpanded, + issue = CallQualityIssue.AUDIO_I_HEARD_ECHO, + isSelected = CallQualityIssue.AUDIO_I_HEARD_ECHO in selectedQualityIssues, + onClick = { onCallQualityIssueClick(CallQualityIssue.AUDIO_I_HEARD_ECHO) } + ) + + AnimatedIssueChip( + visible = isAudioExpanded, + issue = CallQualityIssue.AUDIO_OTHERS_HEARD_ECHO, + isSelected = CallQualityIssue.AUDIO_OTHERS_HEARD_ECHO in selectedQualityIssues, + onClick = { onCallQualityIssueClick(CallQualityIssue.AUDIO_OTHERS_HEARD_ECHO) } + ) + + IssueChip( + issue = CallQualityIssue.VIDEO_ISSUE, + isSelected = isVideoExpanded, + onClick = { onCallQualityIssueClick(CallQualityIssue.VIDEO_ISSUE) } + ) + + AnimatedIssueChip( + visible = isVideoExpanded, + issue = CallQualityIssue.VIDEO_POOR_QUALITY, + isSelected = CallQualityIssue.VIDEO_POOR_QUALITY in selectedQualityIssues, + onClick = { onCallQualityIssueClick(CallQualityIssue.VIDEO_POOR_QUALITY) } + ) + + AnimatedIssueChip( + visible = isVideoExpanded, + issue = CallQualityIssue.VIDEO_LOW_RESOLUTION, + isSelected = CallQualityIssue.VIDEO_LOW_RESOLUTION in selectedQualityIssues, + onClick = { onCallQualityIssueClick(CallQualityIssue.VIDEO_LOW_RESOLUTION) } + ) + + AnimatedIssueChip( + visible = isVideoExpanded, + issue = CallQualityIssue.VIDEO_CAMERA_MALFUNCTION, + isSelected = CallQualityIssue.VIDEO_CAMERA_MALFUNCTION in selectedQualityIssues, + onClick = { onCallQualityIssueClick(CallQualityIssue.VIDEO_CAMERA_MALFUNCTION) } + ) + + IssueChip( + issue = CallQualityIssue.CALL_DROPPED, + isSelected = CallQualityIssue.CALL_DROPPED in selectedQualityIssues, + onClick = { onCallQualityIssueClick(CallQualityIssue.CALL_DROPPED) } + ) + + IssueChip( + issue = CallQualityIssue.SOMETHING_ELSE, + isSelected = CallQualityIssue.SOMETHING_ELSE in selectedQualityIssues, + onClick = { onCallQualityIssueClick(CallQualityIssue.SOMETHING_ELSE) } + ) + } + + AnimatedVisibility( + visible = CallQualityIssue.SOMETHING_ELSE in selectedQualityIssues, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + val text = somethingElseDescription.ifEmpty { + stringResource(R.string.CallQualitySheet__describe_your_issue) } - if (CallQualityIssue.SOMETHING_ELSE in selectedQualityIssues) { - val text = somethingElseDescription.ifEmpty { - stringResource(R.string.CallQualitySheet__describe_your_issue) - } - - val textColor = if (somethingElseDescription.isNotEmpty()) { - MaterialTheme.colorScheme.onSurface - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - - val textUnderlineStrokeWidthPx = with(LocalDensity.current) { - 1.dp.toPx() - } - - val textUnderlineColor = MaterialTheme.colorScheme.outline - - Text( - text = text, - color = textColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .clickable( - role = Role.Button, - onClick = onDescribeYourIssueClick - ) - .fillMaxWidth() - .padding(top = 24.dp) - .background(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp)) - .drawWithContent { - drawContent() - - val width = size.width - val height = size.height - textUnderlineStrokeWidthPx / 2f - - drawLine( - color = textUnderlineColor, - start = Offset(x = 0f, y = height), - end = Offset(x = width, y = height), - strokeWidth = textUnderlineStrokeWidthPx - ) - } - .padding(16.dp) - ) + val textColor = if (somethingElseDescription.isNotEmpty()) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant } - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + val textUnderlineStrokeWidthPx = with(LocalDensity.current) { + 1.dp.toPx() + } + + val textUnderlineColor = MaterialTheme.colorScheme.outline + + Text( + text = text, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, modifier = Modifier + .clickable( + role = Role.Button, + onClick = onDescribeYourIssueClick + ) .fillMaxWidth() - .padding(top = 32.dp, bottom = 24.dp) - ) { - CancelButton( - onClick = onCancelClick - ) + .horizontalGutters() + .padding(top = 24.dp) + .background(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp)) + .drawWithContent { + drawContent() - Buttons.LargeTonal( - onClick = onContinueClick - ) { - Text(text = stringResource(R.string.CallQualitySheet__continue)) - } + val width = size.width + val height = size.height - textUnderlineStrokeWidthPx / 2f + + drawLine( + color = textUnderlineColor, + start = Offset(x = 0f, y = height), + end = Offset(x = width, y = height), + strokeWidth = textUnderlineStrokeWidthPx + ) + } + .padding(16.dp) + ) + } + + // Buttons - outside FlowRow, stable position + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .horizontalGutters() + .padding(top = 32.dp, bottom = 24.dp) + ) { + CancelButton( + onClick = onCancelClick + ) + + Buttons.LargeTonal( + onClick = onContinueClick + ) { + Text(text = stringResource(R.string.CallQualitySheet__continue)) } } } } +@Composable +private fun IssueChip( + issue: CallQualityIssue, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + InputChip( + selected = isSelected, + onClick = onClick, + colors = InputChipDefaults.inputChipColors( + leadingIconColor = MaterialTheme.colorScheme.onSurface, + selectedLeadingIconColor = MaterialTheme.colorScheme.onSurface, + labelColor = MaterialTheme.colorScheme.onSurface + ), + leadingIcon = { + Icon( + imageVector = if (isSelected) { + ImageVector.vectorResource(R.drawable.symbol_check_24) + } else { + ImageVector.vectorResource(issue.category.icon) + }, + contentDescription = null + ) + }, + label = { + Text(text = stringResource(issue.label)) + }, + modifier = modifier.padding(horizontal = 4.dp) + ) +} + +@Composable +private fun AnimatedIssueChip( + visible: Boolean, + issue: CallQualityIssue, + isSelected: Boolean, + onClick: () -> Unit +) { + AnimatedVisibility( + visible = visible, + enter = fadeIn() + expandHorizontally(), + exit = fadeOut() + shrinkHorizontally() + ) { + IssueChip( + issue = issue, + isSelected = isSelected, + onClick = onClick + ) + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun HelpUsImprove( @@ -549,33 +662,8 @@ private fun SomethingElseContentPreview() { } } -@Composable -private fun rememberQualityDisplaySet(userSelection: Set): Set { - return remember(userSelection) { - val displaySet = mutableSetOf() - displaySet.add(CallQualityIssue.AUDIO_ISSUE) - if (CallQualityIssue.AUDIO_ISSUE in userSelection) { - displaySet.add(CallQualityIssue.AUDIO_STUTTERING) - displaySet.add(CallQualityIssue.AUDIO_CUT_OUT) - displaySet.add(CallQualityIssue.AUDIO_I_HEARD_ECHO) - displaySet.add(CallQualityIssue.AUDIO_OTHERS_HEARD_ECHO) - } - - displaySet.add(CallQualityIssue.VIDEO_ISSUE) - if (CallQualityIssue.VIDEO_ISSUE in userSelection) { - displaySet.add(CallQualityIssue.VIDEO_POOR_QUALITY) - displaySet.add(CallQualityIssue.VIDEO_LOW_RESOLUTION) - displaySet.add(CallQualityIssue.VIDEO_CAMERA_MALFUNCTION) - } - - displaySet.add(CallQualityIssue.CALL_DROPPED) - displaySet.add(CallQualityIssue.SOMETHING_ELSE) - - displaySet - } -} - data class CallQualitySheetState( + val isUserSatisfiedWithCall: Boolean = false, val selectedQualityIssues: Set = emptySet(), val somethingElseDescription: String = "", val isShareDebugLogSelected: Boolean = false @@ -584,6 +672,7 @@ data class CallQualitySheetState( interface CallQualitySheetCallback { fun dismiss() fun viewDebugLog() + fun onUserSatisfiedWithCall(isUserSatisfiedWithCall: Boolean) fun describeYourIssue() fun onCallQualityIssueSelectionChanged(selection: Set) fun onShareDebugLogChanged(shareDebugLog: Boolean) @@ -592,6 +681,7 @@ interface CallQualitySheetCallback { object Empty : CallQualitySheetCallback { override fun dismiss() = Unit override fun viewDebugLog() = Unit + override fun onUserSatisfiedWithCall(isUserSatisfiedWithCall: Boolean) = Unit override fun describeYourIssue() = Unit override fun onCallQualityIssueSelectionChanged(selection: Set) = Unit override fun onShareDebugLogChanged(shareDebugLog: Boolean) = Unit @@ -615,18 +705,19 @@ enum class CallQualityIssueCategory( } enum class CallQualityIssue( + val code: String, val category: CallQualityIssueCategory, @param:StringRes val label: Int ) { - AUDIO_ISSUE(category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__audio_issue), - AUDIO_STUTTERING(category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__audio_stuttering), - AUDIO_CUT_OUT(category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__audio_cut_out), - AUDIO_I_HEARD_ECHO(category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__i_heard_echo), - AUDIO_OTHERS_HEARD_ECHO(category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__others_heard_echo), - VIDEO_ISSUE(category = CallQualityIssueCategory.VIDEO, label = R.string.CallQualityIssue__video_issue), - VIDEO_POOR_QUALITY(category = CallQualityIssueCategory.VIDEO, label = R.string.CallQualityIssue__poor_video_quality), - VIDEO_LOW_RESOLUTION(category = CallQualityIssueCategory.VIDEO, label = R.string.CallQualityIssue__low_resolution), - VIDEO_CAMERA_MALFUNCTION(category = CallQualityIssueCategory.VIDEO, label = R.string.CallQualityIssue__camera_did_not_work), - CALL_DROPPED(category = CallQualityIssueCategory.CALL_DROPPED, label = R.string.CallQualityIssue__call_droppped), - SOMETHING_ELSE(category = CallQualityIssueCategory.SOMETHING_ELSE, label = R.string.CallQualityIssue__something_else) + AUDIO_ISSUE(code = "audio", category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__audio_issue), + AUDIO_STUTTERING(code = "audio_stuttering", category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__audio_stuttering), + AUDIO_CUT_OUT(code = "audio_drop", category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__audio_cut_out), + AUDIO_I_HEARD_ECHO(code = "audio_remote_echo", category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__i_heard_echo), + AUDIO_OTHERS_HEARD_ECHO(code = "audio_local_echo", category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__others_heard_echo), + VIDEO_ISSUE(code = "video", category = CallQualityIssueCategory.VIDEO, label = R.string.CallQualityIssue__video_issue), + VIDEO_POOR_QUALITY(code = "video_low_quality", category = CallQualityIssueCategory.VIDEO, label = R.string.CallQualityIssue__poor_video_quality), + VIDEO_LOW_RESOLUTION(code = "video_low_resolution", category = CallQualityIssueCategory.VIDEO, label = R.string.CallQualityIssue__low_resolution), + VIDEO_CAMERA_MALFUNCTION(code = "video_no_camera", category = CallQualityIssueCategory.VIDEO, label = R.string.CallQualityIssue__camera_did_not_work), + CALL_DROPPED(code = "call_dropped", category = CallQualityIssueCategory.CALL_DROPPED, label = R.string.CallQualityIssue__call_droppped), + SOMETHING_ELSE(code = "other", category = CallQualityIssueCategory.SOMETHING_ELSE, label = R.string.CallQualityIssue__something_else) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt index 9735f7b046..6278ff03c7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt @@ -580,10 +580,11 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter } ) - clickPref( - title = DSLSettingsText.from("Display Call Quality Survey UX"), + switchPref( + title = DSLSettingsText.from("Enable call quality surveys"), + isChecked = state.callQualitySurveys, onClick = { - CallQualityBottomSheetFragment().show(parentFragmentManager, null) + viewModel.setEnableCallQualitySurveys(!state.callQualitySurveys) } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt index 138bcfc987..f577846f3c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt @@ -31,5 +31,6 @@ data class InternalSettingsState( val hasPendingOneTimeDonation: Boolean, val hevcEncoding: Boolean, val newCallingUi: Boolean, + val callQualitySurveys: Boolean, val forceSplitPane: Boolean ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt index c8d6fbf639..2c2266a008 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt @@ -197,6 +197,7 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito hasPendingOneTimeDonation = SignalStore.inAppPayments.getPendingOneTimeDonation() != null, hevcEncoding = SignalStore.internal.hevcEncoding, newCallingUi = SignalStore.internal.newCallingUi, + callQualitySurveys = SignalStore.internal.callQualitySurveys, forceSplitPane = SignalStore.internal.forceSplitPane ) @@ -213,6 +214,11 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito refresh() } + fun setEnableCallQualitySurveys(enabled: Boolean) { + SignalStore.internal.callQualitySurveys = enabled + refresh() + } + fun setForceSplitPane(forceSplitPane: Boolean) { SignalStore.internal.forceSplitPane = forceSplitPane refresh() diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt index 8632bca0f9..3fa7591522 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt @@ -449,7 +449,7 @@ object AppDependencies { fun provideUnauthWebSocket(signalServiceConfigurationSupplier: Supplier, libSignalNetworkSupplier: Supplier): SignalWebSocket.UnauthenticatedWebSocket fun provideAccountApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): AccountApi fun provideUsernameApi(unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): UsernameApi - fun provideCallingApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, pushServiceSocket: PushServiceSocket): CallingApi + fun provideCallingApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket): CallingApi fun providePaymentsApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): PaymentsApi fun provideCdsApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): CdsApi fun provideRateLimitChallengeApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): RateLimitChallengeApi diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java index fca7bd8ad3..17df819f16 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -525,8 +525,8 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider { } @Override - public @NonNull CallingApi provideCallingApi(@NonNull SignalWebSocket.AuthenticatedWebSocket authWebSocket, @NonNull PushServiceSocket pushServiceSocket) { - return new CallingApi(authWebSocket, pushServiceSocket); + public @NonNull CallingApi provideCallingApi(@NonNull SignalWebSocket.AuthenticatedWebSocket authWebSocket, @NonNull SignalWebSocket.UnauthenticatedWebSocket unauthWebSocket, @NonNull PushServiceSocket pushServiceSocket) { + return new CallingApi(authWebSocket, unauthWebSocket, pushServiceSocket); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt index a3a1bd236d..3f4a488e1f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt @@ -180,7 +180,7 @@ class NetworkDependenciesModule( } val callingApi: CallingApi by lazy { - provider.provideCallingApi(authWebSocket, pushServiceSocket) + provider.provideCallingApi(authWebSocket, unauthWebSocket, pushServiceSocket) } val paymentsApi: PaymentsApi by lazy { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/CallQualitySurveySubmissionJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/CallQualitySurveySubmissionJob.kt new file mode 100644 index 0000000000..021254800f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/CallQualitySurveySubmissionJob.kt @@ -0,0 +1,130 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.jobs + +import org.signal.core.util.logging.Log +import org.signal.storageservice.protos.calls.quality.SubmitCallQualitySurveyRequest +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint +import org.thoughtcrime.securesms.jobs.protos.CallQualitySurveySubmissionJobData +import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogRepository +import org.whispersystems.signalservice.api.NetworkResult +import kotlin.jvm.optionals.getOrNull +import kotlin.time.Duration.Companion.hours + +/** + * Job which will upload call quality survey data after submission by user. + */ +class CallQualitySurveySubmissionJob private constructor( + private var request: SubmitCallQualitySurveyRequest, + private val includeDebugLogs: Boolean, + parameters: Parameters +) : Job(parameters) { + + constructor(request: SubmitCallQualitySurveyRequest, includeDebugLogs: Boolean) : this( + request, + includeDebugLogs, + Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setMaxInstancesForFactory(1) + .setLifespan(6.hours.inWholeMilliseconds) + .build() + ) + + companion object { + private val TAG = Log.tag(CallQualitySurveySubmissionJob::class) + + const val KEY = "CallQualitySurveySubmission" + } + + override fun serialize(): ByteArray { + return CallQualitySurveySubmissionJobData( + request = request, + includeDebugLogs = includeDebugLogs + ).encode() + } + + override fun getFactoryKey(): String = KEY + + override fun run(): Result { + Log.i(TAG, "Starting call quality survey submission.") + + val request = handleRequestDebugLogs(request) + + Log.i(TAG, "Sending survey data to server.") + + return submitRequestData(request) + } + + override fun onFailure() = Unit + + private fun handleRequestDebugLogs(request: SubmitCallQualitySurveyRequest): SubmitCallQualitySurveyRequest { + return if (includeDebugLogs) { + Log.i(TAG, "Including debug logs.") + + if (request.debug_log_url != null) { + Log.i(TAG, "Already included debug logs.") + request + } else { + uploadDebugLogs(request) + } + } else { + Log.i(TAG, "Skipping debug logs.") + request + } + } + + private fun uploadDebugLogs(request: SubmitCallQualitySurveyRequest): SubmitCallQualitySurveyRequest { + val repository = SubmitDebugLogRepository() + val url = repository.buildAndSubmitLogSync(request.end_timestamp).getOrNull() + + if (url == null) { + Log.w(TAG, "Failed to upload debug log. Proceeding with survey capture.") + } + + return request.newBuilder().debug_log_url(url).build() + } + + private fun submitRequestData(request: SubmitCallQualitySurveyRequest): Result { + return when (val networkResult = AppDependencies.callingApi.submitCallQualitySurvey(request)) { + is NetworkResult.Success -> { + Log.i(TAG, "Successfully submitted survey.") + Result.success() + } + is NetworkResult.ApplicationError -> { + Log.w(TAG, "Failed to submit due to an application error.", networkResult.getCause()) + Result.failure() + } + is NetworkResult.NetworkError -> { + Log.w(TAG, "Failed to submit due to a network error. Scheduling a retry.", networkResult.getCause()) + Result.retry(defaultBackoff()) + } + is NetworkResult.StatusCodeError -> { + when (networkResult.code) { + 429 -> { + val retryIn: Long = networkResult.header("Retry-After")?.toLongOrNull()?.let { it * 1000 } ?: defaultBackoff() + Log.w(TAG, "Failed to submit survey due to rate limit, status code ${networkResult.code} retrying in ${retryIn}ms", networkResult.getCause()) + Result.retry(retryIn) + } + + else -> { + Log.w(TAG, "Failed to submit survey, status code ${networkResult.code}", networkResult.getCause()) + Result.failure() + } + } + } + } + } + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): CallQualitySurveySubmissionJob { + val jobData = CallQualitySurveySubmissionJobData.ADAPTER.decode(serializedData!!) + + return CallQualitySurveySubmissionJob(jobData.request!!, jobData.includeDebugLogs, parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 337d3dc14e..9dae405514 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -151,6 +151,7 @@ public final class JobManagerFactories { put(CallLinkPeekJob.KEY, new CallLinkPeekJob.Factory()); put(CallLinkUpdateSendJob.KEY, new CallLinkUpdateSendJob.Factory()); put(CallLogEventSendJob.KEY, new CallLogEventSendJob.Factory()); + put(CallQualitySurveySubmissionJob.KEY, new CallQualitySurveySubmissionJob.Factory()); put(CallSyncEventJob.KEY, new CallSyncEventJob.Factory()); put(CancelRestoreMediaJob.KEY, new CancelRestoreMediaJob.Factory()); put(CheckRestoreMediaLeftJob.KEY, new CheckRestoreMediaLeftJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/CallQualityValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/CallQualityValues.kt new file mode 100644 index 0000000000..5ee36e8185 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/CallQualityValues.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.keyvalue + +import org.signal.storageservice.protos.calls.quality.SubmitCallQualitySurveyRequest +import kotlin.time.Duration + +class CallQualityValues(store: KeyValueStore) : SignalStoreValues(store) { + + companion object { + const val SURVEY_REQUEST = "callQualityValues.survey_request" + const val IS_CALL_QUALITY_SURVEY_ENABLED = "callQualityValues.is_call_quality_survey_enabled" + const val LAST_FAILURE_REPORT_TIME = "callQualityValues.last_failure_report_time" + const val LAST_SURVEY_PROMPT_TIME = "callQualityValues.last_survey_prompt_time" + } + + var surveyRequest: SubmitCallQualitySurveyRequest? by protoValue(SURVEY_REQUEST, SubmitCallQualitySurveyRequest.ADAPTER) + var isQualitySurveyEnabled: Boolean by booleanValue(IS_CALL_QUALITY_SURVEY_ENABLED, true) + var lastFailureReportTime: Duration? by durationValue(LAST_FAILURE_REPORT_TIME, null) + var lastSurveyPromptTime: Duration? by durationValue(LAST_SURVEY_PROMPT_TIME, null) + + public override fun onFirstEverAppLaunch() = Unit + + public override fun getKeysToIncludeInBackup(): List = emptyList() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.kt index 060994193f..b37e460b6c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.kt @@ -32,6 +32,7 @@ class InternalValues internal constructor(store: KeyValueStore) : SignalStoreVal const val WEB_SOCKET_SHADOWING_STATS: String = "internal.web_socket_shadowing_stats" const val ENCODE_HEVC: String = "internal.hevc_encoding" const val NEW_CALL_UI: String = "internal.new.call.ui" + const val CALL_QUALITY_SURVEYS: String = "internal.call_quality_surveys" const val FORCE_SPLIT_PANE_ON_COMPACT_LANDSCAPE: String = "internal.force.split.pane.on.compact.landscape.ui" const val SHOW_ARCHIVE_STATE_HINT: String = "internal.show_archive_state_hint" const val INCLUDE_DEBUGLOG_IN_BACKUP: String = "internal.include_debuglog_in_backup" @@ -170,6 +171,8 @@ class InternalValues internal constructor(store: KeyValueStore) : SignalStoreVal var newCallingUi: Boolean by booleanValue(NEW_CALL_UI, false).defaultForExternalUsers() + var callQualitySurveys: Boolean by booleanValue(CALL_QUALITY_SURVEYS, false).defaultForExternalUsers() + var lastScrollPosition: Int by integerValue(LAST_SCROLL_POSITION, 0).defaultForExternalUsers() var useConversationItemV2Media by booleanValue(CONVERSATION_ITEM_V2_MEDIA, false).defaultForExternalUsers() diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.kt index 4bd9fe3bb0..cb90265e18 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.kt @@ -37,6 +37,7 @@ class SignalStore(context: Application, private val store: KeyValueStore) { val storyValues = StoryValues(store) val apkUpdateValues = ApkUpdateValues(store) val backupValues = BackupValues(store) + val callQualityValues = CallQualityValues(store) val plainTextValues = PlainTextSharedPrefsDataStore(context) @@ -84,6 +85,7 @@ class SignalStore(context: Application, private val store: KeyValueStore) { story.onFirstEverAppLaunch() apkUpdate.onFirstEverAppLaunch() backup.onFirstEverAppLaunch() + callQuality.onFirstEverAppLaunch() } @JvmStatic @@ -115,7 +117,8 @@ class SignalStore(context: Application, private val store: KeyValueStore) { releaseChannel.keysToIncludeInBackup + story.keysToIncludeInBackup + apkUpdate.keysToIncludeInBackup + - backup.keysToIncludeInBackup + backup.keysToIncludeInBackup + + callQuality.keysToIncludeInBackup } /** @@ -266,6 +269,11 @@ class SignalStore(context: Application, private val store: KeyValueStore) { val backup: BackupValues get() = instance!!.backupValues + @JvmStatic + @get:JvmName("callQuality") + val callQuality: CallQualityValues + get() = instance!!.callQualityValues + val groupsV2AciAuthorizationCache: GroupsV2AuthorizationSignalStoreCache get() = GroupsV2AuthorizationSignalStoreCache.createAciCache(instance!!.store) diff --git a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/SubmitDebugLogRepository.java b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/SubmitDebugLogRepository.java index 8146540c6e..1c7d753d58 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/SubmitDebugLogRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/SubmitDebugLogRepository.java @@ -126,6 +126,13 @@ public class SubmitDebugLogRepository { }); } + @WorkerThread + public Optional buildAndSubmitLogSync(long untilTime) { + Log.blockUntilAllWritesFinished(); + LogDatabase.getInstance(context).logs().trimToSize(); + return submitLogInternal(untilTime, getPrefixLogLinesInternal(), Tracer.getInstance().serialize()); + } + public void submitLogFromReader(DebugLogsViewer.LogReader logReader, @Nullable byte[] trace, Callback> callback) { SignalExecutors.UNBOUNDED.execute(() -> callback.onResult(submitLogFromReaderInternal(logReader, trace))); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainBottomChrome.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainBottomChrome.kt index fe27c4f5da..2481251288 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainBottomChrome.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainBottomChrome.kt @@ -106,7 +106,11 @@ fun MainBottomChrome( ) } - val snackBarModifier = if (!windowSizeClass.isSplitPane() && state.mainToolbarMode == MainToolbarMode.BASIC) { + if (windowSizeClass.isSplitPane()) { + return@Column + } + + val snackBarModifier = if (state.mainToolbarMode == MainToolbarMode.BASIC) { Modifier.navigationBarsPadding() } else { Modifier @@ -121,7 +125,7 @@ fun MainBottomChrome( } @Composable -private fun MainSnackbar( +fun MainSnackbar( snackbarState: SnackbarState?, onDismissed: () -> Unit, modifier: Modifier = Modifier diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java index bcd9ac412b..241aca4599 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java @@ -35,6 +35,7 @@ import org.signal.ringrtc.NetworkRoute; import org.signal.ringrtc.PeekInfo; import org.signal.ringrtc.Remote; import org.signal.storageservice.protos.groups.GroupExternalCredential; +import org.thoughtcrime.securesms.calls.quality.CallQuality; import org.thoughtcrime.securesms.components.webrtc.v2.CallIntent; import org.thoughtcrime.securesms.crypto.SealedSenderAccessUtil; import org.thoughtcrime.securesms.database.CallLinkTable; @@ -69,7 +70,6 @@ import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState; import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState; import org.thoughtcrime.securesms.util.AppForegroundObserver; import org.thoughtcrime.securesms.util.RecipientAccessList; -import org.thoughtcrime.securesms.util.RemoteConfig; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.rx.RxStore; @@ -611,7 +611,15 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall. Log.i(TAG, "onCallEnded(): call_id: " + remotePeer.getCallId() + ", state: " + remotePeer.getState() + ", reason: " + reason); - // TODO: Handle the call summary. + if (s.getCallInfoState().getGroupCall() != null) { + Log.i(TAG, "onCallEnded(): call_id: bypassing call summary handling for group call, this is handled in onEnded(groupCall, ...)"); + } else { + boolean isRemoteVideoEnabled = Objects.requireNonNull(s.getCallInfoState().getRemoteCallParticipant(s.getCallInfoState().getCallRecipient())).isVideoEnabled(); + CameraState cameraState = s.getLocalDeviceState().getCameraState(); + boolean isLocalVideoEnabled = cameraState.isEnabled() && cameraState.getCameraCount() > 0; + + CallQuality.handleOneToOneCallSummary(summary, isRemoteVideoEnabled || isLocalVideoEnabled); + } switch (reason) { case LOCAL_HANGUP: @@ -1025,7 +1033,7 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall. @Override public void onEnded(@NonNull GroupCall groupCall, @NonNull CallManager.CallEndReason reason, @NonNull CallSummary summary) { - // TODO: Handle the call summary. + CallQuality.handleGroupCallSummary(summary, groupCall.getKind()); process((s, p) -> p.handleGroupCallEnded(s, groupCall.hashCode(), reason)); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt index 06e67141e8..1312e2abd1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt @@ -1229,5 +1229,21 @@ object RemoteConfig { defaultValue = false, hotSwappable = true ) + + @JvmStatic + @get:JvmName("callQualitySurvey") + val callQualitySurvey: Boolean by remoteBoolean( + key = "android.callQualitySurvey", + defaultValue = false, + hotSwappable = true + ) + + @JvmStatic + @get:JvmName("callQualitySurveyPercent") + val callQualitySurveyPercent: Int by remoteInt( + key = "android.callQualitySurveyPercent", + defaultValue = 1, + hotSwappable = true + ) // endregion } diff --git a/app/src/main/protowire/JobData.proto b/app/src/main/protowire/JobData.proto index 1c811b5051..82305dde57 100644 --- a/app/src/main/protowire/JobData.proto +++ b/app/src/main/protowire/JobData.proto @@ -3,6 +3,7 @@ syntax = "proto3"; package signal; import "ResumableUploads.proto"; +import "CallQualitySurvey.proto"; option java_package = "org.thoughtcrime.securesms.jobs.protos"; option java_multiple_files = true; @@ -257,4 +258,9 @@ message UnpinJobData { uint64 messageId = 1; repeated uint64 recipients = 2; uint32 initialRecipientCount = 3; +} + +message CallQualitySurveySubmissionJobData { + org.signal.storageservice.protos.calls.quality.SubmitCallQualitySurveyRequest request = 1; + bool includeDebugLogs = 2; } \ No newline at end of file diff --git a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt index 68db20423d..ead7ad70de 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt @@ -267,7 +267,7 @@ class MockApplicationDependencyProvider : AppDependencies.Provider { return mockk(relaxed = true) } - override fun provideCallingApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, pushServiceSocket: PushServiceSocket): CallingApi { + override fun provideCallingApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket): CallingApi { return mockk(relaxed = true) } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/calling/CallingApi.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/calling/CallingApi.kt index c58096670e..226c04f3fe 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/calling/CallingApi.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/calling/CallingApi.kt @@ -7,6 +7,7 @@ package org.whispersystems.signalservice.api.calling import org.signal.libsignal.zkgroup.calllinks.CreateCallLinkCredentialRequest import org.signal.libsignal.zkgroup.calllinks.CreateCallLinkCredentialResponse +import org.signal.storageservice.protos.calls.quality.SubmitCallQualitySurveyRequest import org.whispersystems.signalservice.api.NetworkResult import org.whispersystems.signalservice.api.messages.calls.CallingResponse import org.whispersystems.signalservice.api.messages.calls.TurnServerInfo @@ -17,6 +18,7 @@ import org.whispersystems.signalservice.internal.push.CreateCallLinkAuthRequest import org.whispersystems.signalservice.internal.push.CreateCallLinkAuthResponse import org.whispersystems.signalservice.internal.push.GetCallingRelaysResponse import org.whispersystems.signalservice.internal.push.PushServiceSocket +import org.whispersystems.signalservice.internal.putCustom import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage /** @@ -24,9 +26,28 @@ import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessa */ class CallingApi( private val auth: SignalWebSocket.AuthenticatedWebSocket, + private val unAuth: SignalWebSocket.UnauthenticatedWebSocket, private val pushServiceSocket: PushServiceSocket ) { + /** + * Submit call quality information (with the user's permission) to the server on an unauthenticated channel. + * + * PUT /v1/call_quality_survey + * - 204: The survey response was submitted successfully + * - 422: The survey response could not be parsed + * - 429: Too many attempts, try after Retry-After seconds. + */ + fun submitCallQualitySurvey(request: SubmitCallQualitySurveyRequest): NetworkResult { + val webSocketRequestMessage = WebSocketRequestMessage.putCustom( + path = "/v1/call_quality_survey", + body = request.encode(), + headers = mapOf("Content-Type" to "application/octet-stream") + ) + + return NetworkResult.fromWebSocketRequest(unAuth, webSocketRequestMessage) + } + /** * Get 1:1 relay addresses in IpV4, Ipv6, and URL formats. * diff --git a/libsignal-service/src/main/protowire/CallQualitySurvey.proto b/libsignal-service/src/main/protowire/CallQualitySurvey.proto index edb7e48d8c..7158b1d3ee 100644 --- a/libsignal-service/src/main/protowire/CallQualitySurvey.proto +++ b/libsignal-service/src/main/protowire/CallQualitySurvey.proto @@ -1,25 +1,100 @@ -/* - * Copyright 2025 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only syntax = "proto3"; -option java_package = "org.signal.storageservice.protos.calls.quality"; option java_multiple_files = true; +package org.signal.storageservice.protos.calls.quality; + message SubmitCallQualitySurveyRequest { - optional bool user_satisfied = 1; - repeated string call_quality_issues = 2; + // 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; - optional string debug_log_url = 4; - optional int64 start_timestamp = 5; - optional int64 end_timestamp = 6; - optional string call_type = 7; - optional bool success = 8; - optional string call_end_reason = 9; - optional float rtt_median = 10; - optional float jitter_median = 11; - optional float packet_loss_fraction = 12; - optional bytes call_telemetry = 13; -} \ No newline at end of file + + // 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 microseconds since the epoch (see + // https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types#timestamp_type) + int64 start_timestamp = 5; + + // The time at which the call ended in microseconds since the epoch (see + // https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types#timestamp_type) + 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; +}