diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualityBottomSheetFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualityBottomSheetFragment.kt new file mode 100644 index 0000000000..7130a4d6d4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualityBottomSheetFragment.kt @@ -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) { + 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)) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualityScreenViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualityScreenViewModel.kt new file mode 100644 index 0000000000..8270233410 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualityScreenViewModel.kt @@ -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 = internalState + + fun onCallQualityIssueSelectionChanged(selection: Set) { + 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. + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualityScreens.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualityScreens.kt new file mode 100644 index 0000000000..8177a508e1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualityScreens.kt @@ -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, + somethingElseDescription: String, + onCallQualityIssueSelectionChanged: (Set) -> 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) { + 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>(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): Set { + return remember(userSelection) { + val displaySet = mutableSetOf() + displaySet.add(CallQualityIssue.AUDIO_ISSUE) + if (CallQualityIssue.AUDIO_ISSUE in userSelection) { + displaySet.add(CallQualityIssue.AUDIO_STUTTERING) + displaySet.add(CallQualityIssue.AUDIO_CUT_OUT) + displaySet.add(CallQualityIssue.AUDIO_I_HEARD_ECHO) + displaySet.add(CallQualityIssue.AUDIO_OTHERS_HEARD_ECHO) + } + + displaySet.add(CallQualityIssue.VIDEO_ISSUE) + if (CallQualityIssue.VIDEO_ISSUE in userSelection) { + displaySet.add(CallQualityIssue.VIDEO_POOR_QUALITY) + displaySet.add(CallQualityIssue.VIDEO_LOW_RESOLUTION) + displaySet.add(CallQualityIssue.VIDEO_CAMERA_MALFUNCTION) + } + + displaySet.add(CallQualityIssue.CALL_DROPPED) + displaySet.add(CallQualityIssue.SOMETHING_ELSE) + + displaySet + } +} + +data class CallQualitySheetState( + val selectedQualityIssues: Set = emptySet(), + val somethingElseDescription: String = "", + val isShareDebugLogSelected: Boolean = false +) + +interface CallQualitySheetCallback { + fun dismiss() + fun viewDebugLog() + fun describeYourIssue() + fun onCallQualityIssueSelectionChanged(selection: Set) + 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) = 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) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualitySomethingElseFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualitySomethingElseFragment.kt new file mode 100644 index 0000000000..ddb216fdda --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualitySomethingElseFragment.kt @@ -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() + } + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualitySomethingElseScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualitySomethingElseScreen.kt new file mode 100644 index 0000000000..d40aa17f5a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/quality/CallQualitySomethingElseScreen.kt @@ -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() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt index 58b5175730..1344df9f30 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt @@ -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(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/SubmitDebugLogActivity.java b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/SubmitDebugLogActivity.java index a03bf9b8d7..2e1995d853 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/SubmitDebugLogActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/SubmitDebugLogActivity.java @@ -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)); diff --git a/app/src/main/res/values/signal_styles.xml b/app/src/main/res/values/signal_styles.xml index 944a61e7ed..7779eedae9 100644 --- a/app/src/main/res/values/signal_styles.xml +++ b/app/src/main/res/values/signal_styles.xml @@ -221,7 +221,7 @@ - +