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.SnackbarDuration
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo 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.PaneExpansionAnchor
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole
import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState 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.CallLogFilter
import org.thoughtcrime.securesms.calls.log.CallLogFragment import org.thoughtcrime.securesms.calls.log.CallLogFragment
import org.thoughtcrime.securesms.calls.new.NewCallActivity 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.DebugLogsPromptDialogFragment
import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment
import org.thoughtcrime.securesms.components.compose.ConnectivityWarningBottomSheet 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.MainNavigationListLocation
import org.thoughtcrime.securesms.main.MainNavigationRail import org.thoughtcrime.securesms.main.MainNavigationRail
import org.thoughtcrime.securesms.main.MainNavigationViewModel import org.thoughtcrime.securesms.main.MainNavigationViewModel
import org.thoughtcrime.securesms.main.MainSnackbar
import org.thoughtcrime.securesms.main.MainToolbar import org.thoughtcrime.securesms.main.MainToolbar
import org.thoughtcrime.securesms.main.MainToolbarCallback import org.thoughtcrime.securesms.main.MainToolbarCallback
import org.thoughtcrime.securesms.main.MainToolbarMode 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) shareDataTimestampViewModel.setTimestampFromActivityCreation(savedInstanceState, intent)
setContent { setContent {
@@ -526,6 +544,15 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
modifier = chatNavGraphState.writeContentToGraphicsLayer(), modifier = chatNavGraphState.writeContentToGraphicsLayer(),
paneExpansionState = paneExpansionState, paneExpansionState = paneExpansionState,
contentWindowInsets = WindowInsets(), contentWindowInsets = WindowInsets(),
snackbarHost = {
if (wrappedNavigator.scaffoldValue.primary == PaneAdaptedValue.Expanded) {
MainSnackbar(
snackbarState = snackbar,
onDismissed = mainBottomChromeCallback::onSnackbarDismissed,
modifier = Modifier.navigationBarsPadding()
)
}
},
bottomNavContent = { bottomNavContent = {
if (isNavigationBarVisible) { if (isNavigationBarVisible) {
Column( Column(
@@ -827,6 +854,10 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
vitalsViewModel.checkSlowNotificationHeuristics() vitalsViewModel.checkSlowNotificationHeuristics()
mainNavigationViewModel.refreshNavigationBarState() mainNavigationViewModel.refreshNavigationBarState()
CallQuality.consumeQualityRequest()?.let {
CallQualityBottomSheetFragment.create(it).show(supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
} }
override fun onStop() { 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.setFragmentResult
import androidx.fragment.app.setFragmentResultListener import androidx.fragment.app.setFragmentResultListener
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.signal.storageservice.protos.calls.quality.SubmitCallQualitySurveyRequest
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity
import org.thoughtcrime.securesms.util.viewModel import org.thoughtcrime.securesms.util.viewModel
import kotlin.time.Duration.Companion.milliseconds
/** /**
* Fragment which manages sheets for walking the user through collecting call * Fragment which manages sheets for walking the user through collecting call
@@ -27,14 +30,23 @@ class CallQualityBottomSheetFragment : ComposeBottomSheetDialogFragment() {
companion object { companion object {
const val REQUEST_KEY = "CallQualityBottomSheetRequestKey" 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 { 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?) { 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) ?: "" val result = bundle.getString(CallQualitySomethingElseFragment.REQUEST_KEY) ?: ""
viewModel.onSomethingElseDescriptionChanged(result) viewModel.onSomethingElseDescriptionChanged(result)
@@ -64,6 +76,10 @@ class CallQualityBottomSheetFragment : ComposeBottomSheetDialogFragment() {
) )
} }
override fun onUserSatisfiedWithCall(isUserSatisfiedWithCall: Boolean) {
viewModel.setUserSatisfiedWithCall(isUserSatisfiedWithCall)
}
override fun describeYourIssue() { override fun describeYourIssue() {
CallQualitySomethingElseFragment.create( CallQualitySomethingElseFragment.create(
viewModel.state.value.somethingElseDescription viewModel.state.value.somethingElseDescription

View File

@@ -9,12 +9,21 @@ import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update 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()) private val internalState = MutableStateFlow(CallQualitySheetState())
val state: StateFlow<CallQualitySheetState> = internalState val state: StateFlow<CallQualitySheetState> = internalState
fun setUserSatisfiedWithCall(userSatisfiedWithCall: Boolean) {
internalState.update { it.copy(isUserSatisfiedWithCall = userSatisfiedWithCall) }
}
fun onCallQualityIssueSelectionChanged(selection: Set<CallQualityIssue>) { fun onCallQualityIssueSelectionChanged(selection: Set<CallQualityIssue>) {
internalState.update { it.copy(selectedQualityIssues = selection) } internalState.update { it.copy(selectedQualityIssues = selection) }
} }
@@ -28,6 +37,19 @@ class CallQualityScreenViewModel : ViewModel() {
} }
fun submit() { 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.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize 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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -73,9 +80,11 @@ fun CallQualitySheet(
when (navEntry) { when (navEntry) {
CallQualitySheetNavEntry.HowWasYourCall -> HowWasYourCall( CallQualitySheetNavEntry.HowWasYourCall -> HowWasYourCall(
onGreatClick = { onGreatClick = {
callback.onUserSatisfiedWithCall(true)
navEntry = CallQualitySheetNavEntry.HelpUsImprove navEntry = CallQualitySheetNavEntry.HelpUsImprove
}, },
onHadIssuesClick = { onHadIssuesClick = {
callback.onUserSatisfiedWithCall(true)
navEntry = CallQualitySheetNavEntry.WhatIssuesDidYouHave navEntry = CallQualitySheetNavEntry.WhatIssuesDidYouHave
}, },
onCancelClick = callback::dismiss onCancelClick = callback::dismiss
@@ -146,7 +155,6 @@ private fun WhatIssuesDidYouHave(
SheetTitle(text = stringResource(R.string.CallQualitySheet__what_issues_did_you_have)) SheetTitle(text = stringResource(R.string.CallQualitySheet__what_issues_did_you_have))
SheetSubtitle(text = stringResource(R.string.CallQualitySheet__select_all_that_apply)) SheetSubtitle(text = stringResource(R.string.CallQualitySheet__select_all_that_apply))
val qualityIssueDisplaySet = rememberQualityDisplaySet(selectedQualityIssues)
val onCallQualityIssueClick: (CallQualityIssue) -> Unit = remember(selectedQualityIssues, onCallQualityIssueSelectionChanged) { val onCallQualityIssueClick: (CallQualityIssue) -> Unit = remember(selectedQualityIssues, onCallQualityIssueSelectionChanged) {
{ issue -> { issue ->
val isRemoving = issue in selectedQualityIssues 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( FlowRow(
modifier = Modifier modifier = Modifier
.animateContentSize() .animateContentSize()
@@ -179,104 +190,206 @@ private fun WhatIssuesDidYouHave(
.horizontalGutters(), .horizontalGutters(),
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
qualityIssueDisplaySet.forEach { issue -> IssueChip(
val isIssueSelected = issue in selectedQualityIssues issue = CallQualityIssue.AUDIO_ISSUE,
isSelected = isAudioExpanded,
onClick = { onCallQualityIssueClick(CallQualityIssue.AUDIO_ISSUE) }
)
InputChip( AnimatedIssueChip(
selected = isIssueSelected, visible = isAudioExpanded,
onClick = { issue = CallQualityIssue.AUDIO_STUTTERING,
onCallQualityIssueClick(issue) isSelected = CallQualityIssue.AUDIO_STUTTERING in selectedQualityIssues,
}, onClick = { onCallQualityIssueClick(CallQualityIssue.AUDIO_STUTTERING) }
colors = InputChipDefaults.inputChipColors( )
leadingIconColor = MaterialTheme.colorScheme.onSurface,
selectedLeadingIconColor = MaterialTheme.colorScheme.onSurface, AnimatedIssueChip(
labelColor = MaterialTheme.colorScheme.onSurface visible = isAudioExpanded,
), issue = CallQualityIssue.AUDIO_CUT_OUT,
leadingIcon = { isSelected = CallQualityIssue.AUDIO_CUT_OUT in selectedQualityIssues,
Icon( onClick = { onCallQualityIssueClick(CallQualityIssue.AUDIO_CUT_OUT) }
imageVector = if (isIssueSelected) { )
ImageVector.vectorResource(R.drawable.symbol_check_24)
} else { AnimatedIssueChip(
ImageVector.vectorResource(issue.category.icon) visible = isAudioExpanded,
}, issue = CallQualityIssue.AUDIO_I_HEARD_ECHO,
contentDescription = null isSelected = CallQualityIssue.AUDIO_I_HEARD_ECHO in selectedQualityIssues,
) onClick = { onCallQualityIssueClick(CallQualityIssue.AUDIO_I_HEARD_ECHO) }
}, )
label = {
Text(text = stringResource(issue.label)) AnimatedIssueChip(
}, visible = isAudioExpanded,
modifier = Modifier.padding(horizontal = 4.dp) 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 textColor = if (somethingElseDescription.isNotEmpty()) {
val text = somethingElseDescription.ifEmpty { MaterialTheme.colorScheme.onSurface
stringResource(R.string.CallQualitySheet__describe_your_issue) } else {
} MaterialTheme.colorScheme.onSurfaceVariant
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)
)
} }
Row( val textUnderlineStrokeWidthPx = with(LocalDensity.current) {
horizontalArrangement = Arrangement.SpaceBetween, 1.dp.toPx()
verticalAlignment = Alignment.CenterVertically, }
val textUnderlineColor = MaterialTheme.colorScheme.outline
Text(
text = text,
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier modifier = Modifier
.clickable(
role = Role.Button,
onClick = onDescribeYourIssueClick
)
.fillMaxWidth() .fillMaxWidth()
.padding(top = 32.dp, bottom = 24.dp) .horizontalGutters()
) { .padding(top = 24.dp)
CancelButton( .background(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp))
onClick = onCancelClick .drawWithContent {
) drawContent()
Buttons.LargeTonal( val width = size.width
onClick = onContinueClick val height = size.height - textUnderlineStrokeWidthPx / 2f
) {
Text(text = stringResource(R.string.CallQualitySheet__continue)) 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun HelpUsImprove( 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( data class CallQualitySheetState(
val isUserSatisfiedWithCall: Boolean = false,
val selectedQualityIssues: Set<CallQualityIssue> = emptySet(), val selectedQualityIssues: Set<CallQualityIssue> = emptySet(),
val somethingElseDescription: String = "", val somethingElseDescription: String = "",
val isShareDebugLogSelected: Boolean = false val isShareDebugLogSelected: Boolean = false
@@ -584,6 +672,7 @@ data class CallQualitySheetState(
interface CallQualitySheetCallback { interface CallQualitySheetCallback {
fun dismiss() fun dismiss()
fun viewDebugLog() fun viewDebugLog()
fun onUserSatisfiedWithCall(isUserSatisfiedWithCall: Boolean)
fun describeYourIssue() fun describeYourIssue()
fun onCallQualityIssueSelectionChanged(selection: Set<CallQualityIssue>) fun onCallQualityIssueSelectionChanged(selection: Set<CallQualityIssue>)
fun onShareDebugLogChanged(shareDebugLog: Boolean) fun onShareDebugLogChanged(shareDebugLog: Boolean)
@@ -592,6 +681,7 @@ interface CallQualitySheetCallback {
object Empty : CallQualitySheetCallback { object Empty : CallQualitySheetCallback {
override fun dismiss() = Unit override fun dismiss() = Unit
override fun viewDebugLog() = Unit override fun viewDebugLog() = Unit
override fun onUserSatisfiedWithCall(isUserSatisfiedWithCall: Boolean) = Unit
override fun describeYourIssue() = Unit override fun describeYourIssue() = Unit
override fun onCallQualityIssueSelectionChanged(selection: Set<CallQualityIssue>) = Unit override fun onCallQualityIssueSelectionChanged(selection: Set<CallQualityIssue>) = Unit
override fun onShareDebugLogChanged(shareDebugLog: Boolean) = Unit override fun onShareDebugLogChanged(shareDebugLog: Boolean) = Unit
@@ -615,18 +705,19 @@ enum class CallQualityIssueCategory(
} }
enum class CallQualityIssue( enum class CallQualityIssue(
val code: String,
val category: CallQualityIssueCategory, val category: CallQualityIssueCategory,
@param:StringRes val label: Int @param:StringRes val label: Int
) { ) {
AUDIO_ISSUE(category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__audio_issue), AUDIO_ISSUE(code = "audio", category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__audio_issue),
AUDIO_STUTTERING(category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__audio_stuttering), AUDIO_STUTTERING(code = "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_CUT_OUT(code = "audio_drop", 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_I_HEARD_ECHO(code = "audio_remote_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), AUDIO_OTHERS_HEARD_ECHO(code = "audio_local_echo", category = CallQualityIssueCategory.AUDIO, label = R.string.CallQualityIssue__others_heard_echo),
VIDEO_ISSUE(category = CallQualityIssueCategory.VIDEO, label = R.string.CallQualityIssue__video_issue), VIDEO_ISSUE(code = "video", category = CallQualityIssueCategory.VIDEO, label = R.string.CallQualityIssue__video_issue),
VIDEO_POOR_QUALITY(category = CallQualityIssueCategory.VIDEO, label = R.string.CallQualityIssue__poor_video_quality), VIDEO_POOR_QUALITY(code = "video_low_quality", category = CallQualityIssueCategory.VIDEO, label = R.string.CallQualityIssue__poor_video_quality),
VIDEO_LOW_RESOLUTION(category = CallQualityIssueCategory.VIDEO, label = R.string.CallQualityIssue__low_resolution), VIDEO_LOW_RESOLUTION(code = "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), VIDEO_CAMERA_MALFUNCTION(code = "video_no_camera", category = CallQualityIssueCategory.VIDEO, label = R.string.CallQualityIssue__camera_did_not_work),
CALL_DROPPED(category = CallQualityIssueCategory.CALL_DROPPED, label = R.string.CallQualityIssue__call_droppped), CALL_DROPPED(code = "call_dropped", category = CallQualityIssueCategory.CALL_DROPPED, label = R.string.CallQualityIssue__call_droppped),
SOMETHING_ELSE(category = CallQualityIssueCategory.SOMETHING_ELSE, label = R.string.CallQualityIssue__something_else) 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( switchPref(
title = DSLSettingsText.from("Display Call Quality Survey UX"), title = DSLSettingsText.from("Enable call quality surveys"),
isChecked = state.callQualitySurveys,
onClick = { onClick = {
CallQualityBottomSheetFragment().show(parentFragmentManager, null) viewModel.setEnableCallQualitySurveys(!state.callQualitySurveys)
} }
) )

View File

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

View File

@@ -197,6 +197,7 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
hasPendingOneTimeDonation = SignalStore.inAppPayments.getPendingOneTimeDonation() != null, hasPendingOneTimeDonation = SignalStore.inAppPayments.getPendingOneTimeDonation() != null,
hevcEncoding = SignalStore.internal.hevcEncoding, hevcEncoding = SignalStore.internal.hevcEncoding,
newCallingUi = SignalStore.internal.newCallingUi, newCallingUi = SignalStore.internal.newCallingUi,
callQualitySurveys = SignalStore.internal.callQualitySurveys,
forceSplitPane = SignalStore.internal.forceSplitPane forceSplitPane = SignalStore.internal.forceSplitPane
) )
@@ -213,6 +214,11 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
refresh() refresh()
} }
fun setEnableCallQualitySurveys(enabled: Boolean) {
SignalStore.internal.callQualitySurveys = enabled
refresh()
}
fun setForceSplitPane(forceSplitPane: Boolean) { fun setForceSplitPane(forceSplitPane: Boolean) {
SignalStore.internal.forceSplitPane = forceSplitPane SignalStore.internal.forceSplitPane = forceSplitPane
refresh() refresh()

View File

@@ -449,7 +449,7 @@ object AppDependencies {
fun provideUnauthWebSocket(signalServiceConfigurationSupplier: Supplier<SignalServiceConfiguration>, libSignalNetworkSupplier: Supplier<Network>): SignalWebSocket.UnauthenticatedWebSocket fun provideUnauthWebSocket(signalServiceConfigurationSupplier: Supplier<SignalServiceConfiguration>, libSignalNetworkSupplier: Supplier<Network>): SignalWebSocket.UnauthenticatedWebSocket
fun provideAccountApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): AccountApi fun provideAccountApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): AccountApi
fun provideUsernameApi(unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): UsernameApi 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 providePaymentsApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): PaymentsApi
fun provideCdsApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): CdsApi fun provideCdsApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): CdsApi
fun provideRateLimitChallengeApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): RateLimitChallengeApi fun provideRateLimitChallengeApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): RateLimitChallengeApi

View File

@@ -525,8 +525,8 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
} }
@Override @Override
public @NonNull CallingApi provideCallingApi(@NonNull SignalWebSocket.AuthenticatedWebSocket authWebSocket, @NonNull PushServiceSocket pushServiceSocket) { public @NonNull CallingApi provideCallingApi(@NonNull SignalWebSocket.AuthenticatedWebSocket authWebSocket, @NonNull SignalWebSocket.UnauthenticatedWebSocket unauthWebSocket, @NonNull PushServiceSocket pushServiceSocket) {
return new CallingApi(authWebSocket, pushServiceSocket); return new CallingApi(authWebSocket, unauthWebSocket, pushServiceSocket);
} }
@Override @Override

View File

@@ -180,7 +180,7 @@ class NetworkDependenciesModule(
} }
val callingApi: CallingApi by lazy { val callingApi: CallingApi by lazy {
provider.provideCallingApi(authWebSocket, pushServiceSocket) provider.provideCallingApi(authWebSocket, unauthWebSocket, pushServiceSocket)
} }
val paymentsApi: PaymentsApi by lazy { 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(CallLinkPeekJob.KEY, new CallLinkPeekJob.Factory());
put(CallLinkUpdateSendJob.KEY, new CallLinkUpdateSendJob.Factory()); put(CallLinkUpdateSendJob.KEY, new CallLinkUpdateSendJob.Factory());
put(CallLogEventSendJob.KEY, new CallLogEventSendJob.Factory()); put(CallLogEventSendJob.KEY, new CallLogEventSendJob.Factory());
put(CallQualitySurveySubmissionJob.KEY, new CallQualitySurveySubmissionJob.Factory());
put(CallSyncEventJob.KEY, new CallSyncEventJob.Factory()); put(CallSyncEventJob.KEY, new CallSyncEventJob.Factory());
put(CancelRestoreMediaJob.KEY, new CancelRestoreMediaJob.Factory()); put(CancelRestoreMediaJob.KEY, new CancelRestoreMediaJob.Factory());
put(CheckRestoreMediaLeftJob.KEY, new CheckRestoreMediaLeftJob.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 WEB_SOCKET_SHADOWING_STATS: String = "internal.web_socket_shadowing_stats"
const val ENCODE_HEVC: String = "internal.hevc_encoding" const val ENCODE_HEVC: String = "internal.hevc_encoding"
const val NEW_CALL_UI: String = "internal.new.call.ui" 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 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 SHOW_ARCHIVE_STATE_HINT: String = "internal.show_archive_state_hint"
const val INCLUDE_DEBUGLOG_IN_BACKUP: String = "internal.include_debuglog_in_backup" 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 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 lastScrollPosition: Int by integerValue(LAST_SCROLL_POSITION, 0).defaultForExternalUsers()
var useConversationItemV2Media by booleanValue(CONVERSATION_ITEM_V2_MEDIA, false).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 storyValues = StoryValues(store)
val apkUpdateValues = ApkUpdateValues(store) val apkUpdateValues = ApkUpdateValues(store)
val backupValues = BackupValues(store) val backupValues = BackupValues(store)
val callQualityValues = CallQualityValues(store)
val plainTextValues = PlainTextSharedPrefsDataStore(context) val plainTextValues = PlainTextSharedPrefsDataStore(context)
@@ -84,6 +85,7 @@ class SignalStore(context: Application, private val store: KeyValueStore) {
story.onFirstEverAppLaunch() story.onFirstEverAppLaunch()
apkUpdate.onFirstEverAppLaunch() apkUpdate.onFirstEverAppLaunch()
backup.onFirstEverAppLaunch() backup.onFirstEverAppLaunch()
callQuality.onFirstEverAppLaunch()
} }
@JvmStatic @JvmStatic
@@ -115,7 +117,8 @@ class SignalStore(context: Application, private val store: KeyValueStore) {
releaseChannel.keysToIncludeInBackup + releaseChannel.keysToIncludeInBackup +
story.keysToIncludeInBackup + story.keysToIncludeInBackup +
apkUpdate.keysToIncludeInBackup + apkUpdate.keysToIncludeInBackup +
backup.keysToIncludeInBackup backup.keysToIncludeInBackup +
callQuality.keysToIncludeInBackup
} }
/** /**
@@ -266,6 +269,11 @@ class SignalStore(context: Application, private val store: KeyValueStore) {
val backup: BackupValues val backup: BackupValues
get() = instance!!.backupValues get() = instance!!.backupValues
@JvmStatic
@get:JvmName("callQuality")
val callQuality: CallQualityValues
get() = instance!!.callQualityValues
val groupsV2AciAuthorizationCache: GroupsV2AuthorizationSignalStoreCache val groupsV2AciAuthorizationCache: GroupsV2AuthorizationSignalStoreCache
get() = GroupsV2AuthorizationSignalStoreCache.createAciCache(instance!!.store) 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) { public void submitLogFromReader(DebugLogsViewer.LogReader logReader, @Nullable byte[] trace, Callback<Optional<String>> callback) {
SignalExecutors.UNBOUNDED.execute(() -> callback.onResult(submitLogFromReaderInternal(logReader, trace))); 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() Modifier.navigationBarsPadding()
} else { } else {
Modifier Modifier
@@ -121,7 +125,7 @@ fun MainBottomChrome(
} }
@Composable @Composable
private fun MainSnackbar( fun MainSnackbar(
snackbarState: SnackbarState?, snackbarState: SnackbarState?,
onDismissed: () -> Unit, onDismissed: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier

View File

@@ -35,6 +35,7 @@ import org.signal.ringrtc.NetworkRoute;
import org.signal.ringrtc.PeekInfo; import org.signal.ringrtc.PeekInfo;
import org.signal.ringrtc.Remote; import org.signal.ringrtc.Remote;
import org.signal.storageservice.protos.groups.GroupExternalCredential; 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.components.webrtc.v2.CallIntent;
import org.thoughtcrime.securesms.crypto.SealedSenderAccessUtil; import org.thoughtcrime.securesms.crypto.SealedSenderAccessUtil;
import org.thoughtcrime.securesms.database.CallLinkTable; 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.service.webrtc.state.WebRtcServiceState;
import org.thoughtcrime.securesms.util.AppForegroundObserver; import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.RecipientAccessList; import org.thoughtcrime.securesms.util.RecipientAccessList;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.rx.RxStore; 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); 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) { switch (reason) {
case LOCAL_HANGUP: case LOCAL_HANGUP:
@@ -1025,7 +1033,7 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall.
@Override @Override
public void onEnded(@NonNull GroupCall groupCall, @NonNull CallManager.CallEndReason reason, @NonNull CallSummary summary) { 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)); process((s, p) -> p.handleGroupCallEnded(s, groupCall.hashCode(), reason));
} }

View File

@@ -1229,5 +1229,21 @@ object RemoteConfig {
defaultValue = false, defaultValue = false,
hotSwappable = true 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 // endregion
} }

View File

@@ -3,6 +3,7 @@ syntax = "proto3";
package signal; package signal;
import "ResumableUploads.proto"; import "ResumableUploads.proto";
import "CallQualitySurvey.proto";
option java_package = "org.thoughtcrime.securesms.jobs.protos"; option java_package = "org.thoughtcrime.securesms.jobs.protos";
option java_multiple_files = true; option java_multiple_files = true;
@@ -257,4 +258,9 @@ message UnpinJobData {
uint64 messageId = 1; uint64 messageId = 1;
repeated uint64 recipients = 2; repeated uint64 recipients = 2;
uint32 initialRecipientCount = 3; 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) 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) 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.CreateCallLinkCredentialRequest
import org.signal.libsignal.zkgroup.calllinks.CreateCallLinkCredentialResponse 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.NetworkResult
import org.whispersystems.signalservice.api.messages.calls.CallingResponse import org.whispersystems.signalservice.api.messages.calls.CallingResponse
import org.whispersystems.signalservice.api.messages.calls.TurnServerInfo 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.CreateCallLinkAuthResponse
import org.whispersystems.signalservice.internal.push.GetCallingRelaysResponse import org.whispersystems.signalservice.internal.push.GetCallingRelaysResponse
import org.whispersystems.signalservice.internal.push.PushServiceSocket import org.whispersystems.signalservice.internal.push.PushServiceSocket
import org.whispersystems.signalservice.internal.putCustom
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
/** /**
@@ -24,9 +26,28 @@ import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessa
*/ */
class CallingApi( class CallingApi(
private val auth: SignalWebSocket.AuthenticatedWebSocket, private val auth: SignalWebSocket.AuthenticatedWebSocket,
private val unAuth: SignalWebSocket.UnauthenticatedWebSocket,
private val pushServiceSocket: PushServiceSocket 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. * Get 1:1 relay addresses in IpV4, Ipv6, and URL formats.
* *

View File

@@ -1,25 +1,100 @@
/* // Copyright 2025 Signal Messenger, LLC
* Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only
* SPDX-License-Identifier: AGPL-3.0-only
*/
syntax = "proto3"; syntax = "proto3";
option java_package = "org.signal.storageservice.protos.calls.quality";
option java_multiple_files = true; option java_multiple_files = true;
package org.signal.storageservice.protos.calls.quality;
message SubmitCallQualitySurveyRequest { message SubmitCallQualitySurveyRequest {
optional bool user_satisfied = 1; // Indicates whether the caller was generally satisfied with the quality of
repeated string call_quality_issues = 2; // 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 additional_issues_description = 3;
optional string debug_log_url = 4;
optional int64 start_timestamp = 5; // A URL for a set of debug logs associated with the call if the caller chose
optional int64 end_timestamp = 6; // to submit debug logs
optional string call_type = 7; optional string debug_log_url = 4;
optional bool success = 8;
optional string call_end_reason = 9; // The time at which the call started in microseconds since the epoch (see
optional float rtt_median = 10; // https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types#timestamp_type)
optional float jitter_median = 11; int64 start_timestamp = 5;
optional float packet_loss_fraction = 12;
optional bytes call_telemetry = 13; // 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;
}