Add contact support option within link+sync.

This commit is contained in:
Michelle Tang
2025-02-07 14:39:28 -05:00
committed by GitHub
parent 850c20bcd8
commit 5a7580c4c7
6 changed files with 220 additions and 18 deletions

View File

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

View File

@@ -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() {

View File

@@ -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 {

View File

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