From 0206e8c48718ee8a07176f897f7d049f04d7bcd4 Mon Sep 17 00:00:00 2001 From: Sasha Weiss Date: Fri, 29 May 2026 15:49:10 -0700 Subject: [PATCH] Add "Enable Optimize Storage" toggle to "Welcome to Backups" sheet --- .../BackupSettingsViewController.swift | 173 ++++++++++-------- .../translations/en.lproj/Localizable.strings | 26 +-- .../Backups/Settings/BackupPlanManager.swift | 1 - .../HeroSheetViewController.swift | 88 +++++++-- 4 files changed, 180 insertions(+), 108 deletions(-) diff --git a/Signal/Backups/BackupSettingsViewController.swift b/Signal/Backups/BackupSettingsViewController.swift index 84b40deafd..c4940c30fe 100644 --- a/Signal/Backups/BackupSettingsViewController.swift +++ b/Signal/Backups/BackupSettingsViewController.swift @@ -147,7 +147,7 @@ class BackupSettingsViewController: ), hasBackupFailed: backupFailureStateManager.hasFailedBackup(tx: tx), isBackgroundAppRefreshDisabled: Self.isBackgroundAppRefreshDisabled(), - isOptimizeStorageEnabled: remoteConfig.currentConfig().isOptimizeStorageEnabled, + isOptimizeStorageRemoteConfigEnabled: remoteConfig.currentConfig().isOptimizeStorageEnabled, ) return viewModel @@ -621,28 +621,88 @@ class BackupSettingsViewController: final class WelcomeToBackupsSheet: HeroSheetViewController { override var canBeDismissed: Bool { false } - init(onConfirm: @escaping () -> Void) { + init( + optimizeLocalStorage: (isOn: Bool, onValueChanged: (Bool) -> Void)?, + onConfirm: @escaping (HeroSheetViewController) -> Void, + ) { + let toggle: HeroSheetViewController.Body.Toggle? + if let (isOn, onValueChanged) = optimizeLocalStorage { + toggle = HeroSheetViewController.Body.Toggle( + title: OWSLocalizedString( + "BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_OPTIMIZE_MEDIA_TOGGLE_TITLE", + comment: "Title for a toggle shown after the user enables backups, letting them enable the Optimize Storage feature.", + ), + footer: OWSLocalizedString( + "BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_OPTIMIZE_MEDIA_TOGGLE_FOOTER", + comment: "Footer for a toggle shown after the user enables backups, letting them enable the Optimize Storage feature.", + ), + isOn: isOn, + onValueChanged: onValueChanged, + ) + } else { + toggle = nil + } + super.init( hero: .image(.backupsSubscribed), title: OWSLocalizedString( "BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_TITLE", comment: "Title for a sheet shown after the user enables backups.", ), - body: OWSLocalizedString( - "BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_MESSAGE", - comment: "Message for a sheet shown after the user enables backups.", - ), - primaryButton: HeroSheetViewController.Button( - title: CommonStrings.okButton, - action: { _ in onConfirm() }, + body: HeroSheetViewController.Body( + textContent: .plain(OWSLocalizedString( + "BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_MESSAGE", + comment: "Message for a sheet shown after the user enables backups.", + )), + toggle: toggle, ), + primary: .button(HeroSheetViewController.Button( + title: OWSLocalizedString( + "BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_BUTTON_TITLE", + comment: "Title for a button in a sheet shown after the user enables backups.", + ), + action: { onConfirm($0) }, + )), + secondary: nil, ) } } - let welcomeToBackupsSheet = WelcomeToBackupsSheet { [self] in - viewModel.performManualBackup() - dismiss(animated: true) + let backupPlan = db.read { tx in + backupPlanManager.backupPlan(tx: tx) + } + + let welcomeToBackupsSheet: WelcomeToBackupsSheet + switch backupPlan { + case _ where !viewModel.isOptimizeStorageRemoteConfigEnabled, + .disabled, + .disabling, + .free: + welcomeToBackupsSheet = WelcomeToBackupsSheet( + optimizeLocalStorage: nil, + onConfirm: { sheet in + sheet.dismiss(animated: true) { [self] in + viewModel.performManualBackup() + } + }, + ) + case .paid, + .paidAsTester, + .paidExpiringSoon: + var isOptimizeStorageEnabled = true + + welcomeToBackupsSheet = WelcomeToBackupsSheet( + optimizeLocalStorage: ( + isOn: isOptimizeStorageEnabled, + onValueChanged: { isOptimizeStorageEnabled = $0 }, + ), + onConfirm: { sheet in + sheet.dismiss(animated: true) { [self] in + setOptimizeLocalStorage(isOptimizeStorageEnabled) + viewModel.performManualBackup() + } + }, + ) } present(welcomeToBackupsSheet, animated: true) @@ -1021,35 +1081,33 @@ class BackupSettingsViewController: // MARK: - fileprivate func setOptimizeLocalStorage(_ newOptimizeLocalStorage: Bool) { - let isPaidPlanTester: Bool = db.write { tx in + let hasMadeAtLeastOneBackup: Bool = db.write { tx in let currentBackupPlan = backupPlanManager.backupPlan(tx: tx) - let newBackupPlan: BackupPlan - let isPaidPlanTester: Bool + let lastBackupDetails = backupSettingsStore.lastBackupDetails(tx: tx) + let newBackupPlan: BackupPlan switch currentBackupPlan { case .disabled, .disabling, .free: - owsFailDebug("Shouldn't be setting Optimize Local Storage: \(currentBackupPlan)") - return false + owsFail("Shouldn't be setting Optimize Local Storage: \(currentBackupPlan)") case .paid: newBackupPlan = .paid(optimizeLocalStorage: newOptimizeLocalStorage) - isPaidPlanTester = false case .paidExpiringSoon: newBackupPlan = .paidExpiringSoon(optimizeLocalStorage: newOptimizeLocalStorage) - isPaidPlanTester = false case .paidAsTester: newBackupPlan = .paidAsTester(optimizeLocalStorage: newOptimizeLocalStorage) - isPaidPlanTester = true } backupPlanManager.setBackupPlan(newBackupPlan, tx: tx) - return isPaidPlanTester + return lastBackupDetails != nil } - // If disabling Optimize Local Storage, offer to start downloads now. - if !newOptimizeLocalStorage { + if + hasMadeAtLeastOneBackup, + !newOptimizeLocalStorage + { + // If disabling Optimize Local Storage with media potentially + // offloaded, offer to start downloads now. showDownloadOffloadedMediaSheet() - } else if isPaidPlanTester { - showOffloadedMediaForTestersWarningSheet(onAcknowledge: {}) } } @@ -1088,44 +1146,15 @@ class BackupSettingsViewController: presentActionSheet(actionSheet) } - private func showOffloadedMediaForTestersWarningSheet( - onAcknowledge: @escaping () -> Void, - ) { - let actionSheet = ActionSheetController( - title: OWSLocalizedString( - "BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TESTER_WARNING_SHEET_TITLE", - comment: "Title for an action sheet warning users who are testers about the Optimize Local Storage feature.", - ), - message: OWSLocalizedString( - "BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TESTER_WARNING_SHEET_MESSAGE", - comment: "Message for an action sheet warning users who are testers about the Optimize Local Storage feature.", - ), - ) - actionSheet.addAction(ActionSheetAction( - title: CommonStrings.okButton, - handler: { _ in - onAcknowledge() - }, - )) - - presentActionSheet(actionSheet) - } - // MARK: - fileprivate func setIsBackupDownloadQueueSuspended(_ isSuspended: Bool, backupPlan: BackupPlan) { if isSuspended { switch backupPlan { - case .disabled, .disabling, .free, .paid: + case .disabled, .disabling, .free, .paid, .paidAsTester: db.write { tx in backupSettingsStore.setIsBackupDownloadQueueSuspended(true, tx: tx) } - case .paidAsTester: - showOffloadedMediaForTestersWarningSheet(onAcknowledge: { [self] in - db.write { tx in - backupSettingsStore.setIsBackupDownloadQueueSuspended(true, tx: tx) - } - }) case .paidExpiringSoon: let warningSheet = ActionSheetController( title: OWSLocalizedString( @@ -1542,7 +1571,9 @@ private class BackupSettingsViewModel: ObservableObject { /// from running.) @Published var isBackgroundAppRefreshDisabled: Bool - @Published var isOptimizeStorageEnabled: Bool + /// Whether the "Optimze Storage" feature is available to this user, per + /// remote config. Not to be confused with `isOptimizeLocalStorageAvailable`. + @Published var isOptimizeStorageRemoteConfigEnabled: Bool weak var actionsDelegate: ActionsDelegate? @@ -1560,7 +1591,7 @@ private class BackupSettingsViewModel: ObservableObject { mediaTierCapacityOverflow: UInt64?, hasBackupFailed: Bool, isBackgroundAppRefreshDisabled: Bool, - isOptimizeStorageEnabled: Bool, + isOptimizeStorageRemoteConfigEnabled: Bool, ) { self.backupSubscriptionConfiguration = backupSubscriptionConfiguration @@ -1581,7 +1612,7 @@ private class BackupSettingsViewModel: ObservableObject { self.hasBackupFailed = hasBackupFailed self.isBackgroundAppRefreshDisabled = isBackgroundAppRefreshDisabled - self.isOptimizeStorageEnabled = isOptimizeStorageEnabled + self.isOptimizeStorageRemoteConfigEnabled = isOptimizeStorageRemoteConfigEnabled } // MARK: - @@ -1640,7 +1671,9 @@ private class BackupSettingsViewModel: ObservableObject { // MARK: - - var optimizeLocalStorageAvailable: Bool { + /// Whether the "Optimze Storage" feature is available, per the current + /// `BackupPlan`. Not to be confused with `isOptimizeStorageRemoteConfigEnabled`. + var isOptimizeLocalStorageAvailable: Bool { switch backupPlan { case .disabled, .disabling, .free: false @@ -1649,7 +1682,7 @@ private class BackupSettingsViewModel: ObservableObject { } } - var optimizeLocalStorage: Bool { + var isOptimizeLocalStorageEnabled: Bool { switch backupPlan { case .disabled, .disabling, .free: false @@ -1922,29 +1955,21 @@ struct BackupSettingsView: View { viewModel: viewModel, ) - if viewModel.isOptimizeStorageEnabled { + if viewModel.isOptimizeStorageRemoteConfigEnabled { Toggle( OWSLocalizedString( "BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TOGGLE_TITLE", comment: "Title for a toggle allowing users to change the Optimize Local Storage setting.", ), isOn: Binding( - get: { viewModel.optimizeLocalStorage }, + get: { viewModel.isOptimizeLocalStorageEnabled }, set: { viewModel.setOptimizeLocalStorage($0) }, ), - ).disabled(!viewModel.optimizeLocalStorageAvailable) + ).disabled(!viewModel.isOptimizeLocalStorageAvailable) } } footer: { - if viewModel.isOptimizeStorageEnabled { - let footerText: String = if - viewModel.optimizeLocalStorageAvailable, - viewModel.isPaidPlanTester - { - OWSLocalizedString( - "BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TOGGLE_FOOTER_AVAILABLE_FOR_TESTERS", - comment: "Footer for a toggle allowing users to change the Optimize Local Storage setting, if the toggle is available and they are a tester.", - ) - } else if viewModel.optimizeLocalStorageAvailable { + if viewModel.isOptimizeStorageRemoteConfigEnabled { + let footerText: String = if viewModel.isOptimizeLocalStorageAvailable { OWSLocalizedString( "BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TOGGLE_FOOTER_AVAILABLE", comment: "Footer for a toggle allowing users to change the Optimize Local Storage setting, if the toggle is available.", @@ -3178,7 +3203,7 @@ private extension BackupSettingsViewModel { mediaTierCapacityOverflow: mediaTierCapacityOverflow, hasBackupFailed: hasBackupFailed, isBackgroundAppRefreshDisabled: isBackgroundAppRefreshDisabled, - isOptimizeStorageEnabled: false, + isOptimizeStorageRemoteConfigEnabled: true, ) let actionsDelegate = PreviewActionsDelegate() viewModel.actionsDelegate = actionsDelegate diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 3e199d8785..1ed9302247 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -988,23 +988,14 @@ /* Title for an action sheet allowing users to download their offloaded media. */ "BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_DOWNLOAD_SHEET_TITLE" = "Download Offloaded Media?"; -/* Message for an action sheet warning users who are testers about the Optimize Local Storage feature. */ -"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TESTER_WARNING_SHEET_MESSAGE" = "Media that is offloaded will be available as long as you are using TestFlight. Make sure to download all your media before leaving the test program."; - -/* Title for an action sheet warning users who are testers about the Optimize Local Storage feature. */ -"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TESTER_WARNING_SHEET_TITLE" = "Testing Offloaded Media"; - /* Footer for a toggle allowing users to change the Optimize Local Storage setting, if the toggle is available. */ -"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TOGGLE_FOOTER_AVAILABLE" = "Unused media will be offloaded, but can be downloaded from your backup anytime."; - -/* Footer for a toggle allowing users to change the Optimize Local Storage setting, if the toggle is available and they are a tester. */ -"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TOGGLE_FOOTER_AVAILABLE_FOR_TESTERS" = "Unused media will be offloaded, but can be downloaded from your backup anytime while you are using TestFlight."; +"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TOGGLE_FOOTER_AVAILABLE" = "Older media will be offloaded, but can be downloaded from your backup anytime."; /* Footer for a toggle allowing users to change the Optimize Local Storage setting, if the toggle is unavailable. */ "BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TOGGLE_FOOTER_UNAVAILABLE" = "Storage optimization can only be used with the paid tier of Signal Backups. Upgrade your backup plan to start using this feature."; /* Title for a toggle allowing users to change the Optimize Local Storage setting. */ -"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TOGGLE_TITLE" = "Optimize On-Device Storage"; +"BACKUP_SETTINGS_OPTIMIZE_LOCAL_STORAGE_TOGGLE_TITLE" = "Optimize Signal Storage"; /* Subtitle for a notification telling the user they are out of remote storage space. */ "BACKUP_SETTINGS_OUT_OF_STORAGE_SPACE_NOTIFICATION_SUBTITLE" = "You’ve reached your backup storage limit. Free up space in Signal to continue backing up chats and media."; @@ -1060,11 +1051,20 @@ /* Subtitle for a progress bar tracking active uploading. */ "BACKUP_SETTINGS_UPLOAD_PROGRESS_SUBTITLE_RUNNING_GENERIC" = "Uploading…"; +/* Title for a button in a sheet shown after the user enables backups. */ +"BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_BUTTON_TITLE" = "Back Up Now"; + /* Message for a sheet shown after the user enables backups. */ -"BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_MESSAGE" = "Depending on the size of your backup, this could take a long time. You can use Signal as you normally do while the backup takes place."; +"BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_MESSAGE" = "This could take a while. You can use Signal normally while backing up."; + +/* Footer for a toggle shown after the user enables backups, letting them enable the Optimize Storage feature. */ +"BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_OPTIMIZE_MEDIA_TOGGLE_FOOTER" = "Older media will be offloaded, but can be downloaded from your backup anytime."; + +/* Title for a toggle shown after the user enables backups, letting them enable the Optimize Storage feature. */ +"BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_OPTIMIZE_MEDIA_TOGGLE_TITLE" = "Optimize Signal Storage"; /* Title for a sheet shown after the user enables backups. */ -"BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_TITLE" = "Welcome to Signal Secure Backups. Start your backup now."; +"BACKUP_SETTINGS_WELCOME_TO_BACKUPS_SHEET_TITLE" = "You're all set. Start your backup now."; /* Message for a sheet shown when your Backup subscription fails to renew. */ "BACKUP_SUBSCRIPTION_FAILED_TO_RENEW_SHEET_MESSAGE" = "Check to make sure your payment method is up to date. Tap Manage Subscription > Signal > Update Payment Method."; diff --git a/SignalServiceKit/Backups/Settings/BackupPlanManager.swift b/SignalServiceKit/Backups/Settings/BackupPlanManager.swift index 7ed04efe1f..2537d8ab0e 100644 --- a/SignalServiceKit/Backups/Settings/BackupPlanManager.swift +++ b/SignalServiceKit/Backups/Settings/BackupPlanManager.swift @@ -114,7 +114,6 @@ class BackupPlanManagerImpl: BackupPlanManager { let oldBackupPlan = backupPlan(tx: tx) guard oldBackupPlan != newBackupPlan else { - logger.warn("Attempting to set BackupPlan to existing value: aborting. \(oldBackupPlan)") return } diff --git a/SignalUI/ActionSheets/HeroSheetViewController.swift b/SignalUI/ActionSheets/HeroSheetViewController.swift index 6acf008640..fc7d230254 100644 --- a/SignalUI/ActionSheets/HeroSheetViewController.swift +++ b/SignalUI/ActionSheets/HeroSheetViewController.swift @@ -38,16 +38,19 @@ open class HeroSheetViewController: StackSheetViewController { } public struct Toggle { - public let text: String + public let title: String + public let footer: String? public let isOn: Bool public let onValueChanged: (_ isEnabled: Bool) -> Void public init( - text: String, + title: String, + footer: String?, isOn: Bool, onValueChanged: @escaping (_ isEnabled: Bool) -> Void, ) { - self.text = text + self.title = title + self.footer = footer self.isOn = isOn self.onValueChanged = onValueChanged } @@ -305,14 +308,15 @@ open class HeroSheetViewController: StackSheetViewController { } private func viewForToggle(_ toggle: Body.Toggle) -> UIView { - let label = UILabel() - label.text = toggle.text - label.font = .dynamicTypeSubheadline - label.textAlignment = .natural - label.numberOfLines = 0 - label.textColor = .Signal.label + let titleLabel = UILabel() + titleLabel.text = toggle.title + titleLabel.font = .dynamicTypeSubheadline + titleLabel.textAlignment = .natural + titleLabel.numberOfLines = 0 + titleLabel.textColor = .Signal.label let toggleSwitch = UISwitch() + toggleSwitch.setCompressionResistanceHigh() toggleSwitch.isOn = toggle.isOn toggleSwitch.addAction( UIAction { [weak self] action in @@ -327,19 +331,41 @@ open class HeroSheetViewController: StackSheetViewController { for: .valueChanged, ) - let containerView = PillView() - containerView.backgroundColor = .Signal.tertiaryBackground - containerView.layoutMargins = UIEdgeInsets(hMargin: 20, vMargin: 16) - containerView.addSubview(label) - containerView.addSubview(toggleSwitch) + let pillView = PillView() + pillView.backgroundColor = .Signal.tertiaryBackground + pillView.layoutMargins = UIEdgeInsets(hMargin: 20, vMargin: 16) + pillView.addSubview(titleLabel) + pillView.addSubview(toggleSwitch) - label.autoPinEdges(toSuperviewMarginsExcludingEdge: .trailing) + titleLabel.autoPinEdges(toSuperviewMarginsExcludingEdge: .trailing) - toggleSwitch.autoPinEdge(.leading, to: .trailing, of: label, withOffset: 16, relation: .greaterThanOrEqual) + toggleSwitch.autoPinEdge(.leading, to: .trailing, of: titleLabel, withOffset: 16, relation: .greaterThanOrEqual) toggleSwitch.autoPinEdge(toSuperviewMargin: .trailing) toggleSwitch.autoVCenterInSuperview() - return containerView + if let footer = toggle.footer { + let footerLabel = UILabel() + footerLabel.text = footer + footerLabel.font = .dynamicTypeFootnote + footerLabel.textAlignment = .natural + footerLabel.numberOfLines = 0 + footerLabel.textColor = .Signal.secondaryLabel + + let pillAndFooterContainer = UIView() + pillAndFooterContainer.addSubview(pillView) + pillAndFooterContainer.addSubview(footerLabel) + + pillView.autoPinEdges(toSuperviewEdgesExcludingEdge: .bottom) + footerLabel.autoPinEdge(.top, to: .bottom, of: pillView, withOffset: 8) + footerLabel.autoPinEdgesToSuperviewEdges( + with: UIEdgeInsets(hMargin: 20, vMargin: 0), + excludingEdge: .top, + ) + + return pillAndFooterContainer + } else { + return pillView + } } private func viewForElement(_ element: Element) -> UIView { @@ -413,14 +439,36 @@ open class HeroSheetViewController: StackSheetViewController { } @available(iOS 17, *) -#Preview("Body w/toggle") { +#Preview("Body w/toggle-and-footer") { SheetPreviewViewController(sheet: HeroSheetViewController( hero: .image(UIImage(named: "toggle-32")!), - title: nil, + title: "Feeding Boots the cat", body: HeroSheetViewController.Body( textContent: .plain(#"Give Boots extra dinner? He'd like you to know he's "extra hungry" tonight."#), toggle: HeroSheetViewController.Body.Toggle( - text: "Extra Food?", + title: "Extra dinner?", + footer: "Side effects may include sleepiness and increased insistence that he receive extra food in the future.", + isOn: true, + onValueChanged: { enabled in + print(enabled ? "😸" : "😾") + }, + ), + ), + primary: .button(.dismissing(title: "Order Up")), + secondary: nil, + )) +} + +@available(iOS 17, *) +#Preview("Body w/long-text toggle") { + SheetPreviewViewController(sheet: HeroSheetViewController( + hero: .image(UIImage(named: "toggle-32")!), + title: "Feeding Boots the Cat", + body: HeroSheetViewController.Body( + textContent: .plain(#"Give Boots extra dinner? He'd like you to know he's "extra hungry" tonight."#), + toggle: HeroSheetViewController.Body.Toggle( + title: "Give Boots extra dinner? Side effects may include sleepiness and increased insistence that he receive extra food in the future.", + footer: nil, isOn: true, onValueChanged: { enabled in print(enabled ? "😸" : "😾")