diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 525431c663..a0bfdf16ff 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -2515,6 +2515,7 @@ D9388C912DA475230048D4F9 /* BackupSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9388C902DA4751F0048D4F9 /* BackupSettingsStore.swift */; }; D9392DE02D88AA5800728C01 /* ZoomableMediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9392DDF2D88AA5000728C01 /* ZoomableMediaView.swift */; }; D93964B62E038C7B00094117 /* BackupSettingsAttachmentUploadTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93964B52E038C7B00094117 /* BackupSettingsAttachmentUploadTracker.swift */; }; + D93BDD942E43064500779BD8 /* BackupKeepKeySafeSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93BDD932E43063B00779BD8 /* BackupKeepKeySafeSheet.swift */; }; D93CE1242A5C84F600D916B7 /* OWSSyncRequestMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93CE1232A5C84F600D916B7 /* OWSSyncRequestMessage.swift */; }; D93EDC042AE9E3CD0004BDD9 /* DonationSettingsViewController+MySupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93EDC032AE9E3CD0004BDD9 /* DonationSettingsViewController+MySupport.swift */; }; D93F4D5A2D800DD20042926C /* AvatarDefaultColorManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93F4D552D7FAC3C0042926C /* AvatarDefaultColorManager.swift */; }; @@ -2526,7 +2527,7 @@ D945319E2CE53CEB004DAB30 /* SubscriptionRedemptionNecessityChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D945319D2CE53CC8004DAB30 /* SubscriptionRedemptionNecessityChecker.swift */; }; D9495A6D2C7683D100843BC1 /* TSOutgoingMessageRecipientState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9495A6C2C7683D100843BC1 /* TSOutgoingMessageRecipientState.swift */; }; D9495A702C76965600843BC1 /* TSOutgoingMessageRecipientStateTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9495A6E2C76963F00843BC1 /* TSOutgoingMessageRecipientStateTest.swift */; }; - D949C4052DF3A597007E095C /* BackupOnboardingConfirmKeyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D949C4042DF3A588007E095C /* BackupOnboardingConfirmKeyViewController.swift */; }; + D949C4052DF3A597007E095C /* BackupConfirmKeyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D949C4042DF3A588007E095C /* BackupConfirmKeyViewController.swift */; }; D94AEB3A2D28837F00B03D7A /* MasterKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = D94AEB392D28837A00B03D7A /* MasterKey.swift */; }; D94AEB3C2D28940A00B03D7A /* PreKeyTaskAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D94AEB3B2D28940500B03D7A /* PreKeyTaskAPIClient.swift */; }; D94D67CD2C9DEF870091B485 /* BackupArchivePostFrameRestoreActionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D94D67CC2C9DEF6E0091B485 /* BackupArchivePostFrameRestoreActionManager.swift */; }; @@ -6466,6 +6467,7 @@ D938CD5F29283402006FB16A /* Paypal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Paypal.swift; sourceTree = ""; }; D9392DDF2D88AA5000728C01 /* ZoomableMediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomableMediaView.swift; sourceTree = ""; }; D93964B52E038C7B00094117 /* BackupSettingsAttachmentUploadTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupSettingsAttachmentUploadTracker.swift; sourceTree = ""; }; + D93BDD932E43063B00779BD8 /* BackupKeepKeySafeSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupKeepKeySafeSheet.swift; sourceTree = ""; }; D93CE1232A5C84F600D916B7 /* OWSSyncRequestMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSSyncRequestMessage.swift; sourceTree = ""; }; D93EDC032AE9E3CD0004BDD9 /* DonationSettingsViewController+MySupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DonationSettingsViewController+MySupport.swift"; sourceTree = ""; }; D93F4D552D7FAC3C0042926C /* AvatarDefaultColorManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarDefaultColorManager.swift; sourceTree = ""; }; @@ -6478,7 +6480,7 @@ D945319D2CE53CC8004DAB30 /* SubscriptionRedemptionNecessityChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionRedemptionNecessityChecker.swift; sourceTree = ""; }; D9495A6C2C7683D100843BC1 /* TSOutgoingMessageRecipientState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TSOutgoingMessageRecipientState.swift; sourceTree = ""; }; D9495A6E2C76963F00843BC1 /* TSOutgoingMessageRecipientStateTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TSOutgoingMessageRecipientStateTest.swift; sourceTree = ""; }; - D949C4042DF3A588007E095C /* BackupOnboardingConfirmKeyViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupOnboardingConfirmKeyViewController.swift; sourceTree = ""; }; + D949C4042DF3A588007E095C /* BackupConfirmKeyViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupConfirmKeyViewController.swift; sourceTree = ""; }; D94AEB392D28837A00B03D7A /* MasterKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterKey.swift; sourceTree = ""; }; D94AEB3B2D28940500B03D7A /* PreKeyTaskAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreKeyTaskAPIClient.swift; sourceTree = ""; }; D94D67CC2C9DEF6E0091B485 /* BackupArchivePostFrameRestoreActionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupArchivePostFrameRestoreActionManager.swift; sourceTree = ""; }; @@ -12500,7 +12502,9 @@ isa = PBXGroup; children = ( D9DE34FB2DEE76E6005099D7 /* Onboarding */, + D949C4042DF3A588007E095C /* BackupConfirmKeyViewController.swift */, D93FA5BE2DE77E440013879E /* BackupEnablingManager.swift */, + D93BDD932E43063B00779BD8 /* BackupKeepKeySafeSheet.swift */, D98CA2B22DF2450E0060370E /* BackupRecordKeyViewController.swift */, D932C0EA2E13AD3600FEF9C3 /* BackupSettingsAttachmentDownloadTracker.swift */, D93964B52E038C7B00094117 /* BackupSettingsAttachmentUploadTracker.swift */, @@ -12905,7 +12909,6 @@ D9DE34FB2DEE76E6005099D7 /* Onboarding */ = { isa = PBXGroup; children = ( - D949C4042DF3A588007E095C /* BackupOnboardingConfirmKeyViewController.swift */, D99934592DE97BB6002C9196 /* BackupOnboardingCoordinator.swift */, D9DE34FC2DEE775D005099D7 /* BackupOnboardingIntroViewController.swift */, D98CA2AC2DF14A830060370E /* BackupOnboardingKeyIntroViewController.swift */, @@ -16783,10 +16786,11 @@ B95A765C2B76C5BB00AA7E97 /* AvatarViewPresentationContextProvider.swift in Sources */, 66485EB02CCC515A00B8613F /* BackupArchiveInternalErrorViewController.swift in Sources */, 66A1F4E62E03641D0095DE4B /* BackupBGProcessingTaskRunner.swift in Sources */, + D949C4052DF3A597007E095C /* BackupConfirmKeyViewController.swift in Sources */, 041A5F072E05B3F900FAED05 /* BackupEnablementMegaphone.swift in Sources */, D9DF21EC2E21BD6600A962B2 /* BackupEnablingManager.swift in Sources */, + D93BDD942E43064500779BD8 /* BackupKeepKeySafeSheet.swift in Sources */, 04E66D402DFC825B0059DBAC /* BackupKeyReminderMegaphone.swift in Sources */, - D949C4052DF3A597007E095C /* BackupOnboardingConfirmKeyViewController.swift in Sources */, D999345A2DE97BBC002C9196 /* BackupOnboardingCoordinator.swift in Sources */, D9DE34FD2DEE7765005099D7 /* BackupOnboardingIntroViewController.swift in Sources */, D98CA2AD2DF14A890060370E /* BackupOnboardingKeyIntroViewController.swift in Sources */, diff --git a/Signal/Backups/BackupConfirmKeyViewController.swift b/Signal/Backups/BackupConfirmKeyViewController.swift new file mode 100644 index 0000000000..3d0ba960d6 --- /dev/null +++ b/Signal/Backups/BackupConfirmKeyViewController.swift @@ -0,0 +1,78 @@ +// +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import SignalServiceKit +import SignalUI + +class BackupConfirmKeyViewController: EnterAccountEntropyPoolViewController { + private let aep: AccountEntropyPool + + init( + aep: AccountEntropyPool, + onContinue: @escaping () -> Void, + onSeeKeyAgain: @escaping () -> Void, + ) { + self.aep = aep + + super.init() + + configure( + aepValidationPolicy: .acceptOnly(aep), + colorConfig: ColorConfig( + background: UIColor.Signal.groupedBackground, + aepEntryBackground: UIColor.Signal.secondaryGroupedBackground, + ), + headerStrings: HeaderStrings( + title: OWSLocalizedString( + "BACKUP_ONBOARDING_CONFIRM_KEY_TITLE", + comment: "Title for a view asking users to confirm their 'Backup Key'." + ), + subtitle: OWSLocalizedString( + "BACKUP_ONBOARDING_CONFIRM_KEY_SUBTITLE", + comment: "Subtitle for a view asking users to confirm their 'Backup Key'." + ) + ), + footerButtonConfig: FooterButtonConfig( + title: BackupKeepKeySafeSheet.seeKeyAgainButtonTitle, + action: { + onSeeKeyAgain() + } + ), + onEntryConfirmed: { [weak self] aep in + guard let self else { return } + + present( + BackupKeepKeySafeSheet( + onContinue: onContinue, + onSeeKeyAgain: onSeeKeyAgain + ), + animated: true + ) + } + ) + } +} + +// MARK: - + +#if DEBUG + +@available(iOS 17, *) +#Preview { + let aep = try! AccountEntropyPool(key: String( + repeating: "a", + count: AccountEntropyPool.Constants.byteLength + )) + + return UINavigationController( + rootViewController: BackupConfirmKeyViewController( + aep: aep, + onContinue: { print("Continuing...!") }, + onSeeKeyAgain: { print("Seeing key again...!") } + ) + ) +} + +#endif diff --git a/Signal/Backups/BackupKeepKeySafeSheet.swift b/Signal/Backups/BackupKeepKeySafeSheet.swift new file mode 100644 index 0000000000..be87c4fea1 --- /dev/null +++ b/Signal/Backups/BackupKeepKeySafeSheet.swift @@ -0,0 +1,56 @@ +// +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import SignalUI +import SignalServiceKit + +class BackupKeepKeySafeSheet: HeroSheetViewController { + static var seeKeyAgainButtonTitle: String { + return OWSLocalizedString( + "BACKUP_ONBOARDING_CONFIRM_KEY_SEE_KEY_AGAIN_BUTTON_TITLE", + comment: "Title for a button offering to let users see their 'Backup Key'." + ) + } + + /// - Parameter onContinue + /// Called after dismissing this sheet when the user taps "Continue", + /// indicating acknowledgement of the "keep key safe" warning. + /// - Parameter onSeeKeyAgain + /// Called after dismissing this sheet when the user taps "See Key Again", + /// indicating they want another opportunity to record their key. + init( + onContinue: @escaping () -> Void, + onSeeKeyAgain: @escaping () -> Void, + ) { + super.init( + hero: .image(.backupsKey), + title: OWSLocalizedString( + "BACKUP_ONBOARDING_CONFIRM_KEY_KEEP_KEY_SAFE_SHEET_TITLE", + comment: "Title for a sheet warning users to their 'Backup Key' safe." + ), + body: OWSLocalizedString( + "BACKUP_ONBOARDING_CONFIRM_KEY_KEEP_KEY_SAFE_SHEET_BODY", + comment: "Body for a sheet warning users to their 'Backup Key' safe." + ), + primary: .button(HeroSheetViewController.Button( + title: CommonStrings.continueButton, + action: { sheet in + sheet.dismiss(animated: true) { + onContinue() + } + } + )), + secondary: .button(HeroSheetViewController.Button( + title: Self.seeKeyAgainButtonTitle, + style: .secondary, + action: .custom({ sheet in + sheet.dismiss(animated: true) { + onSeeKeyAgain() + } + }), + )), + ) + } +} diff --git a/Signal/Backups/BackupRecordKeyViewController.swift b/Signal/Backups/BackupRecordKeyViewController.swift index 1b06cef990..68770a9ef6 100644 --- a/Signal/Backups/BackupRecordKeyViewController.swift +++ b/Signal/Backups/BackupRecordKeyViewController.swift @@ -8,27 +8,76 @@ import SignalUI import SwiftUI class BackupRecordKeyViewController: HostingController { - private let onCompletion: (BackupRecordKeyViewController) -> Void - private let viewModel: BackupRecordKeyViewModel - private let isOnboardingFlow: Bool + struct Option: OptionSet { + let rawValue: Int + /// Show a "done" button instead of the nav bar "back" button. Note that + /// this prevents dismissal using standard navigation. + static let replaceNavBarBackWithDoneButton = Option(rawValue: 1 << 0) + /// Show a "continue" button in the view footer. Not compatible with + /// `.showCreateNewKeyButton`. + static let showContinueButton = Option(rawValue: 1 << 1) + /// Show a "create new key" button in the view footer. Not compatible + /// with `.showContinueButton`. + static let showCreateNewKeyButton = Option(rawValue: 1 << 2) + } + + enum AEPMode { + /// The user's current AEP, which must only be viewed after device auth. + case current(AccountEntropyPool, LocalDeviceAuthentication.AuthSuccess) + /// A new candidate AEP. + case newCandidate(AccountEntropyPool) + + fileprivate var aep: AccountEntropyPool { + switch self { + case .current(let aep, _): return aep + case .newCandidate(let aep): return aep + } + } + } + + private let onCompletion: (BackupRecordKeyViewController) -> Void + private let onCreateNewKeyPressed: (BackupRecordKeyViewController) -> Void + private let options: [Option] + private let viewModel: BackupRecordKeyViewModel + + /// - Parameter onCreateNewKeyPressed + /// Called when the user taps the "create new key" button. Only relevant if + /// the `.showCreateNewKey` option is passed. + /// - Parameter onCompletion + /// Called when the user "completes" recording their Backup Key, such as by + /// tapping a "continue" or "done" button (depending on the passed options). init( - aep: AccountEntropyPool, - isOnboardingFlow: Bool, - onCompletion: @escaping (BackupRecordKeyViewController) -> Void, + aepMode: AEPMode, + options: [Option], + onCreateNewKeyPressed: @escaping (BackupRecordKeyViewController) -> Void = { _ in }, + onCompletion: @escaping (BackupRecordKeyViewController) -> Void ) { self.onCompletion = onCompletion - self.isOnboardingFlow = isOnboardingFlow - self.viewModel = BackupRecordKeyViewModel(aep: aep, isOnboardingFlow: isOnboardingFlow) + self.onCreateNewKeyPressed = onCreateNewKeyPressed + self.options = options + self.viewModel = BackupRecordKeyViewModel(aep: aepMode.aep, options: options) super.init(wrappedView: BackupRecordKeyView(viewModel: viewModel)) viewModel.actionsDelegate = self } + + override func viewDidAppear(_ animated: Bool) { + if options.contains(.replaceNavBarBackWithDoneButton) { + navigationController?.interactivePopGestureRecognizer?.isEnabled = false + } + } + + override func viewWillDisappear(_ animated: Bool) { + if options.contains(.replaceNavBarBackWithDoneButton) { + navigationController?.interactivePopGestureRecognizer?.isEnabled = true + } + } } extension BackupRecordKeyViewController: BackupRecordKeyViewModel.ActionsDelegate { - func copyToClipboard(_ aep: AccountEntropyPool) { + fileprivate func copyToClipboard(_ aep: AccountEntropyPool) { UIPasteboard.general.setItems( [[UIPasteboard.typeAutomatic: aep.rawData]], options: [.expirationDate: Date().addingTimeInterval(60)] @@ -41,9 +90,13 @@ extension BackupRecordKeyViewController: BackupRecordKeyViewModel.ActionsDelegat toast.presentToastView(from: .bottom, of: view, inset: view.safeAreaInsets.bottom + 8) } - func complete() { + fileprivate func complete() { onCompletion(self) } + + fileprivate func createNewKey() { + onCreateNewKeyPressed(self) + } } // MARK: - @@ -52,17 +105,21 @@ private class BackupRecordKeyViewModel: ObservableObject { protocol ActionsDelegate: AnyObject { func copyToClipboard(_ aep: AccountEntropyPool) func complete() + func createNewKey() } + let aep: AccountEntropyPool + let options: [BackupRecordKeyViewController.Option] + weak var actionsDelegate: ActionsDelegate? - let aep: AccountEntropyPool - let isOnboardingFlow: Bool - init(aep: AccountEntropyPool, isOnboardingFlow: Bool) { + init(aep: AccountEntropyPool, options: [BackupRecordKeyViewController.Option]) { self.aep = aep - self.isOnboardingFlow = isOnboardingFlow + self.options = options } + // MARK: - + func copyToClipboard() { actionsDelegate?.copyToClipboard(aep) } @@ -70,8 +127,14 @@ private class BackupRecordKeyViewModel: ObservableObject { func complete() { actionsDelegate?.complete() } + + func createNewKey() { + actionsDelegate?.createNewKey() + } } +// MARK: - + struct BackupRecordKeyView: View { fileprivate let viewModel: BackupRecordKeyViewModel @@ -119,7 +182,7 @@ struct BackupRecordKeyView: View { )) .fontWeight(.medium) } - .buttonStyle(.plain) + .foregroundStyle(Color.Signal.secondaryLabel) .padding(.horizontal, 12) .padding(.vertical, 8) .background { @@ -130,8 +193,19 @@ struct BackupRecordKeyView: View { } .padding(.horizontal, 12) } pinnedFooter: { - // Only add "continue" button if we're in the onboarding flow. - if viewModel.isOnboardingFlow { + if viewModel.options.contains(.showCreateNewKeyButton) { + Button { + viewModel.createNewKey() + } label: { + Text(OWSLocalizedString( + "BACKUP_RECORD_KEY_CREATE_NEW_KEY_BUTTON_TITLE", + comment: "Title for a button allowing users to create a new 'Backup Key'." + )) + .foregroundStyle(Color.Signal.ultramarine) + } + } + + if viewModel.options.contains(.showContinueButton) { Button { viewModel.complete() } label: { @@ -149,15 +223,15 @@ struct BackupRecordKeyView: View { } .multilineTextAlignment(.center) .background(Color.Signal.groupedBackground) - .navigationBarBackButtonHidden(!viewModel.isOnboardingFlow) - .navigationBarItems(leading: viewModel.isOnboardingFlow ? nil : doneButton) + .navigationBarBackButtonHidden(viewModel.options.contains(.replaceNavBarBackWithDoneButton)) + .navigationBarItems(leading: viewModel.options.contains(.replaceNavBarBackWithDoneButton) ? doneButton : nil) } private var doneButton: some View { - Button(action: { + Button { viewModel.complete() - }) { - Text(OWSLocalizedString("BUTTON_DONE", comment: "Label for generic done button.")) + } label: { + Text(CommonStrings.doneButton) .buttonStyle(.plain) .foregroundStyle(Color.Signal.label) } @@ -227,26 +301,44 @@ private struct DisplayAccountEntropyPoolView: View { #if DEBUG private extension BackupRecordKeyViewModel { - static func forPreview() -> BackupRecordKeyViewModel { + static func forPreview( + options: [BackupRecordKeyViewController.Option], + ) -> BackupRecordKeyViewModel { class PreviewActionsDelegate: ActionsDelegate { - func copyToClipboard(_ aep: AccountEntropyPool) { - print("Copying \(aep.rawData) to clipboard...!") - } - - func complete() { - print("Continuing...!") - } + func copyToClipboard(_ aep: AccountEntropyPool) { print("Copying \(aep.rawData) to clipboard...!") } + func complete() { print("Continuing...!") } + func createNewKey() { print("Creating new key...!") } } - let viewModel = BackupRecordKeyViewModel(aep: AccountEntropyPool(), isOnboardingFlow: true) + let viewModel = BackupRecordKeyViewModel( + aep: AccountEntropyPool(), + options: options, + ) let actionsDelegate = PreviewActionsDelegate() ObjectRetainer.retainObject(actionsDelegate, forLifetimeOf: viewModel) return viewModel } } -#Preview { - BackupRecordKeyView(viewModel: .forPreview()) +#Preview("NavBarDone") { + NavigationView { + BackupRecordKeyView(viewModel: .forPreview(options: [.replaceNavBarBackWithDoneButton])) + } +} + +#Preview("CreateNewKey") { + NavigationView { + BackupRecordKeyView(viewModel: .forPreview(options: [ + .replaceNavBarBackWithDoneButton, + .showCreateNewKeyButton, + ])) + } +} + +#Preview("ContinueButton") { + NavigationView { + BackupRecordKeyView(viewModel: .forPreview(options: [.showContinueButton])) + } } #endif diff --git a/Signal/Backups/BackupSettingsViewController.swift b/Signal/Backups/BackupSettingsViewController.swift index c48e4d485f..4d226acc05 100644 --- a/Signal/Backups/BackupSettingsViewController.swift +++ b/Signal/Backups/BackupSettingsViewController.swift @@ -28,6 +28,7 @@ class BackupSettingsViewController: private let backupSubscriptionManager: BackupSubscriptionManager private let db: DB private let deviceSleepManager: DeviceSleepManager + private let svr: SecureValueRecovery private let tsAccountManager: TSAccountManager private let onLoadAction: OnLoadAction? @@ -57,6 +58,7 @@ class BackupSettingsViewController: backupSubscriptionManager: DependenciesBridge.shared.backupSubscriptionManager, db: DependenciesBridge.shared.db, deviceSleepManager: deviceSleepManager, + svr: DependenciesBridge.shared.svr, tsAccountManager: DependenciesBridge.shared.tsAccountManager, ) } @@ -76,6 +78,7 @@ class BackupSettingsViewController: backupSubscriptionManager: BackupSubscriptionManager, db: DB, deviceSleepManager: DeviceSleepManager, + svr: SecureValueRecovery, tsAccountManager: TSAccountManager ) { owsPrecondition( @@ -100,6 +103,7 @@ class BackupSettingsViewController: self.backupSubscriptionManager = backupSubscriptionManager self.db = db self.deviceSleepManager = deviceSleepManager + self.svr = svr self.tsAccountManager = tsAccountManager self.onLoadAction = onLoadAction @@ -916,61 +920,141 @@ class BackupSettingsViewController: return } - guard await LocalDeviceAuthentication().performBiometricAuth() else { + guard let authSuccess = await LocalDeviceAuthentication().performBiometricAuth() else { return } - navigationController?.pushViewController( - BackupRecordKeyViewController( - aep: aep, - isOnboardingFlow: false, - onCompletion: { [weak self] recordKeyViewController in - self?.showKeyRecordedConfirmationSheet( - fromViewController: recordKeyViewController - ) - } - ), - animated: true + let recordKeyViewController = BackupRecordKeyViewController( + aepMode: .current(aep, authSuccess), + options: [.replaceNavBarBackWithDoneButton, .showCreateNewKeyButton], + onCreateNewKeyPressed: { [weak self] recordKeyViewController in + guard let self else { return } + + // If appropriate, the warning sheet will let the user continue + // in a "create new AEP" flow. + showCreateNewBackupKeyWarningSheet(fromViewController: recordKeyViewController) + }, + onCompletion: { [weak self] recordKeyViewController in + guard let self else { return } + + let keepKeySafeSheet = BackupKeepKeySafeSheet( + onContinue: { [weak self] in + guard let self else { return } + navigationController?.popToViewController(self, animated: true) + }, + onSeeKeyAgain: { + // The sheet is already dismissed, and the topmost + // view is a BackupRecordKeyViewController. + } + ) + recordKeyViewController.present( + keepKeySafeSheet, + animated: true + ) + } ) - navigationController?.interactivePopGestureRecognizer?.isEnabled = false + + navigationController?.pushViewController(recordKeyViewController, animated: true) } - private func showKeyRecordedConfirmationSheet(fromViewController: BackupRecordKeyViewController) { - let sheet = HeroSheetViewController( + private func showCreateNewBackupKeyWarningSheet( + fromViewController: BackupRecordKeyViewController, + ) { + let currentBackupPlan = db.read { tx in + backupSettingsStore.backupPlan(tx: tx) + } + + // Only allow creating a new Backup Key if Backups are already disabled. + let primary: HeroSheetViewController.Button + let secondary: HeroSheetViewController.Button? + switch currentBackupPlan { + case .disabled: + primary = HeroSheetViewController.Button( + title: CommonStrings.continueButton, + action: { sheet in + sheet.dismiss(animated: true) { [weak self] in + guard let self else { return } + showRecordNewBackupKey() + } + } + ) + secondary = .dismissing( + title: CommonStrings.cancelButton, + style: .secondary, + ) + case .disabling, .free, .paid, .paidExpiringSoon, .paidAsTester: + primary = .dismissing(title: CommonStrings.cancelButton) + secondary = nil + } + + let warningSheet = HeroSheetViewController( hero: .image(.backupsKey), title: OWSLocalizedString( - "BACKUP_ONBOARDING_CONFIRM_KEY_KEEP_KEY_SAFE_SHEET_TITLE", - comment: "Title for a sheet warning users to their 'Backup Key' safe." + "BACKUP_SETTINGS_CREATE_NEW_KEY_WARNING_SHEET_TITLE", + comment: "Title for a sheet warning users about creating a new Backup Key." ), body: OWSLocalizedString( - "BACKUP_ONBOARDING_CONFIRM_KEY_KEEP_KEY_SAFE_SHEET_BODY", - comment: "Body for a sheet warning users to their 'Backup Key' safe." + "BACKUP_SETTINGS_CREATE_NEW_KEY_WARNING_SHEET_BODY", + comment: "Body for a sheet warning users about creating a new Backup Key." ), - primary: .button(HeroSheetViewController.Button( - title: OWSLocalizedString( - "BUTTON_CONTINUE", - comment: "Label for 'continue' button." - ), - action: { [weak self] _ in - self?.dismiss(animated: true) - self?.navigationController?.interactivePopGestureRecognizer?.isEnabled = true - self?.navigationController?.popViewController(animated: true) - } - )), - secondary: .button(HeroSheetViewController.Button( - title: OWSLocalizedString( - "BACKUP_ONBOARDING_CONFIRM_KEY_SEE_KEY_AGAIN_BUTTON_TITLE", - comment: "Title for a button offering to let users see their 'Backup Key'." - ), - style: .secondary, - action: .custom({ [weak self] _ in - self?.dismiss(animated: true) - self?.navigationController?.interactivePopGestureRecognizer?.isEnabled = true - }) - )) + primary: .button(primary), + secondary: secondary.map { .button($0) }, ) - fromViewController.present(sheet, animated: true) - }} + fromViewController.present(warningSheet, animated: true) + } + + private func showRecordNewBackupKey() { + let newCandidateAEP = AccountEntropyPool() + let recordKeyViewController = BackupRecordKeyViewController( + aepMode: .newCandidate(newCandidateAEP), + options: [.showContinueButton], + onCompletion: { [weak self] _ in + guard let self else { return } + showConfirmNewBackupKey(newCandidateAEP: newCandidateAEP) + } + ) + + navigationController?.pushViewController(recordKeyViewController, animated: true) + } + + private func showConfirmNewBackupKey(newCandidateAEP: AccountEntropyPool) { + let confirmKeyViewController = BackupConfirmKeyViewController( + aep: newCandidateAEP, + onContinue: { [weak self] in + guard let self else { return } + + Logger.warn("Setting new AEP!") + + // Persists the new AEP, and schedules all the relevant side + // effects. A big deal! + db.write { tx in + self.svr.setNewAccountEntropyPoolWithSideEffects( + newCandidateAEP, + disablePIN: false, + authedAccount: .implicit(), + transaction: tx + ) + } + + // Pop all the way back to Backup Settings. + navigationController?.popToViewController(self, animated: true) { + self.presentToast(text: OWSLocalizedString( + "BACKUP_SETTINGS_CREATE_NEW_KEY_SUCCESS_TOAST", + comment: "Toast shown when a new Backup Key has been created successfully." + )) + } + }, + onSeeKeyAgain: { [weak self] in + guard let self else { return } + + // Popping drops us back on the BackupRecordKeyViewController. + navigationController?.popViewController(animated: true) + } + ) + + navigationController?.pushViewController(confirmKeyViewController, animated: true) + } +} // MARK: - @@ -1384,6 +1468,10 @@ struct BackupSettingsView: View { .foregroundStyle(Color.Signal.secondaryLabel) } + SignalSection { + BackupViewKeyView(viewModel: viewModel) + } + case .disabledFailedToDisableRemotely: SignalSection { VStack(alignment: .center) { @@ -1416,6 +1504,10 @@ struct BackupSettingsView: View { SignalSection { reenableBackupsButton } + + SignalSection { + BackupViewKeyView(viewModel: viewModel) + } } } } @@ -1993,6 +2085,16 @@ private struct BackupDetailsView: View { ) ) + BackupViewKeyView(viewModel: viewModel) + } +} + +// MARK: - + +private struct BackupViewKeyView: View { + let viewModel: BackupSettingsViewModel + + var body: some View { Button { viewModel.showViewBackupKey() } label: { @@ -2007,7 +2109,6 @@ private struct BackupDetailsView: View { } } .foregroundStyle(Color.Signal.label) - } } @@ -2044,6 +2145,7 @@ private extension BackupSettingsViewModel { func setShouldAllowBackupDownloadsOnCellular() { print("Downloads on cellular: true") } func showViewBackupKey() { print("Showing View Backup Key!") } + func showCreateNewBackupKey() { print("Showing Create New Backup Key!") } } let viewModel = BackupSettingsViewModel( diff --git a/Signal/Backups/BackupsReminderCoordinator.swift b/Signal/Backups/BackupsReminderCoordinator.swift index 2ea7b4cd4c..f3f202ed2b 100644 --- a/Signal/Backups/BackupsReminderCoordinator.swift +++ b/Signal/Backups/BackupsReminderCoordinator.swift @@ -54,11 +54,15 @@ class BackupsReminderCoordinator { fromViewController.present(navController, animated: true) } - private func showRecordBackupKey(backupKeyReminderNavController: UINavigationController, aep: AccountEntropyPool) { + private func showRecordBackupKey( + backupKeyReminderNavController: UINavigationController, + localDeviceAuthSuccess: LocalDeviceAuthentication.AuthSuccess, + aep: AccountEntropyPool + ) { backupKeyReminderNavController.pushViewController( BackupRecordKeyViewController( - aep: aep, - isOnboardingFlow: true, + aepMode: .current(aep, localDeviceAuthSuccess), + options: [.showContinueButton], onCompletion: { [weak self] _ in self?.showConfirmBackupKey(backupKeyReminderNavController: backupKeyReminderNavController, aep: aep) }, @@ -69,7 +73,7 @@ class BackupsReminderCoordinator { private func showConfirmBackupKey(backupKeyReminderNavController: UINavigationController, aep: AccountEntropyPool) { backupKeyReminderNavController.pushViewController( - BackupOnboardingConfirmKeyViewController( + BackupConfirmKeyViewController( aep: aep, onContinue: { [weak self] in self?.dismissHandler(false) @@ -96,15 +100,17 @@ extension BackupsReminderCoordinator: RegistrationEnterAccountEntropyPoolPresent func forgotKeyAction() { Task { @MainActor in - guard await LocalDeviceAuthentication().performBiometricAuth() else { - return - } guard + let authSuccess = await LocalDeviceAuthentication().performBiometricAuth(), let backupKeyReminderNavController = backupKeyReminderNavController, let aep = db.read(block: { accountKeyStore.getAccountEntropyPool(tx: $0) }) else { return } - showRecordBackupKey(backupKeyReminderNavController: backupKeyReminderNavController, aep: aep) + showRecordBackupKey( + backupKeyReminderNavController: backupKeyReminderNavController, + localDeviceAuthSuccess: authSuccess, + aep: aep + ) } } } diff --git a/Signal/Backups/Onboarding/BackupOnboardingConfirmKeyViewController.swift b/Signal/Backups/Onboarding/BackupOnboardingConfirmKeyViewController.swift deleted file mode 100644 index 7501b057f8..0000000000 --- a/Signal/Backups/Onboarding/BackupOnboardingConfirmKeyViewController.swift +++ /dev/null @@ -1,116 +0,0 @@ -// -// Copyright 2025 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only -// - -import SignalServiceKit -import SignalUI - -class BackupOnboardingConfirmKeyViewController: EnterAccountEntropyPoolViewController { - private let aep: AccountEntropyPool - - init( - aep: AccountEntropyPool, - onContinue: @escaping () -> Void, - onSeeKeyAgain: @escaping () -> Void, - ) { - self.aep = aep - - super.init() - - configure( - aepValidationPolicy: .acceptOnly(aep), - colorConfig: ColorConfig( - background: UIColor.Signal.groupedBackground, - aepEntryBackground: UIColor.Signal.secondaryGroupedBackground, - ), - headerStrings: HeaderStrings( - title: OWSLocalizedString( - "BACKUP_ONBOARDING_CONFIRM_KEY_TITLE", - comment: "Title for a view asking users to confirm their 'Backup Key'." - ), - subtitle: OWSLocalizedString( - "BACKUP_ONBOARDING_CONFIRM_KEY_SUBTITLE", - comment: "Subtitle for a view asking users to confirm their 'Backup Key'." - ) - ), - footerButtonConfig: FooterButtonConfig( - title: seeKeyAgainButtonTitle, - action: { - onSeeKeyAgain() - } - ), - onEntryConfirmed: { [weak self] aep in - self?.showKeepKeySafeSheet( - onContinue: onContinue, - onSeeKeyAgain: onSeeKeyAgain - ) - } - ) - } - - private var seeKeyAgainButtonTitle: String { - return OWSLocalizedString( - "BACKUP_ONBOARDING_CONFIRM_KEY_SEE_KEY_AGAIN_BUTTON_TITLE", - comment: "Title for a button offering to let users see their 'Backup Key'." - ) - } - - private func showKeepKeySafeSheet( - onContinue: @escaping () -> Void, - onSeeKeyAgain: @escaping () -> Void, - ) { - let sheet = HeroSheetViewController( - hero: .image(.backupsKey), - title: OWSLocalizedString( - "BACKUP_ONBOARDING_CONFIRM_KEY_KEEP_KEY_SAFE_SHEET_TITLE", - comment: "Title for a sheet warning users to their 'Backup Key' safe." - ), - body: OWSLocalizedString( - "BACKUP_ONBOARDING_CONFIRM_KEY_KEEP_KEY_SAFE_SHEET_BODY", - comment: "Body for a sheet warning users to their 'Backup Key' safe." - ), - primaryButton: HeroSheetViewController.Button( - title: CommonStrings.continueButton, - action: { sheet in - sheet.dismiss(animated: true) { - onContinue() - } - } - ), - secondaryButton: HeroSheetViewController.Button( - title: seeKeyAgainButtonTitle, - style: .secondary, - action: .custom({ sheet in - sheet.dismiss(animated: true) { - onSeeKeyAgain() - } - }), - ) - ) - - present(sheet, animated: true) - } -} - -// MARK: - - -#if DEBUG - -@available(iOS 17, *) -#Preview { - let aep = try! AccountEntropyPool(key: String( - repeating: "a", - count: AccountEntropyPool.Constants.byteLength - )) - - return UINavigationController( - rootViewController: BackupOnboardingConfirmKeyViewController( - aep: aep, - onContinue: { print("Continuing...!") }, - onSeeKeyAgain: { print("Seeing key again...!") } - ) - ) -} - -#endif diff --git a/Signal/Backups/Onboarding/BackupOnboardingCoordinator.swift b/Signal/Backups/Onboarding/BackupOnboardingCoordinator.swift index 739e9a4ec6..7e7675da42 100644 --- a/Signal/Backups/Onboarding/BackupOnboardingCoordinator.swift +++ b/Signal/Backups/Onboarding/BackupOnboardingCoordinator.swift @@ -90,8 +90,8 @@ class BackupOnboardingCoordinator { onboardingNavController.pushViewController( BackupOnboardingKeyIntroViewController( - onDeviceAuthSucceeded: { [self] in - showRecordBackupKey() + onDeviceAuthSucceeded: { [self] authSuccess in + showRecordBackupKey(localDeviceAuthSuccess: authSuccess) } ), animated: true @@ -100,7 +100,9 @@ class BackupOnboardingCoordinator { // MARK: - - private func showRecordBackupKey() { + private func showRecordBackupKey( + localDeviceAuthSuccess: LocalDeviceAuthentication.AuthSuccess + ) { guard let onboardingNavController, let aep = db.read(block: { accountKeyStore.getAccountEntropyPool(tx: $0) }) @@ -108,8 +110,8 @@ class BackupOnboardingCoordinator { onboardingNavController.pushViewController( BackupRecordKeyViewController( - aep: aep, - isOnboardingFlow: true, + aepMode: .current(aep, localDeviceAuthSuccess), + options: [.showContinueButton], onCompletion: { [self] _ in showConfirmBackupKey(aep: aep) }, @@ -124,7 +126,7 @@ class BackupOnboardingCoordinator { guard let onboardingNavController else { return } onboardingNavController.pushViewController( - BackupOnboardingConfirmKeyViewController( + BackupConfirmKeyViewController( aep: aep, onContinue: { [self] in Task { diff --git a/Signal/Backups/Onboarding/BackupOnboardingKeyIntroViewController.swift b/Signal/Backups/Onboarding/BackupOnboardingKeyIntroViewController.swift index 255be524cc..2f161d712f 100644 --- a/Signal/Backups/Onboarding/BackupOnboardingKeyIntroViewController.swift +++ b/Signal/Backups/Onboarding/BackupOnboardingKeyIntroViewController.swift @@ -8,10 +8,10 @@ import SignalUI import SwiftUI class BackupOnboardingKeyIntroViewController: HostingController { - private let onDeviceAuthSucceeded: () -> Void + private let onDeviceAuthSucceeded: (LocalDeviceAuthentication.AuthSuccess) -> Void private let viewModel: BackupsOnboardingKeyIntroViewModel - init(onDeviceAuthSucceeded: @escaping () -> Void) { + init(onDeviceAuthSucceeded: @escaping (LocalDeviceAuthentication.AuthSuccess) -> Void) { self.onDeviceAuthSucceeded = onDeviceAuthSucceeded self.viewModel = BackupsOnboardingKeyIntroViewModel() @@ -26,8 +26,8 @@ class BackupOnboardingKeyIntroViewController: HostingController Bool { - return await LocalDeviceAuthentication().performBiometricAuth() + return await LocalDeviceAuthentication().performBiometricAuth() != nil } /// This uses the QuickRestore path behind the scenes to bootstrap a device transfer between two devices. diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 4051d855ca..8222d8508f 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -586,6 +586,9 @@ /* Title for a button allowing users to copy their 'Backup Key' to the clipboard. */ "BACKUP_RECORD_KEY_COPY_TO_CLIPBOARD_BUTTON_TITLE" = "Copy to Clipboard"; +/* Title for a button allowing users to create a new 'Backup Key'. */ +"BACKUP_RECORD_KEY_CREATE_NEW_KEY_BUTTON_TITLE" = "Create New Key"; + /* Subtitle for a view allowing users to record their 'Backup Key'. */ "BACKUP_RECORD_KEY_SUBTITLE" = "This key is required to recover your account and data. Store this key somewhere safe. If you lose it, you won’t be able to recover your account."; @@ -709,6 +712,15 @@ /* Notice that backups is a beta feature */ "BACKUP_SETTINGS_BETA_NOTICE_HEADER" = "This is a Beta feature that we will be adding functionality to on an ongoing basis"; +/* Toast shown when a new Backup Key has been created successfully. */ +"BACKUP_SETTINGS_CREATE_NEW_KEY_SUCCESS_TOAST" = "New key created!"; + +/* Body for a sheet warning users about creating a new Backup Key. */ +"BACKUP_SETTINGS_CREATE_NEW_KEY_WARNING_SHEET_BODY" = "Creating a new key is only necessary if someone else knows your key. Backups must first be disabled to create a new key. You will need to re-enable and re-upload your Backup, including media, after creating a new key."; + +/* Title for a sheet warning users about creating a new Backup Key. */ +"BACKUP_SETTINGS_CREATE_NEW_KEY_WARNING_SHEET_TITLE" = "Create A New Backup Key"; + /* Footer for a menu section allowing users to turn off Backups. */ "BACKUP_SETTINGS_DISABLE_BACKUPS_BUTTON_FOOTER" = "This will delete all your backup data. No new backups will be created."; diff --git a/SignalServiceKit/LocalDeviceAuth/LocalDeviceAuthentication.swift b/SignalServiceKit/LocalDeviceAuth/LocalDeviceAuthentication.swift index bc05f36ae2..c44e39e487 100644 --- a/SignalServiceKit/LocalDeviceAuth/LocalDeviceAuthentication.swift +++ b/SignalServiceKit/LocalDeviceAuth/LocalDeviceAuthentication.swift @@ -12,6 +12,9 @@ public struct LocalDeviceAuthentication { case genericError(localizedErrorMessage: String) } + /// An opaque object representing successful authentication. + public struct AuthSuccess {} + public struct AttemptToken {} private let context: LAContext @@ -22,21 +25,23 @@ public struct LocalDeviceAuthentication { // MARK: - - public func performBiometricAuth() async -> Bool { + public func performBiometricAuth() async -> AuthSuccess? { let localDeviceAuthAttemptToken: AttemptToken switch self.checkCanAttempt() { case .success(let attemptToken): localDeviceAuthAttemptToken = attemptToken - case .failure(.notRequired): return true - case .failure(.canceled), .failure(.genericError): return false + case .failure(.notRequired): return AuthSuccess() + case .failure(.canceled), .failure(.genericError): return nil } switch await self.attempt(token: localDeviceAuthAttemptToken) { - case .success, .failure(.notRequired): return true - case .failure(.canceled), .failure(.genericError): return false + case .success, .failure(.notRequired): return AuthSuccess() + case .failure(.canceled), .failure(.genericError): return nil } } + // MARK: - + /// Returns whether checking local device auth is possible. Must be called /// prior to calling ``attempt(token:)``. public func checkCanAttempt() -> Result { @@ -52,7 +57,7 @@ public struct LocalDeviceAuthentication { /// Returns the result of performing local device authentication. Must be /// called after calling ``checkCanAttempt()``. - public func attempt(token: AttemptToken) async -> Result { + public func attempt(token: AttemptToken) async -> Result { do { try await context.evaluatePolicy( .deviceOwnerAuthentication, @@ -62,7 +67,7 @@ public struct LocalDeviceAuthentication { ) ) - return .success(()) + return .success(AuthSuccess()) } catch { return .failure(parseAuthError(error)) } diff --git a/SignalUI/ViewControllers/HeroSheetViewController.swift b/SignalUI/ViewControllers/HeroSheetViewController.swift index a9b97ff187..dbd1073d38 100644 --- a/SignalUI/ViewControllers/HeroSheetViewController.swift +++ b/SignalUI/ViewControllers/HeroSheetViewController.swift @@ -7,7 +7,7 @@ import Foundation import SignalServiceKit import Lottie -public class HeroSheetViewController: StackSheetViewController { +open class HeroSheetViewController: StackSheetViewController { public enum Hero { /// Scaled image to display at the top of the sheet case image(UIImage)