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