311 lines
10 KiB
Swift
311 lines
10 KiB
Swift
//
|
|
// Copyright 2025 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import SignalServiceKit
|
|
import SignalUI
|
|
import SwiftUI
|
|
|
|
class BackupRecordKeyViewController: HostingController<BackupRecordKeyView> {
|
|
struct Option: OptionSet {
|
|
let rawValue: Int
|
|
|
|
/// 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 onContinuePressedBlock: (BackupRecordKeyViewController) -> Void
|
|
private let onCreateNewKeyPressedBlock: (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 `.showCreateNewKeyButton` option is passed.
|
|
/// - Parameter onContinuePressed
|
|
/// Called when the user taps the "continue" button. Only relevant if the
|
|
/// `.showContinueButton` option is passed.
|
|
init(
|
|
aepMode: AEPMode,
|
|
options: [Option],
|
|
onCreateNewKeyPressed: @escaping (BackupRecordKeyViewController) -> Void = { _ in },
|
|
onContinuePressed: @escaping (BackupRecordKeyViewController) -> Void = { _ in },
|
|
) {
|
|
self.onContinuePressedBlock = onContinuePressed
|
|
self.onCreateNewKeyPressedBlock = onCreateNewKeyPressed
|
|
self.options = options
|
|
self.viewModel = BackupRecordKeyViewModel(aep: aepMode.aep, options: options)
|
|
|
|
super.init(wrappedView: BackupRecordKeyView(viewModel: viewModel))
|
|
|
|
viewModel.actionsDelegate = self
|
|
}
|
|
}
|
|
|
|
extension BackupRecordKeyViewController: BackupRecordKeyViewModel.ActionsDelegate {
|
|
fileprivate func copyToClipboard(_ aep: AccountEntropyPool) {
|
|
UIPasteboard.general.setItems(
|
|
[[UIPasteboard.typeAutomatic: aep.rawData]],
|
|
options: [.expirationDate: Date().addingTimeInterval(60)]
|
|
)
|
|
|
|
let toast = ToastController(text: OWSLocalizedString(
|
|
"BACKUP_KEY_COPIED_MESSAGE_TOAST",
|
|
comment: "Toast indicating that the user has copied their recovery key."
|
|
))
|
|
toast.presentToastView(from: .bottom, of: view, inset: view.safeAreaInsets.bottom + 8)
|
|
}
|
|
|
|
fileprivate func onContinuePressed() {
|
|
onContinuePressedBlock(self)
|
|
}
|
|
|
|
fileprivate func onCreateNewKeyPressed() {
|
|
onCreateNewKeyPressedBlock(self)
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private class BackupRecordKeyViewModel: ObservableObject {
|
|
protocol ActionsDelegate: AnyObject {
|
|
func copyToClipboard(_ aep: AccountEntropyPool)
|
|
func onContinuePressed()
|
|
func onCreateNewKeyPressed()
|
|
}
|
|
|
|
let aep: AccountEntropyPool
|
|
let options: [BackupRecordKeyViewController.Option]
|
|
|
|
weak var actionsDelegate: ActionsDelegate?
|
|
|
|
init(aep: AccountEntropyPool, options: [BackupRecordKeyViewController.Option]) {
|
|
self.aep = aep
|
|
self.options = options
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
func copyToClipboard() {
|
|
actionsDelegate?.copyToClipboard(aep)
|
|
}
|
|
|
|
func onContinuePressed() {
|
|
actionsDelegate?.onContinuePressed()
|
|
}
|
|
|
|
func onCreateNewKeyPressed() {
|
|
actionsDelegate?.onCreateNewKeyPressed()
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
struct BackupRecordKeyView: View {
|
|
fileprivate let viewModel: BackupRecordKeyViewModel
|
|
|
|
var body: some View {
|
|
ScrollableContentPinnedFooterView {
|
|
VStack {
|
|
Spacer().frame(height: 20)
|
|
|
|
Image(.backupsLock)
|
|
.frame(width: 80, height: 80)
|
|
|
|
Spacer().frame(height: 16)
|
|
|
|
Text(OWSLocalizedString(
|
|
"BACKUP_RECORD_KEY_TITLE",
|
|
comment: "Title for a view allowing users to record their 'Recovery Key'."
|
|
))
|
|
.font(.title)
|
|
.fontWeight(.semibold)
|
|
.foregroundStyle(Color.Signal.label)
|
|
.padding(.horizontal, 24) // Extra
|
|
|
|
Spacer().frame(height: 12)
|
|
|
|
Text(OWSLocalizedString(
|
|
"BACKUP_RECORD_KEY_SUBTITLE",
|
|
comment: "Subtitle for a view allowing users to record their 'Recovery Key'."
|
|
))
|
|
.font(.body)
|
|
.foregroundStyle(Color.Signal.secondaryLabel)
|
|
.padding(.horizontal, 28) // Extra
|
|
|
|
Spacer().frame(height: 32)
|
|
|
|
DisplayAccountEntropyPoolView(aep: viewModel.aep)
|
|
|
|
Spacer().frame(height: 32)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
} pinnedFooter: {
|
|
Button {
|
|
viewModel.copyToClipboard()
|
|
} label: {
|
|
Text(OWSLocalizedString(
|
|
"BACKUP_RECORD_KEY_COPY_TO_CLIPBOARD_BUTTON_TITLE",
|
|
comment: "Title for a button allowing users to copy their 'Recovery Key' to the clipboard."
|
|
))
|
|
.fontWeight(.medium)
|
|
}
|
|
.foregroundStyle(Color.Signal.secondaryLabel)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
.background {
|
|
Capsule().fill(Color.Signal.secondaryFill)
|
|
}
|
|
|
|
if viewModel.options.contains(.showCreateNewKeyButton) {
|
|
Spacer().frame(height: 32)
|
|
|
|
Button {
|
|
viewModel.onCreateNewKeyPressed()
|
|
} label: {
|
|
Text(OWSLocalizedString(
|
|
"BACKUP_RECORD_KEY_CREATE_NEW_KEY_BUTTON_TITLE",
|
|
comment: "Title for a button allowing users to create a new 'Recovery Key'."
|
|
))
|
|
.foregroundStyle(Color.Signal.ultramarine)
|
|
}
|
|
}
|
|
|
|
if viewModel.options.contains(.showContinueButton) {
|
|
Spacer().frame(height: 32)
|
|
|
|
Button {
|
|
viewModel.onContinuePressed()
|
|
} label: {
|
|
Text(CommonStrings.continueButton)
|
|
.foregroundStyle(.white)
|
|
.font(.headline)
|
|
.padding(.vertical, 14)
|
|
.frame(maxWidth: .infinity)
|
|
.background(Color.Signal.ultramarine)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.cornerRadius(12)
|
|
.padding(.horizontal, 40)
|
|
}
|
|
}
|
|
.multilineTextAlignment(.center)
|
|
.background(Color.Signal.groupedBackground)
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private struct DisplayAccountEntropyPoolView: View {
|
|
private enum Constants {
|
|
static let charsPerGroup = 4
|
|
static let charGroupsPerLine = 4
|
|
static let spacesTwixtGroups = 4
|
|
|
|
private static let formattingPrecondition: Void = {
|
|
let charsPerLine = charsPerGroup * charGroupsPerLine
|
|
owsPrecondition(AccountEntropyPool.Constants.byteLength % charsPerLine == 0)
|
|
}()
|
|
}
|
|
|
|
let aep: AccountEntropyPool
|
|
|
|
var body: some View {
|
|
Text(stylizedAEPText)
|
|
.lineSpacing(18)
|
|
.font(.body.monospaced())
|
|
.padding(.vertical, 18)
|
|
.padding(.horizontal, 36)
|
|
.background(Color.Signal.secondaryGroupedBackground)
|
|
.foregroundStyle(Color.Signal.label)
|
|
.cornerRadius(16)
|
|
}
|
|
|
|
/// Split the AEP into char groups, themselves split across multiple lines.
|
|
///
|
|
/// - Important
|
|
/// This does index-based accesses, and will crash if the (hardcoded) length
|
|
/// of an AEP changes. `Constants.formattingPrecondition` should alert in
|
|
/// that case.
|
|
private var stylizedAEPText: String {
|
|
let aep = aep.rawData
|
|
|
|
let charGroups: [String] = stride(from: 0, to: aep.count, by: Constants.charsPerGroup).map {
|
|
let startIndex = aep.index(aep.startIndex, offsetBy: $0)
|
|
let endIndex = aep.index(startIndex, offsetBy: Constants.charsPerGroup)
|
|
|
|
return String(aep[startIndex..<endIndex])
|
|
}
|
|
|
|
let charGroupsByLine: [[String]] = stride(from: 0, to: charGroups.count, by: Constants.charGroupsPerLine).map {
|
|
let startIndex = charGroups.index(charGroups.startIndex, offsetBy: $0)
|
|
let endIndex = charGroups.index(startIndex, offsetBy: Constants.charGroupsPerLine)
|
|
|
|
return Array(charGroups[startIndex..<endIndex])
|
|
}
|
|
|
|
let lines: [String] = charGroupsByLine.map { charGroups in
|
|
return charGroups.joined(separator: String(repeating: " ", count: Constants.spacesTwixtGroups))
|
|
}
|
|
|
|
return lines.joined(separator: "\n")
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
#if DEBUG
|
|
|
|
private extension BackupRecordKeyViewModel {
|
|
static func forPreview(
|
|
options: [BackupRecordKeyViewController.Option],
|
|
) -> BackupRecordKeyViewModel {
|
|
class PreviewActionsDelegate: ActionsDelegate {
|
|
func copyToClipboard(_ aep: AccountEntropyPool) { print("Copying \(aep.rawData) to clipboard...!") }
|
|
func onContinuePressed() { print("Completing...!") }
|
|
func onCreateNewKeyPressed() { print("Creating new key...!") }
|
|
}
|
|
|
|
let viewModel = BackupRecordKeyViewModel(
|
|
aep: AccountEntropyPool(),
|
|
options: options,
|
|
)
|
|
let actionsDelegate = PreviewActionsDelegate()
|
|
ObjectRetainer.retainObject(actionsDelegate, forLifetimeOf: viewModel)
|
|
return viewModel
|
|
}
|
|
}
|
|
|
|
#Preview("CreateNewKey") {
|
|
NavigationView {
|
|
BackupRecordKeyView(viewModel: .forPreview(options: [.showCreateNewKeyButton]))
|
|
}
|
|
}
|
|
|
|
#Preview("ContinueButton") {
|
|
NavigationView {
|
|
BackupRecordKeyView(viewModel: .forPreview(options: [.showContinueButton]))
|
|
}
|
|
}
|
|
|
|
#endif
|