Call quality survey integration.

This commit is contained in:
Alex Hart
2025-11-26 13:51:03 -04:00
committed by jeffrey-signal
parent 804f479cb0
commit 54fb7ff23f
24 changed files with 772 additions and 159 deletions

View File

@@ -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() {

View File

@@ -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")
}
}

View File

@@ -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

View File

@@ -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<CallQualitySheetState> = internalState
fun setUserSatisfiedWithCall(userSatisfiedWithCall: Boolean) {
internalState.update { it.copy(isUserSatisfiedWithCall = userSatisfiedWithCall) }
}
fun onCallQualityIssueSelectionChanged(selection: Set<CallQualityIssue>) {
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))
}
}

View File

@@ -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<CallQualityIssue>): Set<CallQualityIssue> {
return remember(userSelection) {
val displaySet = mutableSetOf<CallQualityIssue>()
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<CallQualityIssue> = 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<CallQualityIssue>)
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<CallQualityIssue>) = 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)
}

View File

@@ -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)
}
)

View File

@@ -31,5 +31,6 @@ data class InternalSettingsState(
val hasPendingOneTimeDonation: Boolean,
val hevcEncoding: Boolean,
val newCallingUi: Boolean,
val callQualitySurveys: Boolean,
val forceSplitPane: Boolean
)

View File

@@ -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()

View File

@@ -449,7 +449,7 @@ object AppDependencies {
fun provideUnauthWebSocket(signalServiceConfigurationSupplier: Supplier<SignalServiceConfiguration>, libSignalNetworkSupplier: Supplier<Network>): 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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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<CallQualitySurveySubmissionJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): CallQualitySurveySubmissionJob {
val jobData = CallQualitySurveySubmissionJobData.ADAPTER.decode(serializedData!!)
return CallQualitySurveySubmissionJob(jobData.request!!, jobData.includeDebugLogs, parameters)
}
}
}

View File

@@ -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());

View File

@@ -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<String> = emptyList()
}

View File

@@ -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()

View File

@@ -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)

View File

@@ -126,6 +126,13 @@ public class SubmitDebugLogRepository {
});
}
@WorkerThread
public Optional<String> 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<Optional<String>> callback) {
SignalExecutors.UNBOUNDED.execute(() -> callback.onResult(submitLogFromReaderInternal(logReader, trace)));
}

View File

@@ -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

View File

@@ -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));
}

View File

@@ -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
}

View File

@@ -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;
}

View File

@@ -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)
}

View File

@@ -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<Unit> {
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.
*

View File

@@ -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;
}
// 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;
}