Add some megaphones to encourage users to try backups.

This commit is contained in:
Greyson Parrelli
2026-03-02 16:58:38 -05:00
parent a95ebb2158
commit 7fbcd17759
14 changed files with 861 additions and 11 deletions

View File

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

View File

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

View File

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

View File

@@ -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>): Long {
val queries = SqlUtil.buildCollectionQuery(THREAD_ID, threadIds)
return queries.sumOf {

View File

@@ -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<Megaphones.Event, MegaphoneRecord>,
vararg val gaps: Long
) : MegaphoneSchedule {
companion object {
@JvmField
val BACKUP_UPSELL_EVENTS: Set<Megaphones.Event> = 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
}
}

View File

@@ -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");

View File

@@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="64dp"
android:height="64dp"
android:viewportWidth="64"
android:viewportHeight="64">
<path
android:pathData="M13.632,15.546L38.203,10.934A4,4 64.903,0 1,42.872 14.128L46.303,32.407A4,4 94.864,0 1,43.11 37.077L18.539,41.689A4,4 54.899,0 1,13.87 38.496L10.438,20.216A4,4 0,0 1,13.632 15.546z"
android:fillColor="#E7EBFF"/>
<path
android:pathData="M24,23L49,23A4,4 0,0 1,53 27L53,47A4,4 0,0 1,49 51L24,51A4,4 0,0 1,20 47L20,27A4,4 0,0 1,24 23z"
android:fillColor="#D2D8FE"/>
<path
android:pathData="M29.833,28.479C27.889,28.479 26.313,30.056 26.313,32C26.313,33.944 27.889,35.521 29.833,35.521C31.778,35.521 33.354,33.944 33.354,32C33.354,30.056 31.778,28.479 29.833,28.479Z"
android:fillColor="#666EE5"/>
<path
android:pathData="M46.353,19.73L46.614,21.713C47.399,21.72 48.088,21.738 48.69,21.788C49.694,21.87 50.6,22.045 51.447,22.476C52.772,23.151 53.849,24.228 54.524,25.553C54.956,26.4 55.131,27.306 55.213,28.31C55.292,29.28 55.292,30.473 55.292,31.932V41.818C55.292,43.277 55.292,44.47 55.213,45.44C55.131,46.444 54.956,47.35 54.524,48.197C53.849,49.522 52.772,50.599 51.447,51.274C50.6,51.706 49.694,51.881 48.69,51.963C47.721,52.042 46.527,52.042 45.068,52.042H27.599C26.14,52.042 24.947,52.042 23.977,51.963C22.973,51.881 22.067,51.706 21.22,51.274C19.895,50.599 18.818,49.522 18.143,48.197C17.711,47.35 17.536,46.444 17.454,45.44C17.39,44.653 17.378,43.72 17.376,42.616C16.525,42.632 15.731,42.554 14.956,42.28C13.554,41.783 12.346,40.856 11.503,39.63C10.965,38.847 10.674,37.971 10.461,36.987C10.256,36.036 10.1,34.853 9.91,33.406L8.761,24.679C8.571,23.232 8.415,22.049 8.367,21.077C8.317,20.072 8.372,19.15 8.689,18.254C9.186,16.853 10.113,15.644 11.339,14.802C12.122,14.264 12.998,13.972 13.982,13.759C14.933,13.554 16.116,13.399 17.563,13.208L34.883,10.928C36.329,10.738 37.512,10.582 38.484,10.534C39.49,10.484 40.411,10.539 41.307,10.856C42.709,11.353 43.917,12.28 44.76,13.506C45.298,14.289 45.59,15.165 45.802,16.149C46.007,17.1 46.163,18.283 46.353,19.73ZM17.375,39.365C16.722,39.382 16.336,39.32 16.041,39.216C15.286,38.949 14.635,38.449 14.182,37.789C13.983,37.501 13.808,37.089 13.638,36.302C13.464,35.496 13.325,34.445 13.123,32.915L11.992,24.322C11.791,22.792 11.653,21.74 11.613,20.917C11.573,20.113 11.636,19.669 11.753,19.339C12.02,18.584 12.52,17.934 13.179,17.48C13.468,17.282 13.88,17.106 14.667,16.936C15.473,16.763 16.524,16.623 18.054,16.421L35.239,14.159C36.77,13.958 37.821,13.82 38.644,13.78C39.449,13.74 39.892,13.803 40.222,13.92C40.977,14.187 41.628,14.686 42.081,15.346C42.28,15.635 42.455,16.047 42.625,16.834C42.799,17.64 42.938,18.691 43.14,20.221L43.336,21.708L27.599,21.708C26.14,21.708 24.947,21.708 23.977,21.788C22.973,21.87 22.067,22.045 21.22,22.476C19.895,23.151 18.818,24.228 18.143,25.553C17.711,26.4 17.536,27.306 17.454,28.31C17.375,29.28 17.375,30.473 17.375,31.932L17.375,39.365ZM51.629,27.029C51.788,27.341 51.908,27.772 51.973,28.575C52.041,29.396 52.042,30.456 52.042,32V41.205L45.099,35.254C43.172,33.602 40.328,33.602 38.401,35.254L31.323,41.321L29.58,39.826C27.856,38.348 25.311,38.348 23.587,39.826L20.625,42.365C20.625,42.169 20.625,41.964 20.625,41.75L20.625,32C20.625,30.456 20.626,29.396 20.694,28.575C20.759,27.772 20.879,27.341 21.038,27.029C21.402,26.315 21.982,25.735 22.695,25.372C23.008,25.213 23.439,25.092 24.242,25.027C25.063,24.96 26.123,24.958 27.667,24.958L45,24.958C46.544,24.958 47.604,24.96 48.425,25.027C49.228,25.092 49.66,25.213 49.972,25.372C50.685,25.735 51.265,26.315 51.629,27.029ZM20.768,45.81C20.836,46.229 20.928,46.504 21.038,46.721C21.402,47.435 21.982,48.015 22.695,48.378C23.008,48.537 23.439,48.658 24.242,48.723C25.063,48.79 26.123,48.792 27.667,48.792H45C46.544,48.792 47.604,48.79 48.425,48.723C49.228,48.658 49.66,48.537 49.972,48.378C50.685,48.015 51.265,47.435 51.629,46.721C51.788,46.409 51.908,45.978 51.973,45.175C51.985,45.037 51.994,44.891 52.002,44.738L43.336,37.31C42.424,36.528 41.077,36.528 40.164,37.31L33.404,43.104L33.965,43.585C34.533,44.072 34.598,44.927 34.112,45.494C33.625,46.062 32.77,46.128 32.202,45.641L27.817,41.883C27.107,41.274 26.06,41.274 25.35,41.883L20.768,45.81Z"
android:fillColor="#666EE5"
android:fillType="evenOdd"/>
</vector>

View File

@@ -0,0 +1,20 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="64dp"
android:height="64dp"
android:viewportWidth="64"
android:viewportHeight="64">
<path
android:pathData="M32.588,13.118L24.159,9.505L18.74,10.107L11.516,15.526L8.505,22.751L12.118,32.986L13.322,42.017L20.546,38.405H25.965L34.394,35.394L38.609,28.17L36.802,20.343L32.588,13.118Z"
android:strokeWidth="1.20414"
android:fillColor="#D2D8FE"
android:strokeColor="#666EE5"/>
<path
android:pathData="M23.557,8.607C32.365,8.608 39.507,15.748 39.507,24.557C39.507,33.365 32.365,40.507 23.557,40.507C22.444,40.507 21.358,40.39 20.309,40.173L14.977,43.881C13.098,45.186 10.548,43.714 10.738,41.435L11.298,34.758C8.995,31.993 7.607,28.435 7.607,24.557C7.608,15.748 14.748,8.608 23.557,8.607ZM23.557,11.607C16.405,11.608 10.608,17.405 10.607,24.557C10.607,27.734 11.75,30.641 13.648,32.895C14.1,33.431 14.36,34.145 14.297,34.901L13.78,41.058L18.69,37.646L18.813,37.564C19.356,37.23 19.979,37.109 20.573,37.177L20.845,37.222L21.174,37.287C21.946,37.431 22.742,37.507 23.557,37.507C30.708,37.507 36.507,31.708 36.507,24.557C36.507,17.405 30.708,11.608 23.557,11.607Z"
android:fillColor="#666EE5"/>
<path
android:pathData="M48.242,23.353L42.221,22.751H36.2L32.588,25.761L28.976,31.18V38.404L32.588,46.833L42.823,51.048L48.844,52.252L53.66,54.058L54.262,51.048L55.466,44.425L56.671,35.394L54.864,28.169L48.242,23.353Z"
android:fillColor="#D2D8FE"/>
<path
android:pathData="M42.824,22.149V20.649H42.824L42.824,22.149ZM28.373,36.598H26.873V36.598L28.373,36.598ZM42.824,51.048L42.824,52.548H42.824V51.048ZM57.273,36.598L58.773,36.598V36.598H57.273ZM53.903,45.873L52.754,44.909L53.903,45.873ZM52.303,54.846L53.159,53.614L52.303,54.846ZM45.72,50.756L45.421,49.286L45.72,50.756ZM46.694,50.947L45.838,52.179L46.694,50.947ZM42.824,22.149V23.649C35.672,23.649 29.874,29.446 29.873,36.598L28.373,36.598L26.873,36.598C26.874,27.789 34.015,20.649 42.824,20.649V22.149ZM28.373,36.598H29.873C29.873,43.75 35.672,49.548 42.824,49.548V51.048V52.548C34.015,52.548 26.873,45.407 26.873,36.598H28.373ZM42.824,51.048L42.823,49.548C43.714,49.548 44.582,49.458 45.421,49.286L45.72,50.756L46.02,52.226C44.987,52.437 43.918,52.548 42.824,52.548L42.824,51.048ZM46.694,50.947L47.55,49.716L53.159,53.614L52.303,54.846L51.446,56.077L45.838,52.179L46.694,50.947ZM54.19,53.756L52.695,53.882L52.111,46.911L53.605,46.785L55.1,46.66L55.685,53.631L54.19,53.756ZM53.903,45.873L52.754,44.909C54.639,42.66 55.773,39.763 55.773,36.598H57.273H58.773C58.773,40.494 57.374,44.066 55.053,46.836L53.903,45.873ZM57.273,36.598L55.773,36.598C55.772,29.447 49.975,23.649 42.823,23.649L42.824,22.149L42.824,20.649C51.632,20.649 58.772,27.79 58.773,36.598L57.273,36.598ZM53.605,46.785L52.111,46.911C52.047,46.157 52.305,45.445 52.754,44.909L53.903,45.873L55.053,46.836C55.075,46.81 55.108,46.751 55.1,46.66L53.605,46.785ZM52.303,54.846L53.159,53.614C52.953,53.471 52.674,53.632 52.695,53.882L54.19,53.756L55.685,53.631C55.876,55.911 53.325,57.383 51.446,56.077L52.303,54.846ZM45.72,50.756L45.421,49.286C46.13,49.142 46.907,49.268 47.55,49.716L46.694,50.947L45.838,52.179C45.916,52.233 45.986,52.233 46.02,52.226L45.72,50.756Z"
android:fillColor="#666EE5"/>
</vector>

View File

@@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="64dp"
android:height="64dp"
android:viewportWidth="64"
android:viewportHeight="64">
<path
android:pathData="M45.5,48.5L32.5,32H54L51,42.5L45.5,48.5Z"
android:fillColor="#EFF2FF"/>
<path
android:pathData="M22.5,13.5L31,10H32.5V31L45,48L43.5,50.5L34.5,53.5L23.5,52.5L14.5,46L11.5,35.5V25.5L16.5,16.5L22.5,13.5Z"
android:fillColor="#D2D8FE"/>
<path
android:pathData="M32.5,32L32,10.5L38,11L47,16L51.5,22L53.5,32H32.5Z"
android:fillColor="#989DF9"/>
<path
android:pathData="M32,8.708C19.136,8.708 8.708,19.136 8.708,32C8.708,44.864 19.136,55.292 32,55.292C44.864,55.292 55.292,44.864 55.292,32C55.292,19.136 44.864,8.708 32,8.708ZM30.375,12.023C20.066,12.851 11.958,21.478 11.958,32C11.958,43.069 20.931,52.042 32,52.042C36.181,52.042 40.064,50.761 43.276,48.571L31.695,34.211C30.841,33.151 30.375,31.831 30.375,30.47L30.375,12.023ZM45.805,46.529C49.341,43.168 51.651,38.53 51.997,33.354H40.667L34.889,32.993L45.805,46.529ZM33.625,30.104L51.953,30.104C51.048,20.459 43.307,12.8 33.625,12.023L33.625,30.104Z"
android:fillColor="#666EE5"
android:fillType="evenOdd"/>
</vector>

View File

@@ -0,0 +1,24 @@
<!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="64dp"
android:height="64dp"
android:viewportWidth="64"
android:viewportHeight="64">
<path
android:pathData="M13.632,15.546L38.203,10.934A4,4 64.903,0 1,42.872 14.128L46.248,32.113A4,4 107.165,0 1,43.055 36.782L18.484,41.394A4,4 50.655,0 1,13.814 38.201L10.438,20.216A4,4 0,0 1,13.632 15.546z"
android:fillColor="#E7EBFF"/>
<path
android:pathData="M24,23L49,23A4,4 0,0 1,53 27L53,47A4,4 0,0 1,49 51L24,51A4,4 0,0 1,20 47L20,27A4,4 0,0 1,24 23z"
android:fillColor="#D2D8FE"/>
<path
android:pathData="M29.833,28.479C27.889,28.479 26.313,30.056 26.313,32C26.313,33.944 27.889,35.521 29.833,35.521C31.778,35.521 33.354,33.944 33.354,32C33.354,30.056 31.778,28.479 29.833,28.479Z"
android:fillColor="#3B45FD"/>
<path
android:pathData="M46.353,19.73L46.614,21.713C47.399,21.72 48.088,21.738 48.69,21.788C49.694,21.87 50.6,22.045 51.447,22.476C52.772,23.151 53.849,24.228 54.524,25.553C54.956,26.4 55.131,27.306 55.213,28.31C55.292,29.28 55.292,30.473 55.292,31.932V41.818C55.292,43.277 55.292,44.47 55.213,45.44C55.131,46.444 54.956,47.35 54.524,48.197C53.849,49.522 52.772,50.599 51.447,51.274C50.6,51.706 49.694,51.881 48.69,51.963C47.721,52.042 46.527,52.042 45.068,52.042H27.599C26.14,52.042 24.947,52.042 23.977,51.963C22.973,51.881 22.067,51.706 21.22,51.274C19.895,50.599 18.818,49.522 18.143,48.197C17.711,47.35 17.536,46.444 17.454,45.44C17.39,44.653 17.378,43.72 17.376,42.616C16.525,42.632 15.731,42.554 14.956,42.28C13.554,41.783 12.346,40.856 11.503,39.63C10.965,38.847 10.674,37.971 10.461,36.987C10.256,36.036 10.1,34.853 9.91,33.406L8.761,24.679C8.571,23.232 8.415,22.049 8.367,21.077C8.317,20.072 8.372,19.15 8.689,18.254C9.186,16.853 10.113,15.644 11.339,14.802C12.122,14.264 12.998,13.972 13.982,13.759C14.933,13.554 16.116,13.399 17.563,13.208L34.883,10.928C36.329,10.738 37.512,10.582 38.484,10.534C39.49,10.484 40.411,10.539 41.307,10.856C42.709,11.353 43.917,12.28 44.76,13.506C45.298,14.289 45.59,15.165 45.802,16.149C46.007,17.1 46.163,18.283 46.353,19.73ZM17.375,39.365C16.722,39.382 16.336,39.32 16.041,39.216C15.286,38.949 14.635,38.449 14.182,37.789C13.983,37.501 13.808,37.089 13.638,36.302C13.464,35.496 13.325,34.445 13.123,32.915L11.992,24.322C11.791,22.792 11.653,21.74 11.613,20.917C11.573,20.113 11.636,19.669 11.753,19.339C12.02,18.584 12.52,17.934 13.179,17.48C13.468,17.282 13.88,17.106 14.667,16.936C15.473,16.763 16.524,16.623 18.054,16.421L35.239,14.159C36.77,13.958 37.821,13.82 38.644,13.78C39.449,13.74 39.892,13.803 40.222,13.92C40.977,14.187 41.628,14.686 42.081,15.346C42.28,15.635 42.455,16.047 42.625,16.834C42.799,17.64 42.938,18.691 43.14,20.221L43.336,21.708L27.599,21.708C26.14,21.708 24.947,21.708 23.977,21.788C22.973,21.87 22.067,22.045 21.22,22.476C19.895,23.151 18.818,24.228 18.143,25.553C17.711,26.4 17.536,27.306 17.454,28.31C17.375,29.28 17.375,30.473 17.375,31.932L17.375,39.365ZM51.629,27.029C51.788,27.341 51.908,27.772 51.973,28.575C52.041,29.396 52.042,30.456 52.042,32V41.205L45.099,35.254C43.172,33.602 40.328,33.602 38.401,35.254L31.323,41.321L29.58,39.826C27.856,38.348 25.311,38.348 23.587,39.826L20.625,42.365C20.625,42.169 20.625,41.964 20.625,41.75L20.625,32C20.625,30.456 20.626,29.396 20.694,28.575C20.759,27.772 20.879,27.341 21.038,27.029C21.402,26.315 21.982,25.735 22.695,25.372C23.008,25.213 23.439,25.092 24.242,25.027C25.063,24.96 26.123,24.958 27.667,24.958L45,24.958C46.544,24.958 47.604,24.96 48.425,25.027C49.228,25.092 49.66,25.213 49.972,25.372C50.685,25.735 51.265,26.315 51.629,27.029ZM20.768,45.81C20.836,46.229 20.928,46.504 21.038,46.721C21.402,47.435 21.982,48.015 22.695,48.378C23.008,48.537 23.439,48.658 24.242,48.723C25.063,48.79 26.123,48.792 27.667,48.792H45C46.544,48.792 47.604,48.79 48.425,48.723C49.228,48.658 49.66,48.537 49.972,48.378C50.685,48.015 51.265,47.435 51.629,46.721C51.788,46.409 51.908,45.978 51.973,45.175C51.985,45.037 51.994,44.891 52.002,44.738L43.336,37.31C42.424,36.528 41.077,36.528 40.164,37.31L33.404,43.104L33.965,43.585C34.533,44.072 34.598,44.927 34.112,45.494C33.625,46.062 32.77,46.128 32.202,45.641L27.817,41.883C27.107,41.274 26.06,41.274 25.35,41.883L20.768,45.81Z"
android:fillColor="#3B45FD"
android:fillType="evenOdd"/>
</vector>

View File

@@ -0,0 +1,20 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="64dp"
android:height="64dp"
android:viewportWidth="64"
android:viewportHeight="64">
<path
android:pathData="M32.588,13.118L24.159,9.505L18.74,10.107L11.516,15.526L8.505,22.751L12.118,32.986L13.322,42.017L20.546,38.405H25.965L34.394,35.394L38.609,28.17L36.802,20.343L32.588,13.118Z"
android:strokeWidth="1.20414"
android:fillColor="#D2D8FE"
android:strokeColor="#3B45FD"/>
<path
android:pathData="M23.557,8.607C32.365,8.608 39.507,15.748 39.507,24.557C39.507,33.365 32.365,40.507 23.557,40.507C22.444,40.507 21.358,40.39 20.309,40.173L14.977,43.881C13.098,45.186 10.548,43.714 10.738,41.435L11.298,34.758C8.995,31.993 7.607,28.435 7.607,24.557C7.608,15.748 14.748,8.608 23.557,8.607ZM23.557,11.607C16.405,11.608 10.608,17.405 10.607,24.557C10.607,27.734 11.75,30.641 13.648,32.895C14.1,33.431 14.36,34.145 14.297,34.901L13.78,41.058L18.69,37.646L18.813,37.564C19.356,37.23 19.979,37.109 20.573,37.177L20.845,37.222L21.174,37.287C21.946,37.431 22.742,37.507 23.557,37.507C30.708,37.507 36.507,31.708 36.507,24.557C36.507,17.405 30.708,11.608 23.557,11.607Z"
android:fillColor="#3B45FD"/>
<path
android:pathData="M48.242,23.353L42.221,22.751H36.2L32.588,25.761L28.976,31.18V38.404L32.588,46.833L42.823,51.048L48.844,52.252L53.66,54.058L54.262,51.048L55.466,44.425L56.671,35.394L54.864,28.169L48.242,23.353Z"
android:fillColor="#D2D8FE"/>
<path
android:pathData="M42.824,22.149V20.649H42.824L42.824,22.149ZM28.373,36.598H26.873V36.598L28.373,36.598ZM42.824,51.048L42.824,52.548H42.824V51.048ZM57.273,36.598L58.773,36.598V36.598H57.273ZM53.903,45.873L52.754,44.909L53.903,45.873ZM52.303,54.846L53.159,53.614L52.303,54.846ZM45.72,50.756L45.421,49.286L45.72,50.756ZM46.694,50.947L45.838,52.179L46.694,50.947ZM42.824,22.149V23.649C35.672,23.649 29.874,29.446 29.873,36.598L28.373,36.598L26.873,36.598C26.874,27.789 34.015,20.649 42.824,20.649V22.149ZM28.373,36.598H29.873C29.873,43.75 35.672,49.548 42.824,49.548V51.048V52.548C34.015,52.548 26.873,45.407 26.873,36.598H28.373ZM42.824,51.048L42.823,49.548C43.714,49.548 44.582,49.458 45.421,49.286L45.72,50.756L46.02,52.226C44.987,52.437 43.918,52.548 42.824,52.548L42.824,51.048ZM46.694,50.947L47.55,49.716L53.159,53.614L52.303,54.846L51.446,56.077L45.838,52.179L46.694,50.947ZM54.19,53.756L52.695,53.882L52.111,46.911L53.605,46.785L55.1,46.66L55.685,53.631L54.19,53.756ZM53.903,45.873L52.754,44.909C54.639,42.66 55.773,39.763 55.773,36.598H57.273H58.773C58.773,40.494 57.374,44.066 55.053,46.836L53.903,45.873ZM57.273,36.598L55.773,36.598C55.772,29.447 49.975,23.649 42.823,23.649L42.824,22.149L42.824,20.649C51.632,20.649 58.772,27.79 58.773,36.598L57.273,36.598ZM53.605,46.785L52.111,46.911C52.047,46.157 52.305,45.445 52.754,44.909L53.903,45.873L55.053,46.836C55.075,46.81 55.108,46.751 55.1,46.66L53.605,46.785ZM52.303,54.846L53.159,53.614C52.953,53.471 52.674,53.632 52.695,53.882L54.19,53.756L55.685,53.631C55.876,55.911 53.325,57.383 51.446,56.077L52.303,54.846ZM45.72,50.756L45.421,49.286C46.13,49.142 46.907,49.268 47.55,49.716L46.694,50.947L45.838,52.179C45.916,52.233 45.986,52.233 46.02,52.226L45.72,50.756Z"
android:fillColor="#3B45FD"/>
</vector>

View File

@@ -0,0 +1,26 @@
<!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="64dp"
android:height="64dp"
android:viewportWidth="64"
android:viewportHeight="64">
<path
android:pathData="M32.5,32L32,10.5L38,11L47,16L51.5,22L53.5,32H32.5Z"
android:strokeAlpha="0.5"
android:fillColor="#3B45FD"
android:fillAlpha="0.5"/>
<path
android:pathData="M45.5,48.5L32.5,32H54L51,42.5L45.5,48.5Z"
android:fillColor="#EAEDFF"/>
<path
android:pathData="M22.5,13.5L31,10H32.5V31L45,48L43.5,50.5L34.5,53.5L23.5,52.5L14.5,46L11.5,35.5V25.5L16.5,16.5L22.5,13.5Z"
android:fillColor="#D2D8FE"/>
<path
android:pathData="M32,8.708C19.136,8.708 8.708,19.136 8.708,32C8.708,44.864 19.136,55.292 32,55.292C44.864,55.292 55.292,44.864 55.292,32C55.292,19.136 44.864,8.708 32,8.708ZM30.375,12.023C20.066,12.851 11.958,21.478 11.958,32C11.958,43.069 20.931,52.042 32,52.042C36.181,52.042 40.064,50.761 43.276,48.571L31.695,34.211C30.841,33.151 30.375,31.831 30.375,30.47L30.375,12.023ZM45.805,46.529C49.341,43.168 51.651,38.53 51.997,33.354H40.667L34.889,32.993L45.805,46.529ZM33.625,30.104L51.953,30.104C51.048,20.459 43.307,12.8 33.625,12.023L33.625,30.104Z"
android:fillColor="#3B45FD"
android:fillType="evenOdd"/>
</vector>

View File

@@ -7915,6 +7915,67 @@
<!-- Text for a "toast" that pops up at the bottom of the user\'s screen after they respond "not now" to a prompt asking them to enable backups -->
<string name="TurnOnSignalBackups__toast_not_now">You can enable backups in \"Settings\"</string>
<!-- Title of Megaphone B: upsell to enable backups when user has many messages -->
<string name="BackupMessagesUpsell__title">Never lose a message</string>
<!-- Body of Megaphone B: upsell to enable backups when user has many messages -->
<string name="BackupMessagesUpsell__body">Turn on Signal Secure Backups to preserve your messages and media.</string>
<!-- Primary button of Megaphone B -->
<string name="BackupMessagesUpsell__turn_on">Turn on</string>
<!-- Secondary button of Megaphone B -->
<string name="BackupMessagesUpsell__not_now">Not now</string>
<!-- Title of Megaphone C: upsell to upgrade backups when user has lots of media -->
<string name="BackupMediaUpsell__title">Back up all your media</string>
<!-- Body of Megaphone C: upsell to upgrade backups when user has lots of media -->
<string name="BackupMediaUpsell__body">Paid Signal Secure Backup plans preserve all your media, up to 100GB.</string>
<!-- Primary button of Megaphone C -->
<string name="BackupMediaUpsell__upgrade">Upgrade</string>
<!-- Secondary button of Megaphone C -->
<string name="BackupMediaUpsell__not_now">Not now</string>
<!-- Title of Megaphone D: upsell backups when device storage is low -->
<string name="BackupStorageUpsell__title">Save space with paid backups</string>
<!-- Body of Megaphone D: upsell backups when device storage is low -->
<string name="BackupStorageUpsell__body">Paid secure backup plans can save storage space by offloading media.</string>
<!-- Primary button of Megaphone D when user has free tier backups -->
<string name="BackupStorageUpsell__upgrade">Upgrade</string>
<!-- Primary button of Megaphone D when user has no backups -->
<string name="BackupStorageUpsell__turn_on">Turn on</string>
<!-- Secondary button of Megaphone D -->
<string name="BackupStorageUpsell__not_now">Not now</string>
<!-- Title of the backup upsell bottom sheet promoting paid backup plans -->
<string name="BackupUpsellBottomSheet__title">Upgrade to back up all media</string>
<!-- Body of the backup upsell bottom sheet -->
<string name="BackupUpsellBottomSheet__body">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.</string>
<!-- Label for the paid plan price shown in the feature card, e.g. "$1.99/month" -->
<string name="BackupUpsellBottomSheet__price_per_month">%s/month</string>
<!-- Subtitle for the paid plan feature card -->
<string name="BackupUpsellBottomSheet__text_and_all_media">Text + all your media</string>
<!-- Feature bullet: full text and media backup -->
<string name="BackupUpsellBottomSheet__full_text_media_backup">Full text + media backup</string>
<!-- Feature bullet: 100GB storage -->
<string name="BackupUpsellBottomSheet__storage_100gb">100GB storage</string>
<!-- Feature bullet: save on-device storage -->
<string name="BackupUpsellBottomSheet__save_on_device_storage">Save on-device storage</string>
<!-- Feature bullet: thanks for supporting Signal -->
<string name="BackupUpsellBottomSheet__thanks_for_supporting">Thanks for supporting Signal</string>
<!-- Primary button for the upsell bottom sheet. The %s is replaced by the subscription price, e.g. "$1.99/month" -->
<string name="BackupUpsellBottomSheet__subscribe_for">Subscribe for %s/month</string>
<!-- Secondary/dismiss button for the upsell bottom sheet -->
<string name="BackupUpsellBottomSheet__no_thanks">No thanks</string>
<!-- Title of the backup setup complete bottom sheet -->
<string name="BackupSetupCompleteBottomSheet__title">You\'re all set. Start your backup now.</string>
<!-- Body of the backup setup complete bottom sheet -->
<string name="BackupSetupCompleteBottomSheet__body">This could take a while. You can use Signal normally while backing up.</string>
<!-- Label for the optimize storage toggle -->
<string name="BackupSetupCompleteBottomSheet__optimize_storage">Optimize Signal storage</string>
<!-- Subtitle for the optimize storage toggle -->
<string name="BackupSetupCompleteBottomSheet__optimize_subtitle">Older media will be offloaded, but can be downloaded from your backup anytime.</string>
<!-- Primary button on the setup complete bottom sheet -->
<string name="BackupSetupCompleteBottomSheet__back_up_now">Back up now</string>
<!-- Title of a megaphone shown to prompt the user to upgrade to the new local backups type -->
<string name="UseNewOnDeviceBackups__title">Use new on-device backups</string>
<!-- Body of a megaphone shown to prompt the user to upgrade to the new local backups type -->

View File

@@ -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<Event, MegaphoneRecord>, 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<Event, MegaphoneRecord> {
return BackupUpsellSchedule.BACKUP_UPSELL_EVENTS.associateWith { record(it, seenCount = 0) }
}
}