Add initial Call Quality UX.

This commit is contained in:
Alex Hart
2025-10-20 16:17:05 -03:00
committed by Greyson Parrelli
parent 6e0bfa2cee
commit f38262c0ab
9 changed files with 1014 additions and 2 deletions

View File

@@ -0,0 +1,87 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.quality
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.core.os.bundleOf
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.setFragmentResultListener
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity
import org.thoughtcrime.securesms.util.viewModel
/**
* Fragment which manages sheets for walking the user through collecting call
* quality feedback.
*/
class CallQualityBottomSheetFragment : ComposeBottomSheetDialogFragment() {
companion object {
const val REQUEST_KEY = "CallQualityBottomSheetRequestKey"
}
private val viewModel: CallQualityScreenViewModel by viewModel {
CallQualityScreenViewModel()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
setFragmentResultListener(CallQualitySomethingElseFragment.REQUEST_KEY) { key, bundle ->
val result = bundle.getString(CallQualitySomethingElseFragment.REQUEST_KEY) ?: ""
viewModel.onSomethingElseDescriptionChanged(result)
}
}
@Composable
override fun SheetContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
CallQualitySheet(
state = state,
callback = remember { Callback() }
)
}
private inner class Callback : CallQualitySheetCallback {
override fun dismiss() {
this@CallQualityBottomSheetFragment.dismissAllowingStateLoss()
}
override fun viewDebugLog() {
startActivity(
Intent(requireContext(), SubmitDebugLogActivity::class.java).apply {
putExtra(SubmitDebugLogActivity.ARG_VIEW_ONLY, true)
}
)
}
override fun describeYourIssue() {
CallQualitySomethingElseFragment.create(
viewModel.state.value.somethingElseDescription
).show(parentFragmentManager, null)
}
override fun onCallQualityIssueSelectionChanged(selection: Set<CallQualityIssue>) {
viewModel.onCallQualityIssueSelectionChanged(selection)
}
override fun onShareDebugLogChanged(shareDebugLog: Boolean) {
viewModel.onShareDebugLogChanged(shareDebugLog)
}
override fun submit() {
viewModel.submit()
dismiss()
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to true))
}
}
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.quality
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
class CallQualityScreenViewModel : ViewModel() {
private val internalState = MutableStateFlow(CallQualitySheetState())
val state: StateFlow<CallQualitySheetState> = internalState
fun onCallQualityIssueSelectionChanged(selection: Set<CallQualityIssue>) {
internalState.update { it.copy(selectedQualityIssues = selection) }
}
fun onSomethingElseDescriptionChanged(somethingElseDescription: String) {
internalState.update { it.copy(somethingElseDescription = somethingElseDescription) }
}
fun onShareDebugLogChanged(shareDebugLog: Boolean) {
internalState.update { it.copy(isShareDebugLogSelected = shareDebugLog) }
}
fun submit() {
// Enqueue job.
}
}

View File

