Add initial flag / watermark system for backup failure UX.

This commit is contained in:
Alex Hart
2024-11-01 10:59:50 -03:00
committed by Greyson Parrelli
parent 4446510916
commit 4282d88191
12 changed files with 221 additions and 34 deletions

View File

@@ -64,6 +64,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.toMillis
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.StatusCodeErrorAction
@@ -91,6 +92,7 @@ import java.io.OutputStream
import java.time.ZonedDateTime
import java.util.Locale
import java.util.concurrent.atomic.AtomicLong
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
object BackupRepository {
@@ -119,6 +121,64 @@ object BackupRepository {
}
}
/**
* Whether the yellow dot should be displayed on the conversation list avatar.
*/
@JvmStatic
fun shouldDisplayBackupFailedIndicator(): Boolean {
if (shouldNotDisplayBackupFailedMessaging() || !SignalStore.backup.hasBackupFailure) {
return false
}
val now = System.currentTimeMillis().milliseconds
val alertAfter = SignalStore.backup.nextBackupFailureSnoozeTime
return alertAfter <= now
}
/**
* Whether the "Could not complete backup" row should be displayed in settings.
*/
fun shouldDisplayBackupFailedSettingsRow(): Boolean {
if (shouldNotDisplayBackupFailedMessaging()) {
return false
}
return SignalStore.backup.hasBackupFailure
}
/**
* Updates the watermark for the indicator display.
*/
@JvmStatic
fun markBackupFailedIndicatorClicked() {
SignalStore.backup.updateMessageBackupFailureWatermark()
}
/**
* Whether or not the "Could not complete backup" sheet should be displayed.
*/
@JvmStatic
fun shouldDisplayBackupFailedSheet(): Boolean {
if (shouldNotDisplayBackupFailedMessaging()) {
return false
}
val lastBackupTime = SignalStore.backup.lastBackupTime.milliseconds
val isTimeoutElapsed = when (SignalStore.backup.backupFrequency) {
BackupFrequency.DAILY -> lastBackupTime > 7.days
BackupFrequency.WEEKLY -> lastBackupTime > 14.days
BackupFrequency.MONTHLY -> lastBackupTime > 44.days
BackupFrequency.MANUAL -> false
}
return isTimeoutElapsed && false // TODO [backups] -- watermarking necessary, otherwise this'll show up on every resume.
}
private fun shouldNotDisplayBackupFailedMessaging(): Boolean {
return !RemoteConfig.messageBackups || !SignalStore.backup.areBackupsEnabled || !SignalStore.backup.hasBackupBeenUploaded
}
/**
* If the user is on a paid tier, this method will unsubscribe them from that tier.
* It will then disable backups.

View File

@@ -55,6 +55,7 @@ import org.signal.core.ui.horizontalGutters
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.AvatarImage
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.BannerManager
import org.thoughtcrime.securesms.banner.banners.DeprecatedBuildBanner
@@ -187,42 +188,39 @@ private fun AppSettingsContent(
)
}
if (state.backupFailureState != BackupFailureState.NONE) {
item {
Dividers.Default()
}
when (state.backupFailureState) {
BackupFailureState.SUBSCRIPTION_STATE_MISMATCH -> {
item {
Dividers.Default()
item {
Rows.TextRow(
text = {
Text(text = stringResource(R.string.AppSettingsFragment__renew_your_signal_backups_subscription))
},
icon = {
Box {
Icon(
painter = painterResource(R.drawable.symbol_backup_24),
tint = MaterialTheme.colorScheme.onSurface,
contentDescription = null
)
Box(
modifier = Modifier
.absoluteOffset(3.dp, (-2).dp)
.background(color = Color(0xFFFFCC00), shape = CircleShape)
.size(12.dp)
.align(Alignment.TopEnd)
)
BackupsWarningRow(
text = stringResource(R.string.AppSettingsFragment__renew_your_signal_backups_subscription),
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_remoteBackupsSettingsFragment)
}
},
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_remoteBackupsSettingsFragment)
}
)
)
Dividers.Default()
}
}
item {
Dividers.Default()
BackupFailureState.COULD_NOT_COMPLETE_BACKUP -> {
item {
Dividers.Default()
BackupsWarningRow(
text = stringResource(R.string.AppSettingsFragment__couldnt_complete_backup),
onClick = {
BackupRepository.markBackupFailedIndicatorClicked()
callbacks.navigate(R.id.action_appSettingsFragment_to_remoteBackupsSettingsFragment)
}
)
Dividers.Default()
}
}
BackupFailureState.NONE -> Unit
}
item {
@@ -467,6 +465,36 @@ private fun AppSettingsContent(
}
}
@Composable
private fun BackupsWarningRow(
text: String,
onClick: () -> Unit
) {
Rows.TextRow(
text = {
Text(text = text)
},
icon = {
Box {
Icon(
painter = painterResource(R.drawable.symbol_backup_24),
tint = MaterialTheme.colorScheme.onSurface,
contentDescription = null
)
Box(
modifier = Modifier
.absoluteOffset(3.dp, (-2).dp)
.background(color = Color(0xFFFFCC00), shape = CircleShape)
.size(12.dp)
.align(Alignment.TopEnd)
)
}
},
onClick = onClick
)
}
@Composable
private fun BioRow(
self: BioRecipientState,

View File

@@ -6,6 +6,7 @@ import androidx.lifecycle.map
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.conversationlist.model.UnreadPaymentsLiveData
@@ -70,7 +71,9 @@ class AppSettingsViewModel : ViewModel() {
}
private fun getBackupFailureState(): BackupFailureState {
return if (SignalStore.backup.subscriptionStateMismatchDetected) {
return if (BackupRepository.shouldDisplayBackupFailedSettingsRow()) {
BackupFailureState.COULD_NOT_COMPLETE_BACKUP
} else if (SignalStore.backup.subscriptionStateMismatchDetected) {
BackupFailureState.SUBSCRIPTION_STATE_MISMATCH
} else {
BackupFailureState.NONE

View File

@@ -10,5 +10,6 @@ package org.thoughtcrime.securesms.components.settings.app
*/
enum class BackupFailureState {
NONE,
COULD_NOT_COMPLETE_BACKUP,
SUBSCRIPTION_STATE_MISMATCH
}

