From 7fbcd17759d2dbe0831c943ce41c7c4fcad78840 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Mon, 2 Mar 2026 16:58:38 -0500 Subject: [PATCH] Add some megaphones to encourage users to try backups. --- .../v2/ui/BackupSetupCompleteBottomSheet.kt | 127 ++++++++++ .../backup/v2/ui/BackupUpsellBottomSheet.kt | 236 ++++++++++++++++++ .../securesms/database/AttachmentTable.kt | 11 + .../securesms/database/MessageTable.kt | 10 +- .../megaphone/BackupUpsellSchedule.kt | 51 ++++ .../securesms/megaphone/Megaphones.java | 120 ++++++++- .../megaphone_backup_media_size.xml | 19 ++ .../megaphone_backup_message_count.xml | 20 ++ .../megaphone_backup_storage_low.xml | 19 ++ .../drawable/megaphone_backup_media_size.xml | 24 ++ .../megaphone_backup_message_count.xml | 20 ++ .../drawable/megaphone_backup_storage_low.xml | 26 ++ app/src/main/res/values/strings.xml | 61 +++++ .../megaphone/BackupUpsellScheduleTest.kt | 128 ++++++++++ 14 files changed, 861 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupSetupCompleteBottomSheet.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupUpsellBottomSheet.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/megaphone/BackupUpsellSchedule.kt create mode 100644 app/src/main/res/drawable-night/megaphone_backup_media_size.xml create mode 100644 app/src/main/res/drawable-night/megaphone_backup_message_count.xml create mode 100644 app/src/main/res/drawable-night/megaphone_backup_storage_low.xml create mode 100644 app/src/main/res/drawable/megaphone_backup_media_size.xml create mode 100644 app/src/main/res/drawable/megaphone_backup_message_count.xml create mode 100644 app/src/main/res/drawable/megaphone_backup_storage_low.xml create mode 100644 app/src/test/java/org/thoughtcrime/securesms/megaphone/BackupUpsellScheduleTest.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupSetupCompleteBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupSetupCompleteBottomSheet.kt new file mode 100644 index 0000000000..4a5b6f812e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupSetupCompleteBottomSheet.kt @@ -0,0 +1,127 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.BottomSheets +import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.Rows +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.jobs.BackupMessagesJob +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.signal.core.ui.R as CoreUiR + +/** + * Bottom sheet shown after a successful paid backup subscription from a storage upsell megaphone. + * Allows the user to start their first backup and optionally enable storage optimization. + */ +class BackupSetupCompleteBottomSheet : ComposeBottomSheetDialogFragment() { + + override val peekHeightPercentage: Float = 0.75f + + @Composable + override fun SheetContent() { + SetupCompleteSheetContent( + onBackUpNowClick = { optimizeStorage -> + SignalStore.backup.optimizeStorage = optimizeStorage + BackupMessagesJob.enqueue() + dismissAllowingStateLoss() + } + ) + } +} + +@Composable +private fun SetupCompleteSheetContent( + onBackUpNowClick: (optimizeStorage: Boolean) -> Unit +) { + var optimizeStorage by rememberSaveable { mutableStateOf(true) } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(id = CoreUiR.dimen.gutter)) + ) { + BottomSheets.Handle() + + Spacer(modifier = Modifier.size(26.dp)) + + Image( + imageVector = ImageVector.vectorResource(id = R.drawable.image_signal_backups_subscribed), + contentDescription = null, + modifier = Modifier + .size(80.dp) + .padding(2.dp) + ) + + Text( + text = stringResource(R.string.BackupSetupCompleteBottomSheet__title), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 16.dp, bottom = 12.dp) + ) + + Text( + text = stringResource(R.string.BackupSetupCompleteBottomSheet__body), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 24.dp) + ) + + Rows.ToggleRow( + checked = optimizeStorage, + text = stringResource(R.string.BackupSetupCompleteBottomSheet__optimize_storage), + label = stringResource(R.string.BackupSetupCompleteBottomSheet__optimize_subtitle), + onCheckChanged = { optimizeStorage = it }, + modifier = Modifier.padding(bottom = 24.dp) + ) + + Buttons.LargeTonal( + onClick = { onBackUpNowClick(optimizeStorage) }, + modifier = Modifier + .defaultMinSize(minWidth = 220.dp) + .padding(bottom = 56.dp) + ) { + Text(text = stringResource(R.string.BackupSetupCompleteBottomSheet__back_up_now)) + } + } +} + +@DayNightPreviews +@Composable +private fun BackupSetupCompleteBottomSheetPreview() { + Previews.BottomSheetContentPreview { + SetupCompleteSheetContent( + onBackUpNowClick = {} + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupUpsellBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupUpsellBottomSheet.kt new file mode 100644 index 0000000000..6aef4b2098 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupUpsellBottomSheet.kt @@ -0,0 +1,236 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.ui + +import android.os.Bundle +import android.view.View +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import org.signal.core.ui.compose.BottomSheets +import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.signal.core.util.gibiBytes +import org.signal.core.util.money.FiatMoney +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType +import org.thoughtcrime.securesms.billing.upgrade.UpgradeToPaidTierBottomSheet +import org.thoughtcrime.securesms.payments.FiatMoneyUtil +import java.math.BigDecimal +import java.util.Currency +import kotlin.time.Duration.Companion.days +import org.signal.core.ui.R as CoreUiR + +/** + * Bottom sheet that upsells paid backup plans to users. + */ +class BackupUpsellBottomSheet : UpgradeToPaidTierBottomSheet() { + + companion object { + private const val ARG_SHOW_POST_PAYMENT = "show_post_payment" + + @JvmStatic + fun create(showPostPaymentSheet: Boolean): DialogFragment { + return BackupUpsellBottomSheet().apply { + arguments = bundleOf(ARG_SHOW_POST_PAYMENT to showPostPaymentSheet) + } + } + } + + private val showPostPaymentSheet: Boolean by lazy(LazyThreadSafetyMode.NONE) { + requireArguments().getBoolean(ARG_SHOW_POST_PAYMENT, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + if (showPostPaymentSheet) { + parentFragmentManager.setFragmentResultListener(RESULT_KEY, requireActivity()) { _, bundle -> + if (bundle.getBoolean(RESULT_KEY, false)) { + BackupSetupCompleteBottomSheet().show(parentFragmentManager, "backup_setup_complete") + } + } + } + } + + @Composable + override fun UpgradeSheetContent( + paidBackupType: MessageBackupsType.Paid, + freeBackupType: MessageBackupsType.Free, + isSubscribeEnabled: Boolean, + onSubscribeClick: () -> Unit + ) { + UpsellSheetContent( + paidBackupType = paidBackupType, + isSubscribeEnabled = isSubscribeEnabled, + onSubscribeClick = onSubscribeClick, + onNoThanksClick = { dismissAllowingStateLoss() } + ) + } +} + +@Composable +private fun UpsellSheetContent( + paidBackupType: MessageBackupsType.Paid, + isSubscribeEnabled: Boolean, + onSubscribeClick: () -> Unit, + onNoThanksClick: () -> Unit +) { + val resources = LocalContext.current.resources + val pricePerMonth = remember(paidBackupType) { + FiatMoneyUtil.format(resources, paidBackupType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal()) + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(id = CoreUiR.dimen.gutter)) + ) { + BottomSheets.Handle() + + Spacer(modifier = Modifier.size(26.dp)) + + Image( + imageVector = ImageVector.vectorResource(id = R.drawable.image_signal_backups), + contentDescription = null, + modifier = Modifier + .size(80.dp) + .padding(2.dp) + ) + + Text( + text = stringResource(R.string.BackupUpsellBottomSheet__title), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 16.dp, bottom = 12.dp) + ) + + Text( + text = stringResource(R.string.BackupUpsellBottomSheet__body), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 24.dp) + ) + + FeatureCard(pricePerMonth = pricePerMonth) + + Buttons.LargeTonal( + enabled = isSubscribeEnabled, + onClick = onSubscribeClick, + modifier = Modifier + .defaultMinSize(minWidth = 220.dp) + .padding(bottom = 16.dp) + ) { + Text(text = stringResource(R.string.BackupUpsellBottomSheet__subscribe_for, pricePerMonth)) + } + + TextButton( + enabled = isSubscribeEnabled, + onClick = onNoThanksClick, + modifier = Modifier.padding(bottom = 32.dp) + ) { + Text(text = stringResource(R.string.BackupUpsellBottomSheet__no_thanks)) + } + } +} + +@Composable +private fun FeatureCard(pricePerMonth: String) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(12.dp) + ) + .padding(16.dp) + ) { + Text( + text = stringResource(R.string.BackupUpsellBottomSheet__price_per_month, pricePerMonth), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Text( + text = stringResource(R.string.BackupUpsellBottomSheet__text_and_all_media), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 12.dp) + ) + + FeatureBullet(text = stringResource(R.string.BackupUpsellBottomSheet__full_text_media_backup)) + FeatureBullet(text = stringResource(R.string.BackupUpsellBottomSheet__storage_100gb)) + FeatureBullet(text = stringResource(R.string.BackupUpsellBottomSheet__save_on_device_storage)) + FeatureBullet(text = stringResource(R.string.BackupUpsellBottomSheet__thanks_for_supporting)) + } +} + +@Composable +private fun FeatureBullet(text: String) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(vertical = 2.dp) + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.symbol_check_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } +} + +@DayNightPreviews +@Composable +private fun BackupUpsellBottomSheetPreview() { + Previews.BottomSheetContentPreview { + UpsellSheetContent( + paidBackupType = MessageBackupsType.Paid( + pricePerMonth = FiatMoney(BigDecimal("1.99"), Currency.getInstance("USD")), + mediaTtl = 30.days, + storageAllowanceBytes = 100.gibiBytes.inWholeBytes + ), + isSubscribeEnabled = true, + onSubscribeClick = {}, + onNoThanksClick = {} + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt index 96350ccf8e..3867bf1e90 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt @@ -706,6 +706,17 @@ class AttachmentTable( } } + /** + * This is "approximate" because it doesn't account for things like duplicates. Only useful as a heuristic. + */ + fun getApproximateTotalMediaSize(): Long { + return readableDatabase + .select("SUM($DATA_SIZE)") + .from(TABLE_NAME) + .run() + .readToSingleLong(0L) + } + fun getOptimizedMediaAttachmentSize(): Long { return readableDatabase .select("SUM($DATA_SIZE)") diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt index ce52af5761..aea5f3e3f5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -1915,9 +1915,15 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat } /** - * Given a set of thread ids, return the count of all messages in the table that match that thread id. This will include *all* messages, and is - * explicitly for use as a "fuzzy total" + * "Approximate" because we're not filtering out stuff like message edits. Only useful as a heuristic. */ + fun getApproximateTotalMessageCount(): Long { + return readableDatabase.count() + .from(TABLE_NAME) + .run() + .readToSingleLong(0L) + } + fun getApproximateExportableMessageCount(threadIds: Set): Long { val queries = SqlUtil.buildCollectionQuery(THREAD_ID, threadIds) return queries.sumOf { diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/BackupUpsellSchedule.kt b/app/src/main/java/org/thoughtcrime/securesms/megaphone/BackupUpsellSchedule.kt new file mode 100644 index 0000000000..9e595d3c54 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/BackupUpsellSchedule.kt @@ -0,0 +1,51 @@ +package org.thoughtcrime.securesms.megaphone + +import org.thoughtcrime.securesms.database.model.MegaphoneRecord +import kotlin.time.Duration.Companion.days + +/** + * Schedule for backup upsell megaphones that combines: + * 1. Per-megaphone recurring display timing (like [RecurringSchedule]) + * 2. Cross-megaphone shared snooze: if any other backup upsell was seen recently, suppress display + * + * @param records All megaphone records, used to find the most recent lastSeen across backup upsell events. + * @param gaps Per-megaphone recurring gaps, same semantics as [RecurringSchedule]. + */ +class BackupUpsellSchedule( + private val records: Map, + vararg val gaps: Long +) : MegaphoneSchedule { + + companion object { + @JvmField + val BACKUP_UPSELL_EVENTS: Set = setOf( + Megaphones.Event.BACKUPS_GENERIC_UPSELL, + Megaphones.Event.BACKUP_MESSAGE_COUNT_UPSELL, + Megaphones.Event.BACKUP_MEDIA_SIZE_UPSELL, + Megaphones.Event.BACKUP_LOW_STORAGE_UPSELL + ) + + @JvmField + val MIN_TIME_BETWEEN_BACKUP_UPSELLS: Long = 60.days.inWholeMilliseconds + } + + override fun shouldDisplay(seenCount: Int, lastSeen: Long, firstVisible: Long, currentTime: Long): Boolean { + val lastSeen = lastSeen.coerceAtMost(currentTime) + + val lastSeenAnyBackupUpsell: Long = records.entries + .filter { it.key in BACKUP_UPSELL_EVENTS } + .mapNotNull { it.value.lastSeen.takeIf { t -> t > 0 } } + .maxOrNull() ?: 0L + + if (currentTime - lastSeenAnyBackupUpsell <= MIN_TIME_BETWEEN_BACKUP_UPSELLS) { + return false + } + + if (seenCount == 0) { + return true + } + + val gap = gaps[minOf(seenCount - 1, gaps.size - 1)] + return lastSeen + gap <= currentTime + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java index 08461a28bd..542485affd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/Megaphones.java @@ -14,11 +14,14 @@ import androidx.core.app.NotificationManagerCompat; import com.annimon.stream.Stream; import com.bumptech.glide.Glide; +import org.signal.core.util.DiskUtil; import org.signal.core.util.MapUtil; import org.signal.core.util.SetUtil; import org.signal.core.util.TranslationDetection; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.backup.v2.MessageBackupTier; +import org.thoughtcrime.securesms.backup.v2.ui.BackupUpsellBottomSheet; import org.thoughtcrime.securesms.backup.v2.ui.verify.VerifyBackupKeyActivity; import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity; import org.thoughtcrime.securesms.database.SignalDatabase; @@ -40,6 +43,7 @@ import org.thoughtcrime.securesms.profiles.manage.EditProfileActivity; import org.thoughtcrime.securesms.profiles.username.NewWaysToConnectDialogFragment; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.storage.StorageSyncHelper; +import org.thoughtcrime.securesms.util.ByteUnit; import org.thoughtcrime.securesms.util.DateUtils; import org.thoughtcrime.securesms.util.Environment; import org.thoughtcrime.securesms.util.RemoteConfig; @@ -129,7 +133,10 @@ public final class Megaphones { // Feature-introduction megaphones should *probably* be added below this divider put(Event.ADD_A_PROFILE_PHOTO, shouldShowAddAProfilePhotoMegaphone(context) ? ALWAYS : NEVER); put(Event.PNP_LAUNCH, shouldShowPnpLaunchMegaphone() ? ALWAYS : NEVER); - put(Event.TURN_ON_SIGNAL_BACKUPS, shouldShowTurnOnBackupsMegaphone(context) ? new RecurringSchedule(TimeUnit.DAYS.toMillis(30), TimeUnit.DAYS.toMillis(90)) : NEVER); + put(Event.BACKUP_LOW_STORAGE_UPSELL, shouldShowBackupLowStorageUpsell(context) ? new BackupUpsellSchedule(records, TimeUnit.DAYS.toMillis(60), TimeUnit.DAYS.toMillis(120)) : NEVER); + put(Event.BACKUP_MEDIA_SIZE_UPSELL, shouldShowBackupMediaSizeUpsell() ? new BackupUpsellSchedule(records, TimeUnit.DAYS.toMillis(60), TimeUnit.DAYS.toMillis(120)) : NEVER); + put(Event.BACKUP_MESSAGE_COUNT_UPSELL, shouldShowBackupMessageCountUpsell(context) ? new BackupUpsellSchedule(records, TimeUnit.DAYS.toMillis(60)) : NEVER); + put(Event.BACKUPS_GENERIC_UPSELL, shouldShowGenericBackupsMegaphone(context) ? new BackupUpsellSchedule(records, TimeUnit.DAYS.toMillis(60)) : NEVER); put(Event.VERIFY_BACKUP_KEY, new VerifyBackupKeyReminderSchedule()); put(Event.USE_NEW_ON_DEVICE_BACKUPS, shouldShowUseNewOnDeviceBackupsMegaphone() ? RecurringSchedule.every(TimeUnit.DAYS.toMillis(14)) : NEVER); }}; @@ -181,8 +188,14 @@ public final class Megaphones { return buildPnpLaunchMegaphone(); case NEW_LINKED_DEVICE: return buildNewLinkedDeviceMegaphone(context); - case TURN_ON_SIGNAL_BACKUPS: - return buildTurnOnSignalBackupsMegaphone(); + case BACKUPS_GENERIC_UPSELL: + return buildBackupGenericUpsellMegaphone(); + case BACKUP_MESSAGE_COUNT_UPSELL: + return buildBackupMessageCountUpsellMegaphone(); + case BACKUP_MEDIA_SIZE_UPSELL: + return buildBackupMediaSizeUpsellMegaphone(); + case BACKUP_LOW_STORAGE_UPSELL: + return buildBackupLowStorageUpsellMegaphone(); case VERIFY_BACKUP_KEY: return buildVerifyBackupKeyMegaphone(); case USE_NEW_ON_DEVICE_BACKUPS: @@ -454,8 +467,8 @@ public final class Megaphones { .build(); } - public static @NonNull Megaphone buildTurnOnSignalBackupsMegaphone() { - return new Megaphone.Builder(Event.TURN_ON_SIGNAL_BACKUPS, Megaphone.Style.BASIC) + public static @NonNull Megaphone buildBackupGenericUpsellMegaphone() { + return new Megaphone.Builder(Event.BACKUPS_GENERIC_UPSELL, Megaphone.Style.BASIC) .setImage(R.drawable.backups_megaphone_image) .setTitle(R.string.TurnOnSignalBackups__title) .setBody(R.string.TurnOnSignalBackups__body) @@ -463,11 +476,11 @@ public final class Megaphones { Intent intent = AppSettingsActivity.remoteBackups(controller.getMegaphoneActivity()); controller.onMegaphoneNavigationRequested(intent); - controller.onMegaphoneSnooze(Event.TURN_ON_SIGNAL_BACKUPS); + controller.onMegaphoneSnooze(Event.BACKUPS_GENERIC_UPSELL); }) .setSecondaryButton(R.string.TurnOnSignalBackups__not_now, (megaphone, controller) -> { controller.onMegaphoneToastRequested(controller.getMegaphoneActivity().getString(R.string.TurnOnSignalBackups__toast_not_now)); - controller.onMegaphoneSnooze(Event.TURN_ON_SIGNAL_BACKUPS); + controller.onMegaphoneSnooze(Event.BACKUPS_GENERIC_UPSELL); }) .build(); } @@ -579,7 +592,7 @@ public final class Megaphones { return SignalStore.account().isPrimaryDevice() && TextUtils.isEmpty(SignalStore.account().getUsername()) && !SignalStore.uiHints().hasCompletedUsernameOnboarding(); } - private static boolean shouldShowTurnOnBackupsMegaphone(@NonNull Context context) { + private static boolean shouldShowGenericBackupsMegaphone(@NonNull Context context) { if (!RemoteConfig.backupsMegaphone()) { return false; } @@ -631,6 +644,92 @@ public final class Megaphones { return System.currentTimeMillis() - lastSeenDonatePrompt; } + private static boolean shouldShowBackupMessageCountUpsell(@NonNull Context context) { + if (!SignalStore.account().isRegistered() || TextSecurePreferences.isUnauthorizedReceived(context) || SignalStore.account().isLinkedDevice()) { + return false; + } + + if (SignalStore.backup().getLatestBackupTier() != null) { + return false; + } + + return SignalDatabase.messages().getApproximateTotalMessageCount() > 1000; + } + + private static boolean shouldShowBackupMediaSizeUpsell() { + if (!SignalStore.account().isRegistered() || SignalStore.account().isLinkedDevice() || !Environment.Backups.supportsGooglePlayBilling()) { + return false; + } + + if (SignalStore.backup().getLatestBackupTier() != MessageBackupTier.FREE) { + return false; + } + + return SignalDatabase.attachments().getApproximateTotalMediaSize() > ByteUnit.GIGABYTES.toBytes(1); + } + + private static boolean shouldShowBackupLowStorageUpsell(@NonNull Context context) { + if (!SignalStore.account().isRegistered() || TextSecurePreferences.isUnauthorizedReceived(context) || SignalStore.account().isLinkedDevice() || !Environment.Backups.supportsGooglePlayBilling()) { + return false; + } + + if (SignalStore.backup().getLatestBackupTier() == MessageBackupTier.PAID) { + return false; + } + + long available = DiskUtil.getAvailableSpace(context).getBytes(); + long total = DiskUtil.getTotalDiskSize(context).getBytes(); + + return total > 0 && ((double) available / total) < 0.10; + } + + private static @NonNull Megaphone buildBackupMessageCountUpsellMegaphone() { + return new Megaphone.Builder(Event.BACKUP_MESSAGE_COUNT_UPSELL, Megaphone.Style.BASIC) + .setImage(R.drawable.megaphone_backup_message_count) + .setTitle(R.string.BackupMessagesUpsell__title) + .setBody(R.string.BackupMessagesUpsell__body) + .setActionButton(R.string.BackupMessagesUpsell__turn_on, (megaphone, controller) -> { + Intent intent = AppSettingsActivity.remoteBackups(controller.getMegaphoneActivity()); + controller.onMegaphoneNavigationRequested(intent); + controller.onMegaphoneSnooze(Event.BACKUP_MESSAGE_COUNT_UPSELL); + }) + .setSecondaryButton(R.string.BackupMessagesUpsell__not_now, (megaphone, controller) -> { + controller.onMegaphoneSnooze(Event.BACKUP_MESSAGE_COUNT_UPSELL); + }) + .build(); + } + + private static @NonNull Megaphone buildBackupMediaSizeUpsellMegaphone() { + return new Megaphone.Builder(Event.BACKUP_MEDIA_SIZE_UPSELL, Megaphone.Style.BASIC) + .setImage(R.drawable.megaphone_backup_media_size) + .setTitle(R.string.BackupMediaUpsell__title) + .setBody(R.string.BackupMediaUpsell__body) + .setActionButton(R.string.BackupMediaUpsell__upgrade, (megaphone, controller) -> { + controller.onMegaphoneDialogFragmentRequested(BackupUpsellBottomSheet.create(false)); + controller.onMegaphoneSnooze(Event.BACKUP_MEDIA_SIZE_UPSELL); + }) + .setSecondaryButton(R.string.BackupMediaUpsell__not_now, (megaphone, controller) -> { + controller.onMegaphoneSnooze(Event.BACKUP_MEDIA_SIZE_UPSELL); + }) + .build(); + } + + private static @NonNull Megaphone buildBackupLowStorageUpsellMegaphone() { + boolean hasBackups = SignalStore.backup().getLatestBackupTier() != null; + + return new Megaphone.Builder(Event.BACKUP_LOW_STORAGE_UPSELL, Megaphone.Style.BASIC) + .setImage(R.drawable.megaphone_backup_storage_low) + .setTitle(R.string.BackupStorageUpsell__title) + .setBody(R.string.BackupStorageUpsell__body) + .setActionButton(hasBackups ? R.string.BackupStorageUpsell__upgrade : R.string.BackupStorageUpsell__turn_on, (megaphone, controller) -> { + controller.onMegaphoneDialogFragmentRequested(BackupUpsellBottomSheet.create(true)); + controller.onMegaphoneSnooze(Event.BACKUP_LOW_STORAGE_UPSELL); + }) + .setSecondaryButton(R.string.BackupStorageUpsell__not_now, (megaphone, controller) -> { + controller.onMegaphoneSnooze(Event.BACKUP_LOW_STORAGE_UPSELL); + }) + .build(); + } public enum Event { PINS_FOR_ALL("pins_for_all"), @@ -649,7 +748,10 @@ public final class Megaphones { PNP_LAUNCH("pnp_launch"), GRANT_FULL_SCREEN_INTENT("grant_full_screen_intent"), NEW_LINKED_DEVICE("new_linked_device"), - TURN_ON_SIGNAL_BACKUPS("turn_on_signal_backups"), + BACKUPS_GENERIC_UPSELL("turn_on_signal_backups"), + BACKUP_MESSAGE_COUNT_UPSELL("backup_messages_upsell"), + BACKUP_MEDIA_SIZE_UPSELL("backup_media_upsell"), + BACKUP_LOW_STORAGE_UPSELL("backup_storage_upsell"), VERIFY_BACKUP_KEY("verify_backup_key"), USE_NEW_ON_DEVICE_BACKUPS("use_new_on_device_backups"); diff --git a/app/src/main/res/drawable-night/megaphone_backup_media_size.xml b/app/src/main/res/drawable-night/megaphone_backup_media_size.xml new file mode 100644 index 0000000000..960b5e2f47 --- /dev/null +++ b/app/src/main/res/drawable-night/megaphone_backup_media_size.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/drawable-night/megaphone_backup_message_count.xml b/app/src/main/res/drawable-night/megaphone_backup_message_count.xml new file mode 100644 index 0000000000..f74b272213 --- /dev/null +++ b/app/src/main/res/drawable-night/megaphone_backup_message_count.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable-night/megaphone_backup_storage_low.xml b/app/src/main/res/drawable-night/megaphone_backup_storage_low.xml new file mode 100644 index 0000000000..4c2e83376e --- /dev/null +++ b/app/src/main/res/drawable-night/megaphone_backup_storage_low.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/drawable/megaphone_backup_media_size.xml b/app/src/main/res/drawable/megaphone_backup_media_size.xml new file mode 100644 index 0000000000..8737ecf583 --- /dev/null +++ b/app/src/main/res/drawable/megaphone_backup_media_size.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/megaphone_backup_message_count.xml b/app/src/main/res/drawable/megaphone_backup_message_count.xml new file mode 100644 index 0000000000..91fb443825 --- /dev/null +++ b/app/src/main/res/drawable/megaphone_backup_message_count.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/megaphone_backup_storage_low.xml b/app/src/main/res/drawable/megaphone_backup_storage_low.xml new file mode 100644 index 0000000000..b7c7613910 --- /dev/null +++ b/app/src/main/res/drawable/megaphone_backup_storage_low.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4834ff0dd6..86c6fb3169 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7915,6 +7915,67 @@ You can enable backups in \"Settings\" + + Never lose a message + + Turn on Signal Secure Backups to preserve your messages and media. + + Turn on + + Not now + + + Back up all your media + + Paid Signal Secure Backup plans preserve all your media, up to 100GB. + + Upgrade + + Not now + + + Save space with paid backups + + Paid secure backup plans can save storage space by offloading media. + + Upgrade + + Turn on + + Not now + + + Upgrade to back up all media + + Paid Signal Secure Backup plans save all media you send and receive, up to a maximum of 100GB. Never lose an image or video when you get a new phone or reinstall Signal. + + %s/month + + Text + all your media + + Full text + media backup + + 100GB storage + + Save on-device storage + + Thanks for supporting Signal + + Subscribe for %s/month + + No thanks + + + You\'re all set. Start your backup now. + + This could take a while. You can use Signal normally while backing up. + + Optimize Signal storage + + Older media will be offloaded, but can be downloaded from your backup anytime. + + Back up now + Use new on-device backups diff --git a/app/src/test/java/org/thoughtcrime/securesms/megaphone/BackupUpsellScheduleTest.kt b/app/src/test/java/org/thoughtcrime/securesms/megaphone/BackupUpsellScheduleTest.kt new file mode 100644 index 0000000000..9d17ad8afc --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/megaphone/BackupUpsellScheduleTest.kt @@ -0,0 +1,128 @@ +package org.thoughtcrime.securesms.megaphone + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.thoughtcrime.securesms.database.model.MegaphoneRecord +import org.thoughtcrime.securesms.megaphone.Megaphones.Event +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours + +class BackupUpsellScheduleTest { + + private val now = System.currentTimeMillis() + + // -- First display (seenCount = 0) -- + + @Test + fun `shows on first display when no other backup upsell seen`() { + val schedule = schedule(emptyRecords()) + assertTrue(schedule.shouldDisplay(0, 0, 0, now)) + } + + @Test + fun `shows on first display when other upsell was seen outside the threshold`() { + val records = emptyRecords().toMutableMap().apply { + put(Event.BACKUPS_GENERIC_UPSELL, record(Event.BACKUPS_GENERIC_UPSELL, lastSeen = now - 60.days.inWholeMilliseconds - 1)) + } + val schedule = schedule(records) + assertTrue(schedule.shouldDisplay(0, 0, 0, now)) + } + + @Test + fun `suppressed on first display when another upsell was seen within the threshold`() { + val records = emptyRecords().toMutableMap().apply { + put(Event.BACKUPS_GENERIC_UPSELL, record(Event.BACKUPS_GENERIC_UPSELL, lastSeen = now - 60.days.inWholeMilliseconds + 1.hours.inWholeMilliseconds)) + } + val schedule = schedule(records) + assertFalse(schedule.shouldDisplay(0, 0, 0, now)) + } + + // -- Recurring gap logic (seenCount > 0) -- + + @Test + fun `shows after gap elapsed since last seen`() { + val gapMs = 60.days.inWholeMilliseconds + val schedule = schedule(emptyRecords(), gapMs) + assertTrue(schedule.shouldDisplay(1, now - gapMs - 1, 0, now)) + } + + @Test + fun `suppressed when gap has not elapsed since last seen`() { + val gapMs = 60.days.inWholeMilliseconds + val schedule = schedule(emptyRecords(), gapMs) + assertFalse(schedule.shouldDisplay(1, now - gapMs + 1.hours.inWholeMilliseconds, 0, now)) + } + + @Test + fun `uses second gap for second snooze`() { + val firstGap = 60.days.inWholeMilliseconds + val secondGap = 120.days.inWholeMilliseconds + val schedule = schedule(emptyRecords(), firstGap, secondGap) + + // seenCount=2 -> uses gaps[1] = 120 days + assertFalse(schedule.shouldDisplay(2, now - 100.days.inWholeMilliseconds, 0, now)) + assertTrue(schedule.shouldDisplay(2, now - secondGap - 1, 0, now)) + } + + @Test + fun `repeats last gap for high seen counts`() { + val firstGap = 60.days.inWholeMilliseconds + val secondGap = 120.days.inWholeMilliseconds + val schedule = schedule(emptyRecords(), firstGap, secondGap) + + // seenCount=5 -> clamps to gaps[1] = 120 days + assertFalse(schedule.shouldDisplay(5, now - 100.days.inWholeMilliseconds, 0, now)) + assertTrue(schedule.shouldDisplay(5, now - secondGap - 1, 0, now)) + } + + // -- Combined: cross-event snooze AND recurring gap -- + + @Test + fun `cross-event snooze blocks even when recurring gap is satisfied`() { + val gapMs = 60.days.inWholeMilliseconds + val records = emptyRecords().toMutableMap().apply { + put(Event.BACKUP_LOW_STORAGE_UPSELL, record(Event.BACKUP_LOW_STORAGE_UPSELL, lastSeen = now - 30.days.inWholeMilliseconds)) + } + val schedule = schedule(records, gapMs) + + // Own gap satisfied (last seen 90 days ago) but another upsell was seen 30 days ago + assertFalse(schedule.shouldDisplay(1, now - 90.days.inWholeMilliseconds, 0, now)) + } + + @Test + fun `shows when both cross-event snooze and recurring gap are satisfied`() { + val gapMs = 60.days.inWholeMilliseconds + val records = emptyRecords().toMutableMap().apply { + put(Event.BACKUP_LOW_STORAGE_UPSELL, record(Event.BACKUP_LOW_STORAGE_UPSELL, lastSeen = now - 60.days.inWholeMilliseconds - 1)) + } + val schedule = schedule(records, gapMs) + assertTrue(schedule.shouldDisplay(1, now - gapMs - 1, 0, now)) + } + + // -- Ignores non-backup events -- + + @Test + fun `ignores non-backup upsell events in cross-event check`() { + val records = emptyRecords().toMutableMap().apply { + put(Event.PIN_REMINDER, record(Event.PIN_REMINDER, lastSeen = now)) + put(Event.NOTIFICATIONS, record(Event.NOTIFICATIONS, lastSeen = now)) + } + val schedule = schedule(records) + assertTrue(schedule.shouldDisplay(0, 0, 0, now)) + } + + // -- Helpers -- + + private fun schedule(records: Map, vararg gaps: Long): BackupUpsellSchedule { + return BackupUpsellSchedule(records, *gaps) + } + + private fun record(event: Event, seenCount: Int = 1, lastSeen: Long = 0, firstVisible: Long = 0, finished: Boolean = false): MegaphoneRecord { + return MegaphoneRecord(event, seenCount, lastSeen, firstVisible, finished) + } + + private fun emptyRecords(): Map { + return BackupUpsellSchedule.BACKUP_UPSELL_EVENTS.associateWith { record(it, seenCount = 0) } + } +}