mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-24 13:08:46 +00:00
Add initial flag / watermark system for backup failure UX.
This commit is contained in:
committed by
Greyson Parrelli
parent
4446510916
commit
4282d88191
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,5 +10,6 @@ package org.thoughtcrime.securesms.components.settings.app
|
||||
*/
|
||||
enum class BackupFailureState {
|
||||
NONE,
|
||||
COULD_NOT_COMPLETE_BACKUP,
|
||||
SUBSCRIPTION_STATE_MISMATCH
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
14
app/src/main/res/drawable/backups_failed_background.xml
Normal file
14
app/src/main/res/drawable/backups_failed_background.xml
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user