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

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