@@ -0,0 +1,632 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.quality
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.InputChip
import androidx.compose.material3.InputChipDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withLink
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.IconButtons
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CallQualitySheet(
state: CallQualitySheetState = remember { CallQualitySheetState() },
callback: CallQualitySheetCallback = CallQualitySheetCallback.Empty
) {
var navEntry: CallQualitySheetNavEntry by remember { mutableStateOf(CallQualitySheetNavEntry.HowWasYourCall) }
when (navEntry) {
CallQualitySheetNavEntry.HowWasYourCall -> HowWasYourCall(
onGreatClick = {
navEntry = CallQualitySheetNavEntry.HelpUsImprove
},
onHadIssuesClick = {
navEntry = CallQualitySheetNavEntry.WhatIssuesDidYouHave
},
onCancelClick = callback::dismiss
)
CallQualitySheetNavEntry.WhatIssuesDidYouHave -> WhatIssuesDidYouHave(
selectedQualityIssues = state.selectedQualityIssues,
somethingElseDescription = state.somethingElseDescription,
onCallQualityIssueSelectionChanged = callback::onCallQualityIssueSelectionChanged,
onContinueClick = {
navEntry = CallQualitySheetNavEntry.HelpUsImprove
},
onDescribeYourIssueClick = callback::describeYourIssue,
onCancelClick = callback::dismiss
)
CallQualitySheetNavEntry.HelpUsImprove -> HelpUsImprove(
isShareDebugLogSelected = state.isShareDebugLogSelected,
onViewDebugLogClick = callback::viewDebugLog,
onCancelClick = callback::dismiss,
onShareDebugLogChanged = callback::onShareDebugLogChanged,
onSubmitClick = callback::submit
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun HowWasYourCall(
onHadIssuesClick: () -> Unit,
onGreatClick: () -> Unit,
onCancelClick: () -> Unit
) {
Sheet(onDismissRequest = onCancelClick) {
SheetTitle(text = stringResource(R.string.CallQualitySheet__how_was_your_call))
SheetSubtitle(text = stringResource(R.string.CallQualitySheet__how_was_your_call_subtitle))
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp)
) {
HadIssuesButton(onClick = onHadIssuesClick)
GreatButton(onClick = onGreatClick)
}
CancelButton(
onClick = onCancelClick,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(top = 32.dp, bottom = 24.dp)
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun WhatIssuesDidYouHave(
selectedQualityIssues: Set<CallQualityIssue>,
somethingElseDescription: String,
onCallQualityIssueSelectionChanged: (Set<CallQualityIssue>) -> Unit,
onCancelClick: () -> Unit,
onContinueClick: () -> Unit,
onDescribeYourIssueClick: () -> Unit
) {
Sheet(onDismissRequest = onCancelClick) {
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
val selection = when {
isRemoving && issue == CallQualityIssue.AUDIO_ISSUE -> {
selectedQualityIssues.filterNot { it.category == CallQualityIssueCategory.AUDIO }.toSet()
}
isRemoving && issue == CallQualityIssue.VIDEO_ISSUE -> {
selectedQualityIssues.filterNot { it.category == CallQualityIssueCategory.VIDEO }.toSet()
}
isRemoving -> {
selectedQualityIssues - issue
}
else -> {
selectedQualityIssues + issue
}
}
onCallQualityIssueSelectionChanged(selection)
}
}
FlowRow(
modifier = Modifier
.animateContentSize()
.fillMaxWidth()
.horizontalGutters(),
horizontalArrangement = Arrangement.Center
) {
qualityIssueDisplaySet.forEach { issue ->
val isIssueSelected = issue in selectedQualityIssues
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)
)
}
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)
)
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(top = 32.dp, bottom = 24.dp)
) {
CancelButton(
onClick = onCancelClick
)
Buttons.LargeTonal(
onClick = onContinueClick
) {
Text(text = stringResource(R.string.CallQualitySheet__continue))
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun HelpUsImprove(
isShareDebugLogSelected: Boolean,
onShareDebugLogChanged: (Boolean) -> Unit,
onViewDebugLogClick: () -> Unit,
onCancelClick: () -> Unit,
onSubmitClick: () -> Unit
) {
Sheet(onDismissRequest = onCancelClick) {
SheetTitle(text = stringResource(R.string.CallQualitySheet__help_us_improve))
SheetSubtitle(
text = buildAnnotatedString {
append(stringResource(R.string.CallQualitySheet__help_us_improve_description_prefix))
append(" ")
withLink(
link = LinkAnnotation.Clickable(
"view-your-debug-log",
linkInteractionListener = { onViewDebugLogClick() },
styles = TextLinkStyles(style = SpanStyle(color = MaterialTheme.colorScheme.primary))
)
) {
append(stringResource(R.string.CallQualitySheet__view_your_debug_log))
}
append(" ")
append(stringResource(R.string.CallQualitySheet__help_us_improve_description_suffix))
}
)
Rows.ToggleRow(
checked = isShareDebugLogSelected,
text = stringResource(R.string.CallQualitySheet__share_debug_log),
onCheckChanged = onShareDebugLogChanged
)
Text(
text = stringResource(R.string.CallQualitySheet__debug_log_privacy_notice),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.horizontalGutters().padding(top = 14.dp)
)
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.horizontalGutters()
.padding(top = 32.dp, bottom = 24.dp)
) {
CancelButton(
onClick = onCancelClick
)
Buttons.LargeTonal(
onClick = onSubmitClick
) {
Text(text = stringResource(R.string.CallQualitySheet__submit))
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun Sheet(
onDismissRequest: () -> Unit,
content: @Composable ColumnScope.() -> Unit
) {
ModalBottomSheet(
onDismissRequest = onDismissRequest,
dragHandle = null,
sheetGesturesEnabled = false,
sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
) {
content()
}
}
@Composable
private fun SheetTitle(
text: String
) {
Text(
text = text,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier
.fillMaxWidth()
.padding(top = 46.dp, bottom = 10.dp)
.horizontalGutters()
)
}
@Composable
private fun SheetSubtitle(
text: String
) {
Text(
text = text,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.fillMaxWidth()
.horizontalGutters()
)
}
@Composable
private fun SheetSubtitle(
text: AnnotatedString
) {
Text(
text = text,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.horizontalGutters()
)
}
@Composable
private fun HadIssuesButton(
onClick: () -> Unit
) {
FeedbackButton(
text = stringResource(R.string.CallQualitySheet__had_issues),
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.error,
onClick = onClick
)
}
@Composable
private fun GreatButton(
onClick: () -> Unit
) {
FeedbackButton(
text = stringResource(R.string.CallQualitySheet__great),
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.primary,
onClick = onClick
)
}
@Composable
private fun FeedbackButton(
text: String,
onClick: () -> Unit,
containerColor: Color,
contentColor: Color
// imageVector icon
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = spacedBy(12.dp)
) {
IconButtons.IconButton(
onClick = onClick,
size = 72.dp,
modifier = Modifier
.clip(CircleShape)
.background(color = containerColor)
) {
// TODO - icon with contentcolor tint
}
Text(
text = text,
style = MaterialTheme.typography.bodyLarge
)
}
}
@Composable
fun CancelButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
TextButton(onClick = onClick, modifier = modifier) {
Text(text = stringResource(android.R.string.cancel))
}
}
@PreviewLightDark
@Composable
private fun CallQualityScreenPreview() {
var state by remember { mutableStateOf(CallQualitySheetState()) }
Previews.Preview {
CallQualitySheet(
state = state,
callback = remember {
object : CallQualitySheetCallback by CallQualitySheetCallback.Empty {
override fun onCallQualityIssueSelectionChanged(selection: Set<CallQualityIssue>) {
state = state.copy(selectedQualityIssues = selection)
}
}
}
)
}
}
@PreviewLightDark
@Composable
private fun HowWasYourCallPreview() {
Previews.BottomSheetPreview {
Column {
HowWasYourCall(
onGreatClick = {},
onCancelClick = {},
onHadIssuesClick = {}
)
}
}
}
@PreviewLightDark
@Composable
private fun WhatIssuesDidYouHavePreview() {
Previews.BottomSheetPreview {
var userSelection by remember { mutableStateOf<Set<CallQualityIssue>>(emptySet()) }
Column {
WhatIssuesDidYouHave(
selectedQualityIssues = userSelection,
somethingElseDescription = "",
onCallQualityIssueSelectionChanged = {
userSelection = it
},
onCancelClick = {},
onContinueClick = {},
onDescribeYourIssueClick = {}
)
}
}
}
@PreviewLightDark
@Composable
private fun HelpUsImprovePreview() {
Previews.BottomSheetPreview {
Column {
HelpUsImprove(
isShareDebugLogSelected = true,
onViewDebugLogClick = {},
onCancelClick = {},
onShareDebugLogChanged = {},
onSubmitClick = {}
)
}
}
}
@PreviewLightDark
@Composable
private fun SomethingElseContentPreview() {
Previews.Preview {
CallQualitySomethingElseScreen(
somethingElseDescription = "About 5 minutes into a call with my friend",
onCancelClick = {},
onSaveClick = {}
)
}
}
@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 selectedQualityIssues: Set<CallQualityIssue> = emptySet(),
val somethingElseDescription: String = "",
val isShareDebugLogSelected: Boolean = false
)
interface CallQualitySheetCallback {
fun dismiss()
fun viewDebugLog()
fun describeYourIssue()
fun onCallQualityIssueSelectionChanged(selection: Set<CallQualityIssue>)
fun onShareDebugLogChanged(shareDebugLog: Boolean)
fun submit()
object Empty : CallQualitySheetCallback {
override fun dismiss() = Unit
override fun viewDebugLog() = Unit
override fun describeYourIssue() = Unit
override fun onCallQualityIssueSelectionChanged(selection: Set<CallQualityIssue>) = Unit
override fun onShareDebugLogChanged(shareDebugLog: Boolean) = Unit
override fun submit() = Unit
}
}
private enum class CallQualitySheetNavEntry {
HowWasYourCall,
WhatIssuesDidYouHave,
HelpUsImprove
}
enum class CallQualityIssueCategory(
@param:DrawableRes val icon: Int
) {
AUDIO(icon = R.drawable.symbol_speaker_24),
VIDEO(icon = R.drawable.symbol_video_24),
CALL_DROPPED(icon = R.drawable.symbol_x_circle_24),
SOMETHING_ELSE(icon = R.drawable.symbol_error_circle_24)
}
enum class CallQualityIssue(
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)
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.quality
import android.app.Dialog
import android.os.Bundle
import android.view.WindowManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.setFragmentResult
import org.thoughtcrime.securesms.compose.ComposeFullScreenDialogFragment
/**
* Fragment which allows user to enter additional text to describe a call issue.
*/
class CallQualitySomethingElseFragment : ComposeFullScreenDialogFragment() {
companion object {
const val REQUEST_KEY = "CallQualitySomethingElseRequestKey"
fun create(somethingElseDescription: String): DialogFragment {
return CallQualitySomethingElseFragment().apply {
arguments = bundleOf(REQUEST_KEY to somethingElseDescription)
}
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
dialog.window!!.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
return dialog
}
@Composable
override fun DialogContent() {
val initialState = remember { requireArguments().getString(REQUEST_KEY) ?: "" }
CallQualitySomethingElseScreen(
somethingElseDescription = initialState,
onSaveClick = {
dismissAllowingStateLoss()
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
},
onCancelClick = {
dismissAllowingStateLoss()
}
)
}
}

View File

@@ -0,0 +1,106 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.quality
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.TextFields
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
@Composable
fun CallQualitySomethingElseScreen(
somethingElseDescription: String,
onCancelClick: () -> Unit,
onSaveClick: (String) -> Unit
) {
Scaffolds.Settings(
title = stringResource(R.string.CallQualitySomethingElseScreen__title),
navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24),
onNavigationClick = onCancelClick,
navigationContentDescription = stringResource(R.string.CallQualitySomethingElseScreen__back),
modifier = Modifier.imePadding()
) { paddingValues ->
var issue by remember { mutableStateOf(somethingElseDescription) }
val focusRequester = remember { FocusRequester() }
Column(
modifier = Modifier.padding(paddingValues)
) {
TextFields.TextField(
label = {
Text(stringResource(R.string.CallQualitySomethingElseScreen__describe_your_issue))
},
value = issue,
minLines = 4,
maxLines = 4,
onValueChange = {
issue = it
},
modifier = Modifier
.focusRequester(focusRequester)
.fillMaxWidth()
.horizontalGutters()
)
Text(
text = stringResource(R.string.CallQualitySomethingElseScreen__privacy_notice),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.horizontalGutters()
.padding(top = 24.dp, bottom = 32.dp)
)
Spacer(modifier = Modifier.weight(1f))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.horizontalGutters()
.padding(bottom = 16.dp)
) {
CancelButton(
onClick = onCancelClick
)
Buttons.LargeTonal(
onClick = { onSaveClick(issue) }
) {
Text(text = stringResource(R.string.CallQualitySomethingElseScreen__save))
}
}
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
}

View File

@@ -8,10 +8,12 @@ import android.os.Bundle
import android.view.View
import android.widget.EditText
import android.widget.Toast
import androidx.fragment.app.setFragmentResultListener
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import org.signal.core.util.AppUtil
import org.signal.core.util.ThreadUtil
import org.signal.core.util.concurrent.SignalExecutors
@@ -23,6 +25,7 @@ import org.signal.core.util.requireString
import org.signal.ringrtc.CallManager
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.quality.CallQualityBottomSheetFragment
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
@@ -92,6 +95,12 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
scrollToPosition = SignalStore.internal.lastScrollPosition
setFragmentResultListener(CallQualityBottomSheetFragment.REQUEST_KEY) { _, bundle ->
if (bundle.getBoolean(CallQualityBottomSheetFragment.REQUEST_KEY, false)) {
Snackbar.make(requireView(), R.string.CallQualitySheet__thanks_for_your_feedback, Snackbar.LENGTH_SHORT).show()
}
}
}
override fun bindAdapter(adapter: MappingAdapter) {
@@ -583,6 +592,13 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
clickPref(
title = DSLSettingsText.from("Display Call Quality Survey UX"),
onClick = {
CallQualityBottomSheetFragment().show(parentFragmentManager, null)
}
)
radioListPref(
title = DSLSettingsText.from("Bandwidth mode"),
listItems = CallManager.DataMode.entries.map { it.name }.toTypedArray(),

View File

@@ -20,6 +20,7 @@ import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.core.app.ActivityCompat;
import androidx.core.app.ShareCompat;
import androidx.core.content.ContextCompat;
import androidx.core.text.util.LinkifyCompat;
@@ -52,6 +53,8 @@ public class SubmitDebugLogActivity extends BaseActivity {
private static final int CODE_SAVE = 24601;
public static final String ARG_VIEW_ONLY = "args.view_only";
private WebView logWebView;
private SubmitDebugLogViewModel viewModel;
@@ -335,7 +338,14 @@ public class SubmitDebugLogActivity extends BaseActivity {
subscribeToLogLines();
});
submitButton.setOnClickListener(v -> onSubmitClicked());
boolean isViewOnly = getIntent().getBooleanExtra(ARG_VIEW_ONLY, false);
if (isViewOnly) {
submitButton.setText(R.string.SubmitDebugLogActivity_close);
submitButton.setOnClickListener(v -> ActivityCompat.finishAfterTransition(this));
} else {
submitButton.setOnClickListener(v -> onSubmitClicked());
}
scrollToTopButton.setOnClickListener(v -> DebugLogsViewer.scrollToTop(logWebView));
scrollToBottomButton.setOnClickListener(v -> DebugLogsViewer.scrollToBottom(logWebView));

View File

@@ -221,7 +221,7 @@
<style name="Signal.DayNight.Dialog.FullScreen.Donate">
</style>
<style name="ThemeOverlay.Signal.MaterialAlertDialog" parent="@style/ThemeOverlay.Material3.MaterialAlertDialog">
<item name="alertDialogStyle">@style/Signal.MaterialAlertDialog</item>
<item name="android:background">@color/signal_colorSurface1</item>

View File

@@ -2926,6 +2926,8 @@
<string name="SubmitDebugLogActivity_success">Success!</string>
<string name="SubmitDebugLogActivity_copy_this_url_and_add_it_to_your_issue">Copy this URL and add it to your issue report or support email:\n\n<b>%1$s</b></string>
<string name="SubmitDebugLogActivity_share">Share</string>
<!-- Displayed when entering activity in view-only mode -->
<string name="SubmitDebugLogActivity_close">Close</string>
<string name="SubmitDebugLogActivity_this_log_will_be_posted_publicly_online_for_contributors">This log will be posted publicly online for contributors to view. You may examine it before uploading.</string>
<!-- Banner message shown while submitting debug log -->
<string name="SubmitDebugLogActivity_your_log_will_be_posted_online">When you click Submit, your log will be posted online for 30 days at a unique, unpublished URL. You may Save it locally first.</string>
@@ -8851,5 +8853,75 @@
<!-- Snackbar text to add a question before creating a poll -->
<string name="CreatePollFragment__add_question">Add a question</string>
<!-- CallQualityIssue -->
<!-- Label for general audio issue category -->
<string name="CallQualityIssue__audio_issue">Audio issue</string>
<!-- Label for general video issue category -->
<string name="CallQualityIssue__video_issue">Video issue</string>
<!-- Label for call dropped issue -->
<string name="CallQualityIssue__call_droppped">Call dropped</string>
<!-- Label for other/custom issues not covered by predefined categories -->
<string name="CallQualityIssue__something_else">Something else</string>
<!-- Label for audio stuttering/choppy audio issue -->
<string name="CallQualityIssue__audio_stuttering">Audio stuttering</string>
<!-- Label for audio cutting out/dropping issue -->
<string name="CallQualityIssue__audio_cut_out">Audio cut out</string>
<!-- Label for echo heard by the user -->
<string name="CallQualityIssue__i_heard_echo">I heard echo</string>
<!-- Label for echo heard by other call participants -->
<string name="CallQualityIssue__others_heard_echo">Others heard echo</string>
<!-- Label for poor video quality issue -->
<string name="CallQualityIssue__poor_video_quality">Poor video quality</string>
<!-- Label for low video resolution issue -->
<string name="CallQualityIssue__low_resolution">Low resolution</string>
<!-- Label for camera malfunction issue -->
<string name="CallQualityIssue__camera_did_not_work">Camera didn\'t work</string>
<!-- CallQualitySomethingElseScreen -->
<!-- Title for the "Something else" call quality issue screen -->
<string name="CallQualitySomethingElseScreen__title">Something else</string>
<!-- Label for text field where user describes their call quality issue -->
<string name="CallQualitySomethingElseScreen__describe_your_issue">Describe your issue</string>
<!-- Privacy notice explaining that issue details will be kept private and used to improve Signal calls -->
<string name="CallQualitySomethingElseScreen__privacy_notice">Please include any details relevant to the issue. Anything you share here will be kept private and will only be used to help improve calls in Signal.</string>
<!-- Button to save the call quality issue description -->
<string name="CallQualitySomethingElseScreen__save">Save</string>
<!-- Content description for the back navigation button -->
<string name="CallQualitySomethingElseScreen__back">Back</string>
<!-- CallQualitySheet -->
<!-- Title asking user how their call was -->
<string name="CallQualitySheet__how_was_your_call">How was your call?</string>
<!-- Subtitle explaining that feedback helps improve Signal and no PII is stored -->
<string name="CallQualitySheet__how_was_your_call_subtitle">This helps us improve calls in Signal. No personally identifiable information will be stored.</string>
<!-- Button label indicating the call had issues -->
<string name="CallQualitySheet__had_issues">Had issues</string>
<!-- Button label indicating the call was great -->
<string name="CallQualitySheet__great">Great</string>
<!-- Title asking what issues the user experienced -->
<string name="CallQualitySheet__what_issues_did_you_have">What issues did you have?</string>
<!-- Instruction to select all applicable issues -->
<string name="CallQualitySheet__select_all_that_apply">Select all that apply</string>
<!-- Placeholder text for describing a custom issue -->
<string name="CallQualitySheet__describe_your_issue">Describe your issue</string>
<!-- Button to continue to the next step -->
<string name="CallQualitySheet__continue">Continue</string>
<!-- Title asking user to help improve Signal -->
<string name="CallQualitySheet__help_us_improve">Help us improve</string>
<!-- First part of description explaining diagnostics help improve call quality -->
<string name="CallQualitySheet__help_us_improve_description_prefix">Sending us your diagnostics info helps us improve call quality. You can </string>
<!-- Link text to view debug log -->
<string name="CallQualitySheet__view_your_debug_log">view your debug log</string>
<!-- Last part of description about viewing debug log before submitting -->
<string name="CallQualitySheet__help_us_improve_description_suffix"> before submitting</string>
<!-- Toggle label for sharing debug log -->
<string name="CallQualitySheet__share_debug_log">Share debug log</string>
<!-- Privacy notice explaining what debug logs contain and why they are helpful -->
<string name="CallQualitySheet__debug_log_privacy_notice">Debug logs contain low level app information and do not reveal any of your message contents.</string>
<!-- Button to submit the call quality feedback -->
<string name="CallQualitySheet__submit">Submit</string>
<!-- Displayed as a snackbar after submitting feedback -->
<string name="CallQualitySheet__thanks_for_your_feedback">Thanks for your feedback!</string>
<!-- EOF -->
</resources>