View File

@@ -28,6 +28,7 @@ import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.backup.v2.BackupFrequency
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatusData
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.banner.banners.MediaRestoreProgressBanner
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
@@ -90,6 +91,8 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
}
} else if (SignalStore.backup.totalRestorableAttachmentSize > 0L) {
_restoreState.update { BackupRestoreState.Ready(SignalStore.backup.totalRestorableAttachmentSize.bytes.toUnitString()) }
} else if (BackupRepository.shouldDisplayBackupFailedSettingsRow()) {
_restoreState.update { BackupRestoreState.FromBackupStatusData(BackupStatusData.CouldNotCompleteBackup) }
} else {
_restoreState.update { BackupRestoreState.None }
}

View File

@@ -94,6 +94,9 @@ import org.thoughtcrime.securesms.MainNavigator;
import org.thoughtcrime.securesms.MuteDialog;
import org.thoughtcrime.securesms.NewConversationActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.backup.v2.BackupRepository;
import org.thoughtcrime.securesms.backup.v2.ui.BackupAlert;
import org.thoughtcrime.securesms.backup.v2.ui.BackupAlertBottomSheet;
import org.thoughtcrime.securesms.backup.v2.ui.BackupAlertDelegate;
import org.thoughtcrime.securesms.badges.models.Badge;
import org.thoughtcrime.securesms.badges.self.expired.ExpiredOneTimeBadgeBottomSheetDialogFragment;
@@ -173,6 +176,7 @@ import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.BottomSheetUtil;
import org.thoughtcrime.securesms.util.CachedInflater;
import org.thoughtcrime.securesms.util.ConversationUtil;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SignalProxyUtil;
@@ -571,6 +575,10 @@ public class ConversationListFragment extends MainFragment implements ActionMode
if (this.bannerManager != null) {
this.bannerManager.updateContent(bannerView.get());
}
if (BackupRepository.shouldDisplayBackupFailedSheet()) {
BackupAlertBottomSheet.Companion.create(BackupAlert.COULD_NOT_COMPLETE_BACKUP).show(getParentFragmentManager(), null);
}
}
@Override

View File

