diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/EditDeviceNameFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/EditDeviceNameFragment.kt index d56977ac8b..5d2c45feb2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/EditDeviceNameFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/EditDeviceNameFragment.kt @@ -68,13 +68,14 @@ class EditDeviceNameFragment : ComposeFragment() { LinkDeviceSettingsState.OneTimeEvent.SnackbarNameChangeFailure -> { Snackbar.make(requireView(), context.getString(R.string.EditDeviceNameFragment__unable_to_change), Snackbar.LENGTH_LONG).show() } - LinkDeviceSettingsState.OneTimeEvent.HideFinishedSheet -> Unit - LinkDeviceSettingsState.OneTimeEvent.LaunchQrCodeScanner -> Unit - LinkDeviceSettingsState.OneTimeEvent.None -> Unit - LinkDeviceSettingsState.OneTimeEvent.ShowFinishedSheet -> Unit - is LinkDeviceSettingsState.OneTimeEvent.ToastLinked -> Unit - LinkDeviceSettingsState.OneTimeEvent.ToastNetworkFailed -> Unit - is LinkDeviceSettingsState.OneTimeEvent.ToastUnlinked -> Unit + LinkDeviceSettingsState.OneTimeEvent.HideFinishedSheet, + LinkDeviceSettingsState.OneTimeEvent.LaunchQrCodeScanner, + LinkDeviceSettingsState.OneTimeEvent.None, + LinkDeviceSettingsState.OneTimeEvent.ShowFinishedSheet, + is LinkDeviceSettingsState.OneTimeEvent.ToastLinked, + LinkDeviceSettingsState.OneTimeEvent.ToastNetworkFailed, + is LinkDeviceSettingsState.OneTimeEvent.ToastUnlinked, + LinkDeviceSettingsState.OneTimeEvent.LaunchEmail, LinkDeviceSettingsState.OneTimeEvent.SnackbarLinkCancelled -> Unit } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt index 1c5cc5eda9..eb55cd63b7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceFragment.kt @@ -73,6 +73,7 @@ import org.thoughtcrime.securesms.compose.ComposeFragment import org.thoughtcrime.securesms.linkdevice.LinkDeviceSettingsState.DialogState import org.thoughtcrime.securesms.util.CommunicationActions import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.SupportEmailUtil import org.thoughtcrime.securesms.util.navigation.safeNavigate import java.util.Locale @@ -138,7 +139,7 @@ class LinkDeviceFragment : ComposeFragment() { Log.i(TAG, "Acquiring wake lock for linked device") linkDeviceWakeLock.acquire() } - DialogState.Unlinking, is DialogState.DeviceUnlinked -> Unit + DialogState.Unlinking, is DialogState.DeviceUnlinked, DialogState.ContactSupport, DialogState.LoadingDebugLog -> Unit } } @@ -172,6 +173,11 @@ class LinkDeviceFragment : ComposeFragment() { } LinkDeviceSettingsState.OneTimeEvent.SnackbarNameChangeFailure -> Unit LinkDeviceSettingsState.OneTimeEvent.SnackbarNameChangeSuccess -> Unit + LinkDeviceSettingsState.OneTimeEvent.LaunchEmail -> { + val subject = getString(R.string.LinkDeviceFragment__link_sync_failure_support_email) + val body = getEmailBody(state.debugLogUrl) + CommunicationActions.openEmail(requireContext(), SupportEmailUtil.getSupportEmailAddress(requireContext()), subject, body) + } } if (state.oneTimeEvent != LinkDeviceSettingsState.OneTimeEvent.None) { @@ -210,16 +216,29 @@ class LinkDeviceFragment : ComposeFragment() { viewModel.onSyncErrorIgnored() CommunicationActions.openBrowserLink(requireContext(), requireContext().getString(R.string.LinkDeviceFragment__learn_more_url)) }, + onSyncFailureContactSupport = { viewModel.onSyncErrorContactSupport() }, onSyncCancelled = { viewModel.onSyncCancelled() }, onEditDevice = { device -> viewModel.setDeviceToEdit(device) navController.safeNavigate(R.id.action_linkDeviceFragment_to_editDeviceNameFragment) }, - onDialogDismissed = { viewModel.onDialogDismissed() } + onDialogDismissed = { viewModel.onDialogDismissed() }, + onContactWithLogs = { viewModel.onContactSupport(includeLogs = true) }, + onContactWithoutLogs = { viewModel.onContactSupport(includeLogs = false) } ) } } + private fun getEmailBody(debugLog: String?): String { + val filter = R.string.LinkDeviceFragment__link_sync_failure_support_email_filter + val prefix = StringBuilder() + if (debugLog != null) { + prefix.append("\n") + prefix.append(getString(R.string.HelpFragment__debug_log)).append(" ").append(debugLog).append("\n\n") + } + return SupportEmailUtil.generateSupportEmailBody(requireContext(), filter, prefix.toString(), null) + } + private fun NavController.navigateToQrScannerIfAuthed(seenEducation: Boolean) { if (seenEducation && biometricAuth.canAuthenticate(requireContext())) { if (!biometricAuth.authenticate(requireContext(), true) { biometricDeviceLockLauncher.launch(getString(R.string.LinkDeviceFragment__unlock_to_link)) }) { @@ -266,9 +285,12 @@ fun DeviceListScreen( onSyncFailureRetryRequested: () -> Unit = {}, onSyncFailureIgnored: () -> Unit = {}, onSyncFailureLearnMore: () -> Unit = {}, + onSyncFailureContactSupport: () -> Unit = {}, onSyncCancelled: () -> Unit = {}, onEditDevice: (Device) -> Unit = {}, - onDialogDismissed: () -> Unit = {} + onDialogDismissed: () -> Unit = {}, + onContactWithLogs: () -> Unit = {}, + onContactWithoutLogs: () -> Unit = {} ) { // If a bottom sheet is showing, we don't want the spinner underneath if (!state.bottomSheetVisible) { @@ -302,14 +324,15 @@ fun DeviceListScreen( onDeny = onSyncFailureIgnored ) } else { - Dialogs.SimpleAlertDialog( + Dialogs.AdvancedAlertDialog( title = stringResource(R.string.LinkDeviceFragment__sync_failure_title), body = stringResource(R.string.LinkDeviceFragment__sync_failure_body_unretryable), - confirm = stringResource(R.string.LinkDeviceFragment__continue), - onConfirm = onSyncFailureIgnored, - dismiss = stringResource(R.string.LinkDeviceFragment__learn_more), - onDismissRequest = onSyncFailureIgnored, - onDeny = onSyncFailureLearnMore + positive = stringResource(R.string.LinkDeviceFragment__contact_support), + onPositive = onSyncFailureContactSupport, + neutral = stringResource(R.string.LinkDeviceFragment__learn_more), + onNeutral = onSyncFailureLearnMore, + negative = stringResource(R.string.LinkDeviceFragment__continue), + onNegative = onSyncFailureIgnored ) } } @@ -333,6 +356,19 @@ fun DeviceListScreen( onDismiss = onDialogDismissed ) } + DialogState.LoadingDebugLog -> { Dialogs.IndeterminateProgressDialog() } + DialogState.ContactSupport -> { + Dialogs.AdvancedAlertDialog( + title = stringResource(R.string.LinkDeviceFragment__submit_debug_log), + body = stringResource(R.string.LinkDeviceFragment__your_debug_logs), + positive = stringResource(R.string.LinkDeviceFragment__submit_with_debug), + onPositive = onContactWithLogs, + neutral = stringResource(R.string.LinkDeviceFragment__submit_without_debug), + onNeutral = onContactWithoutLogs, + negative = stringResource(R.string.LinkDeviceFragment__cancel), + onNegative = onDialogDismissed + ) + } } } @@ -626,7 +662,7 @@ private fun DeviceListScreenSyncingMessagesPreview() { @SignalPreview @Composable -private fun DeviceListScreenSyncingFailedPreview() { +private fun DeviceListScreenSyncingFailedRetryPreview() { Previews.Preview { DeviceListScreen( state = LinkDeviceSettingsState( @@ -638,6 +674,34 @@ private fun DeviceListScreenSyncingFailedPreview() { } } +@SignalPreview +@Composable +private fun DeviceListScreenSyncingFailedPreview() { + Previews.Preview { + DeviceListScreen( + state = LinkDeviceSettingsState( + dialogState = DialogState.SyncingFailed(1, 1, false), + seenQrEducationSheet = true, + seenBioAuthEducationSheet = true + ) + ) + } +} + +@SignalPreview +@Composable +private fun DeviceListScreenContactSupportPreview() { + Previews.Preview { + DeviceListScreen( + state = LinkDeviceSettingsState( + dialogState = DialogState.ContactSupport, + seenQrEducationSheet = true, + seenBioAuthEducationSheet = true + ) + ) + } +} + @SignalPreview @Composable private fun DeviceListScreenDeviceUnlinkedPreview() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt index 7be90dabbd..82056b38fe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceSettingsState.kt @@ -23,7 +23,8 @@ data class LinkDeviceSettingsState( val needsBioAuthEducationSheet: Boolean = !seenBioAuthEducationSheet && SignalStore.uiHints.lastSeenLinkDeviceAuthSheetTime < System.currentTimeMillis() - 30.days.inWholeMilliseconds, val bottomSheetVisible: Boolean = false, val deviceToEdit: Device? = null, - val shouldCancelArchiveUpload: Boolean = false + val shouldCancelArchiveUpload: Boolean = false, + val debugLogUrl: String? = null ) { sealed interface DialogState { data object None : DialogState @@ -33,6 +34,8 @@ data class LinkDeviceSettingsState( data object SyncingTimedOut : DialogState data class SyncingFailed(val deviceId: Int, val deviceCreatedAt: Long, val canRetry: Boolean) : DialogState data class DeviceUnlinked(val deviceCreatedAt: Long) : DialogState + data object LoadingDebugLog : DialogState + data object ContactSupport : DialogState } sealed interface OneTimeEvent { @@ -46,6 +49,7 @@ data class LinkDeviceSettingsState( data object ShowFinishedSheet : OneTimeEvent data object HideFinishedSheet : OneTimeEvent data object LaunchQrCodeScanner : OneTimeEvent + data object LaunchEmail : OneTimeEvent } enum class QrCodeState { diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt index 2c5b74a1e9..bddda61853 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceViewModel.kt @@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.linkdevice.LinkDeviceRepository.getPlaintextDe import org.thoughtcrime.securesms.linkdevice.LinkDeviceSettingsState.DialogState import org.thoughtcrime.securesms.linkdevice.LinkDeviceSettingsState.OneTimeEvent import org.thoughtcrime.securesms.linkdevice.LinkDeviceSettingsState.QrCodeState +import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogRepository import org.thoughtcrime.securesms.notifications.NotificationIds import org.thoughtcrime.securesms.util.RemoteConfig import org.thoughtcrime.securesms.util.ServiceUtil @@ -27,6 +28,7 @@ import org.thoughtcrime.securesms.util.Util import org.whispersystems.signalservice.api.backup.MessageBackupKey import org.whispersystems.signalservice.api.link.TransferArchiveError import org.whispersystems.signalservice.api.link.WaitForLinkedDeviceResponse +import kotlin.jvm.optionals.getOrNull import kotlin.time.Duration.Companion.seconds /** @@ -40,6 +42,7 @@ class LinkDeviceViewModel : ViewModel() { private val _state = MutableStateFlow(LinkDeviceSettingsState()) val state = _state.asStateFlow() + private val submitDebugLogRepository: SubmitDebugLogRepository = SubmitDebugLogRepository() private var pollJob: Job? = null @@ -433,6 +436,14 @@ class LinkDeviceViewModel : ViewModel() { } } + fun onSyncErrorContactSupport() { + _state.update { + it.copy( + dialogState = DialogState.ContactSupport + ) + } + } + fun onSyncErrorRetryRequested() = viewModelScope.launch(Dispatchers.IO) { val dialogState = _state.value.dialogState if (dialogState is DialogState.SyncingFailed) { @@ -500,4 +511,33 @@ class LinkDeviceViewModel : ViewModel() { } } } + + fun onContactSupport(includeLogs: Boolean) { + viewModelScope.launch { + if (includeLogs) { + _state.update { + it.copy( + dialogState = DialogState.LoadingDebugLog + ) + } + submitDebugLogRepository.buildAndSubmitLog { result -> + val url = result.getOrNull() + _state.update { + it.copy( + debugLogUrl = url, + oneTimeEvent = OneTimeEvent.LaunchEmail, + dialogState = DialogState.None + ) + } + } + } else { + _state.update { + it.copy( + oneTimeEvent = OneTimeEvent.LaunchEmail, + dialogState = DialogState.None + ) + } + } + } + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7d00d9df24..3fe4168525 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1026,6 +1026,21 @@ Tap continue and enter your phone\'s lock to confirm. Do not enter your Signal PIN. Continue + + Contact support + + Submit debug log? + + Your debug logs will help us troubleshoot your issue faster. Submitting your logs is optional. + + Submit with debug log + + Submit without debug log + + Cancel + + Android Link&Sync Export Failed + Android Link&Sync Export Failed Message sync failed diff --git a/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt b/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt index bac9227d14..5324811384 100644 --- a/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt +++ b/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import org.signal.core.ui.Dialogs.AdvancedAlertDialog import org.signal.core.ui.Dialogs.PermissionRationaleDialog import org.signal.core.ui.Dialogs.SimpleAlertDialog import org.signal.core.ui.Dialogs.SimpleMessageDialog @@ -284,6 +285,66 @@ object Dialogs { } } } + + /** + * Alert dialog that supports three options. + * If you only need two options (confirm/dismiss), use [SimpleAlertDialog] instead. + */ + @Composable + fun AdvancedAlertDialog( + title: String = "", + body: String = "", + positive: String, + neutral: String, + negative: String, + onPositive: () -> Unit, + onNegative: () -> Unit, + onNeutral: () -> Unit + ) { + Dialog( + onDismissRequest = onNegative, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + modifier = Modifier + .fillMaxWidth(fraction = 0.75f) + .background( + color = SignalTheme.colors.colorSurface2, + shape = AlertDialogDefaults.shape + ) + .clip(AlertDialogDefaults.shape) + ) { + Column(modifier = Modifier.padding(24.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge + ) + + Text( + text = body, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 16.dp) + ) + + Column( + horizontalAlignment = Alignment.End, + modifier = Modifier.fillMaxWidth() + ) { + TextButton(onClick = onPositive) { + Text(text = positive) + } + TextButton(onClick = onNeutral) { + Text(text = neutral) + } + TextButton(onClick = onNegative) { + Text(text = negative) + } + } + } + } + } + } } @Preview @@ -314,6 +375,23 @@ private fun AlertDialogPreview() { ) } +@SignalPreview +@Composable +private fun AdvancedAlertDialogPreview() { + Previews.Preview { + AdvancedAlertDialog( + title = "Title text", + body = "Body message text.", + positive = "Continue", + neutral = "Learn more", + negative = "Not now", + onPositive = {}, + onNegative = {}, + onNeutral = {} + ) + } +} + @Preview @Composable private fun MessageDialogPreview() {