Add UX for rotating the AEP, from Backup Settings

This commit is contained in:
Sasha Weiss 2025-08-07 12:02:18 -07:00 committed by GitHub
parent 60a97f04ec
commit 3aebe57906
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 465 additions and 224 deletions

View File

@ -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 = "<group>"; };
D9392DDF2D88AA5000728C01 /* ZoomableMediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomableMediaView.swift; sourceTree = "<group>"; };
D93964B52E038C7B00094117 /* BackupSettingsAttachmentUploadTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupSettingsAttachmentUploadTracker.swift; sourceTree = "<group>"; };
D93BDD932E43063B00779BD8 /* BackupKeepKeySafeSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupKeepKeySafeSheet.swift; sourceTree = "<group>"; };
D93CE1232A5C84F600D916B7 /* OWSSyncRequestMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSSyncRequestMessage.swift; sourceTree = "<group>"; };
D93EDC032AE9E3CD0004BDD9 /* DonationSettingsViewController+MySupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DonationSettingsViewController+MySupport.swift"; sourceTree = "<group>"; };
D93F4D552D7FAC3C0042926C /* AvatarDefaultColorManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarDefaultColorManager.swift; sourceTree = "<group>"; };
@ -6478,7 +6480,7 @@
D945319D2CE53CC8004DAB30 /* SubscriptionRedemptionNecessityChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionRedemptionNecessityChecker.swift; sourceTree = "<group>"; };
D9495A6C2C7683D100843BC1 /* TSOutgoingMessageRecipientState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TSOutgoingMessageRecipientState.swift; sourceTree = "<group>"; };
D9495A6E2C76963F00843BC1 /* TSOutgoingMessageRecipientStateTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TSOutgoingMessageRecipientStateTest.swift; sourceTree = "<group>"; };
D949C4042DF3A588007E095C /* BackupOnboardingConfirmKeyViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupOnboardingConfirmKeyViewController.swift; sourceTree = "<group>"; };
D949C4042DF3A588007E095C /* BackupConfirmKeyViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupConfirmKeyViewController.swift; sourceTree = "<group>"; };
D94AEB392D28837A00B03D7A /* MasterKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterKey.swift; sourceTree = "<group>"; };
D94AEB3B2D28940500B03D7A /* PreKeyTaskAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreKeyTaskAPIClient.swift; sourceTree = "<group>"; };
D94D67CC2C9DEF6E0091B485 /* BackupArchivePostFrameRestoreActionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupArchivePostFrameRestoreActionManager.swift; sourceTree = "<group>"; };
@ -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 */,

View File

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

View File

@ -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()
}
}),
)),
)
}
}

View File

@ -8,27 +8,76 @@ import SignalUI
import SwiftUI
class BackupRecordKeyViewController: HostingController<BackupRecordKeyView> {
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

View File

@ -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(

View File

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

View File

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

View File

@ -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 {

View File

@ -8,10 +8,10 @@ import SignalUI
import SwiftUI
class BackupOnboardingKeyIntroViewController: HostingController<BackupOnboardingKeyIntroView> {
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<BackupOnboarding
extension BackupOnboardingKeyIntroViewController: BackupsOnboardingKeyIntroViewModel.ActionsDelegate {
fileprivate func onContinue() {
Task {
if await LocalDeviceAuthentication().performBiometricAuth() {
onDeviceAuthSucceeded()
if let authSuccess = await LocalDeviceAuthentication().performBiometricAuth() {
onDeviceAuthSucceeded(authSuccess)
}
}
}

View File

@ -59,7 +59,7 @@ class OutgoingDeviceRestoreViewModel: ObservableObject, DeviceTransferServiceObs
}
func confirmTransfer() async -> 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.

View File

@ -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 wont 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.";

View File

@ -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<AttemptToken, AuthError> {
@ -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<Void, AuthError> {
public func attempt(token: AttemptToken) async -> Result<AuthSuccess, AuthError> {
do {
try await context.evaluatePolicy(
.deviceOwnerAuthentication,
@ -62,7 +67,7 @@ public struct LocalDeviceAuthentication {
)
)
return .success(())
return .success(AuthSuccess())
} catch {
return .failure(parseAuthError(error))
}

View File

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