@@ -65,7 +65,12 @@ class BackupMessagesJob private constructor(parameters: Parameters) : Job(parame
override fun getFactoryKey(): String = KEY
override fun onFailure() = Unit
override fun onFailure() {
if (!isCanceled) {
Log.w(TAG, "Failed to backup user messages. Marking failure state.")
SignalStore.backup.markMessageBackupFailure()
}
}
override fun run(): Result {
val stopwatch = Stopwatch("BackupMessagesJob")
@@ -126,6 +131,7 @@ class BackupMessagesJob private constructor(parameters: Parameters) : Job(parame
ArchiveUploadProgress.onMessageBackupFinishedEarly()
}
SignalStore.backup.clearMessageBackupFailure()
return Result.success()
}

View File

@@ -14,6 +14,7 @@ import java.io.IOException
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
companion object {
@@ -45,6 +46,10 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
private const val KEY_BACKUP_UPLOADED = "backup.backupUploaded"
private const val KEY_SUBSCRIPTION_STATE_MISMATCH = "backup.subscriptionStateMismatch"
private const val KEY_BACKUP_FAIL = "backup.failed"
private const val KEY_BACKUP_FAIL_ACKNOWLEDGED_SNOOZE_TIME = "backup.failed.acknowledged.snooze.time"
private const val KEY_BACKUP_FAIL_ACKNOWLEDGED_SNOOZE_COUNT = "backup.failed.acknowledged.snooze.count"
private val cachedCdnCredentialsExpiresIn: Duration = 12.hours
}
@@ -118,6 +123,9 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
/** True if we believe we have successfully uploaded a backup, otherwise false. */
var hasBackupBeenUploaded: Boolean by booleanValue(KEY_BACKUP_UPLOADED, false)
val hasBackupFailure: Boolean = getBoolean(KEY_BACKUP_FAIL, false)
val nextBackupFailureSnoozeTime: Duration get() = getLong(KEY_BACKUP_FAIL_ACKNOWLEDGED_SNOOZE_TIME, 0L).milliseconds
/**
* Call when the user disables backups. Clears/resets all relevant fields.
*/
@@ -204,6 +212,36 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
putString(KEY_CREDENTIALS, null)
}
fun markMessageBackupFailure() {
store.beginWrite()
.putBoolean(KEY_BACKUP_FAIL, true)
.putLong(KEY_BACKUP_FAIL_ACKNOWLEDGED_SNOOZE_TIME, System.currentTimeMillis())
.putLong(KEY_BACKUP_FAIL_ACKNOWLEDGED_SNOOZE_COUNT, 0)
.apply()
}
fun updateMessageBackupFailureWatermark() {
if (!hasBackupFailure) {
return
}
val snoozeCount = getLong(KEY_BACKUP_FAIL_ACKNOWLEDGED_SNOOZE_COUNT, 0) + 1
val nextSnooze = when (snoozeCount) {
1L -> 48.hours
2L -> 72.hours
else -> Long.MAX_VALUE.hours
}
store.beginWrite()
.putLong(KEY_BACKUP_FAIL_ACKNOWLEDGED_SNOOZE_TIME, (System.currentTimeMillis().milliseconds + nextSnooze).inWholeMilliseconds)
.putLong(KEY_BACKUP_FAIL_ACKNOWLEDGED_SNOOZE_COUNT, snoozeCount)
.apply()
}
fun clearMessageBackupFailure() {
putBoolean(KEY_BACKUP_FAIL, false)
}
class SerializedCredentials(
@JsonProperty
val credentialsByDay: Map<Long, ArchiveServiceCredential>

View File

@@ -24,6 +24,7 @@ import org.signal.core.util.concurrent.SimpleTask
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.calls.log.CallLogFragment
import org.thoughtcrime.securesms.components.Material3SearchToolbar
@@ -66,6 +67,7 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
private lateinit var _searchToolbar: Stub<Material3SearchToolbar>
private lateinit var _searchAction: ImageView
private lateinit var _unreadPaymentsDot: View
private lateinit var _backupsFailedDot: View
private var previousTopToastPopup: TopToastPopup? = null
@@ -88,6 +90,7 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
_searchAction = view.findViewById(R.id.search_action)
_searchToolbar = Stub(view.findViewById(R.id.search_toolbar))
_unreadPaymentsDot = view.findViewById(R.id.unread_payments_indicator)
_backupsFailedDot = view.findViewById(R.id.backups_failed_indicator)
notificationProfileStatus.setOnClickListener { handleNotificationProfile() }
proxyStatus.setOnClickListener { onProxyStatusClicked() }
@@ -163,6 +166,12 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
super.onResume()
SimpleTask.run(viewLifecycleOwner.lifecycle, { Recipient.self() }, ::initializeProfileIcon)
_backupsFailedDot.alpha = if (BackupRepository.shouldDisplayBackupFailedIndicator()) {
1f
} else {
0f
}
requireView()
.findViewById<View>(R.id.fragment_container)
.findNavController()
@@ -273,7 +282,10 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
private fun initializeSettingsTouchTarget() {
val touchArea = requireView().findViewById<View>(R.id.toolbar_settings_touch_area)
touchArea.setOnClickListener { openSettings.launch(AppSettingsActivity.home(requireContext())) }
touchArea.setOnClickListener {
BackupRepository.markBackupFailedIndicatorClicked()
openSettings.launch(AppSettingsActivity.home(requireContext()))
}
}
private fun handleNotificationProfile() {

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="@dimen/unread_count_bubble_radius" />
<solid android:color="#FFFFCC00" />
<stroke
android:color="@color/signal_colorOnPrimary"
android:width="1dp"/>
</shape>

View File

@@ -71,6 +71,18 @@
app:layout_constraintStart_toStartOf="@id/toolbar_icon"
tools:alpha="1" />
<View
android:id="@+id/backups_failed_indicator"
android:layout_width="13dp"
android:layout_height="13dp"
android:layout_marginStart="20dp"
android:layout_marginBottom="20dp"
android:alpha="0"
android:background="@drawable/backups_failed_background"
app:layout_constraintBottom_toBottomOf="@id/toolbar_icon"
app:layout_constraintStart_toStartOf="@id/toolbar_icon"
tools:alpha="1" />
<TextView
android:id="@+id/conversation_list_title"
android:layout_width="0dp"

View File

@@ -4966,6 +4966,8 @@
<!-- AppSettingsFragment -->
<!-- String alerting user that something is wrong with their backups subscription -->
<string name="AppSettingsFragment__renew_your_signal_backups_subscription">Renew your Signal Backups subscription</string>
<!-- String alerting user that backup failed -->
<string name="AppSettingsFragment__couldnt_complete_backup">Couldn\'t complete backup</string>
<!-- String displayed telling user to invite their friends to Signal -->
<string name="AppSettingsFragment__invite_your_friends">Invite your friends</string>
<!-- String displayed in a toast when we successfully copy the donations subscriber id to the clipboard -->