diff --git a/Vault/Sources/VaultFeed/Presentation/Backup/AutoBackupViewModel.swift b/Vault/Sources/VaultFeed/Presentation/Backup/AutoBackupViewModel.swift new file mode 100644 index 000000000..fecb859c1 --- /dev/null +++ b/Vault/Sources/VaultFeed/Presentation/Backup/AutoBackupViewModel.swift @@ -0,0 +1,193 @@ +import Combine +import Foundation + +@MainActor +@Observable +public final class AutoBackupViewModel { + /// Snapshot of a storage provider's display information, decoupled from + /// the async provider protocol so views and tests can render synchronously. + public struct ProviderDisplayState: Equatable, Sendable, Identifiable { + public let id: String + public let displayName: String + public let iconSystemName: String + public var isConfigured: Bool + public var folderSummary: String? + + public init( + id: String, + displayName: String, + iconSystemName: String, + isConfigured: Bool, + folderSummary: String?, + ) { + self.id = id + self.displayName = displayName + self.iconSystemName = iconSystemName + self.isConfigured = isConfigured + self.folderSummary = folderSummary + } + } + + public private(set) var status: AutoBackupStatus + public private(set) var configuration: AutoBackupConfiguration + public private(set) var providerStates: [ProviderDisplayState] + public internal(set) var configureError: AutoBackupError? + + private let service: any AutoBackupService + private let providerStatesWereSeeded: Bool + @ObservationIgnored private var cancellables = Set() + + public init( + service: any AutoBackupService, + initialProviderStates: [ProviderDisplayState] = [], + ) { + self.service = service + providerStatesWereSeeded = !initialProviderStates.isEmpty + // Seed synchronously: the service's publishers do not replay, so + // waiting for an emission would leave the screen stuck on defaults. + status = service.status + configuration = service.configuration + providerStates = initialProviderStates + + service.statusPublisher + .sink { [weak self] newStatus in + self?.status = newStatus + } + .store(in: &cancellables) + + service.configurationPublisher + .sink { [weak self] newConfiguration in + guard let self else { return } + configuration = newConfiguration + Task { + await self.refreshProviderStates() + } + } + .store(in: &cancellables) + } + + /// The provider the destination row acts on: the configured one if any, + /// otherwise the first available. The single seam for future + /// multi-provider selection. + public var activeProvider: ProviderDisplayState? { + if let providerID = configuration.providerID, + let selected = providerStates.first(where: { $0.id == providerID }) + { + return selected + } + return providerStates.first + } + + public var isDestinationConfigured: Bool { + activeProvider?.isConfigured ?? false + } + + /// Hydrates provider states on first appearance. Seeded states (tests, + /// previews) are authoritative and must not be clobbered by a refresh + /// against a service that vends no providers. + public func onAppear() async { + guard !providerStatesWereSeeded else { return } + await refreshProviderStates() + } + + public func refreshProviderStates() async { + var states = [ProviderDisplayState]() + for provider in service.availableProviders { + states.append(ProviderDisplayState( + id: provider.id, + displayName: provider.displayName, + iconSystemName: provider.iconSystemName, + isConfigured: await provider.isConfigured, + folderSummary: await provider.configurationSummary, + )) + } + providerStates = states + } + + /// Marks the active provider as selected ahead of a folder pick and + /// clears any stale configuration error so a repeated failure re-renders. + public func beginDestinationSelection() async { + configureError = nil + guard let provider = activeProvider else { return } + await service.selectProvider(id: provider.id) + } + + public func configureDestination(url: URL) async { + guard let providerID = activeProvider?.id, + let provider = service.availableProviders.first(where: { $0.id == providerID }) + else { return } + + do { + try await provider.configure(with: url) + await service.saveProviderConfiguration() + await refreshProviderStates() + await service.triggerBackupIfNeeded() + } catch let error as AutoBackupError { + configureError = error + } catch { + configureError = .unknown(reason: error.localizedDescription) + } + } + + public func setEnabled(_ enabled: Bool) async { + await service.setEnabled(enabled) + } + + public func setRetention(_ retention: AutoBackupRetention) async { + await service.setRetention(retention) + } + + public func backupNow() async { + await service.forceBackup() + } + + // MARK: - Status Presentation + + public var isBackingUp: Bool { + switch status { + case .backingUp, .cleaningUp: true + case .disabled, .idle, .error, .completed: false + } + } + + public var statusIconName: String { + switch status { + case .disabled: + "icloud.slash" + case .idle: + "icloud" + case .backingUp, .cleaningUp: + "arrow.clockwise.icloud" + case .completed: + "checkmark.icloud" + case .error: + "exclamationmark.icloud" + } + } + + public var statusDescription: String { + switch status { + case .disabled: + "Automatic backups are disabled" + case .idle: + "Ready to back up when changes occur" + case .backingUp: + "Backing up..." + case .cleaningUp: + "Cleaning up old backups..." + case let .completed(date): + "Last backup: \(date.formatted(date: .abbreviated, time: .shortened))" + case .error: + "Backup failed" + } + } + + /// When auto-backup is off the footer explains what the feature does; once it is on, the footer + /// carries the live status so the state is visible without a separate status row. + public var footerText: String { + guard configuration.isEnabled else { + return "Enable to automatically back up your vault to cloud storage whenever changes are made." + } + return statusDescription + } +} diff --git a/Vault/Sources/VaultFeed/Presentation/Backup/BackupCreateViewModel.swift b/Vault/Sources/VaultFeed/Presentation/Backup/BackupCreateViewModel.swift deleted file mode 100644 index 450a96b2b..000000000 --- a/Vault/Sources/VaultFeed/Presentation/Backup/BackupCreateViewModel.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Foundation - -/// View model for the backup creation view. -@MainActor -@Observable -public final class BackupCreateViewModel { - public let strings = Strings() - public init() {} -} - -// MARK: - Strings - -extension BackupCreateViewModel { - @MainActor - public struct Strings { - init() {} - - public let homeTitle = localized(key: "backupCreate.title") - public let backupPasswordSectionTitle = localized(key: "backupPasswordState.section.title") - public let backupPasswordCreateTitle = localized(key: "backupPasswordState.create.title") - public let backupPasswordUpdateTitle = localized(key: "backupPasswordState.update.title") - public let backupPasswordExportTitle = localized(key: "backupPasswordState.export.title") - public let backupPasswordLoadingTitle = localized(key: "backupPasswordState.loading.title") - public let backupPasswordErrorTitle = localized(key: "backupPasswordState.retrieveError.title") - public let backupPasswordErrorDetail = localized(key: "backupPasswordState.retrieveError.detail") - } -} diff --git a/Vault/Sources/VaultFeed/Presentation/Backup/BackupKeyChangeViewModel.swift b/Vault/Sources/VaultFeed/Presentation/Backup/BackupKeyChangeViewModel.swift index ae98262cd..16752f20d 100644 --- a/Vault/Sources/VaultFeed/Presentation/Backup/BackupKeyChangeViewModel.swift +++ b/Vault/Sources/VaultFeed/Presentation/Backup/BackupKeyChangeViewModel.swift @@ -44,7 +44,7 @@ public final class BackupKeyChangeViewModel { newlyEnteredPassword == newlyEnteredPasswordConfirm } - public var canGenerateNewPassword: Bool { + public var canSetBackupPassword: Bool { !newPassword.isLoading && passwordConfirmMatches && newlyEnteredPassword.isNotBlank } diff --git a/Vault/Sources/VaultFeed/Resources/VaultFeed.xcstrings b/Vault/Sources/VaultFeed/Resources/VaultFeed.xcstrings index e0e414ead..8c8ae6040 100644 --- a/Vault/Sources/VaultFeed/Resources/VaultFeed.xcstrings +++ b/Vault/Sources/VaultFeed/Resources/VaultFeed.xcstrings @@ -1,50 +1,6 @@ { "sourceLanguage" : "en", "strings" : { - "backupCreate.title" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Backup" - } - } - } - }, - "backupHome.title" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Backups" - } - } - } - }, - "backupPasswordState.create.title" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Create Backup Password" - } - } - } - }, - "backupPasswordState.export.title" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Share Backup Password" - } - } - } - }, "backupPasswordState.import.title" : { "extractionState" : "manual", "localizations" : { @@ -56,61 +12,6 @@ } } }, - "backupPasswordState.loading.title" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Locked" - } - } - } - }, - "backupPasswordState.retrieveError.detail" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Unable to fetch backup password information" - } - } - } - }, - "backupPasswordState.retrieveError.title" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Authentication Failure" - } - } - } - }, - "backupPasswordState.section.title" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Encryption Key" - } - } - } - }, - "backupPasswordState.update.title" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Change Backup Password" - } - } - } - }, "backupRestore.title" : { "extractionState" : "manual", "localizations" : { diff --git a/Vault/Sources/VaultiOS/Views/Backup/AutoBackupSettingsView.swift b/Vault/Sources/VaultiOS/Views/Backup/AutoBackupSettingsView.swift deleted file mode 100644 index 7e7da53da..000000000 --- a/Vault/Sources/VaultiOS/Views/Backup/AutoBackupSettingsView.swift +++ /dev/null @@ -1,257 +0,0 @@ -import Foundation -import SwiftUI -import UniformTypeIdentifiers -import VaultFeed - -/// View for configuring and monitoring auto-backup settings. -@MainActor -struct AutoBackupSettingsView: View { - @Environment(VaultInjector.self) var injector - let autoBackupService: any AutoBackupService - - @State private var isShowingFolderPicker = false - @State private var selectedProviderID: String? - @State private var providerConfigStates: [String: Bool] = [:] - @State private var providerConfigSummaries: [String: String] = [:] - - // Local state to observe changes from publishers - @State private var status: AutoBackupStatus = .disabled - @State private var configuration: AutoBackupConfiguration = .init() - - var body: some View { - Section { - enabledToggle - - if configuration.isEnabled { - destinationRow - - if selectedProviderIsConfigured { - retentionPicker - } - - if case let .error(error) = status { - errorRow(error) - } - - if selectedProviderIsConfigured { - backupNowButton - } - } - } footer: { - Text(footerText) - } - .fileImporter( - isPresented: $isShowingFolderPicker, - allowedContentTypes: [.folder], - ) { result in - // A cancelled picker is not an error worth surfacing; the provider - // reports any real configuration failure through its own status. - guard case let .success(url) = result else { return } - configureSelectedProvider(with: url) - } - .task { - // Initialize with current values - status = autoBackupService.status - configuration = autoBackupService.configuration - await loadProviderConfigStates() - } - .onReceive(autoBackupService.statusPublisher) { newStatus in - status = newStatus - } - .onReceive(autoBackupService.configurationPublisher) { newConfiguration in - configuration = newConfiguration - // Refresh provider states when configuration changes - Task { - await loadProviderConfigStates() - } - } - } - - // MARK: - Rows - - private var enabledToggle: some View { - Toggle(isOn: Binding( - get: { configuration.isEnabled }, - set: { enabled in - Task { - await autoBackupService.setEnabled(enabled) - } - }, - )) { - FormRow(image: Image(systemName: statusIconName), color: statusColor) { - Text("Auto-Backup") - } - } - } - - private var destinationRow: some View { - Button { - if let provider = autoBackupService.availableProviders.first { - Task { - await autoBackupService.selectProvider(id: provider.id) - } - selectedProviderID = provider.id - isShowingFolderPicker = true - } - } label: { - LabeledContent { - Text(selectedProviderSummary ?? "Choose a folder") - } label: { - FormRow(image: Image(systemName: "folder.fill"), color: .green) { - Text("Destination") - } - } - } - } - - private var retentionPicker: some View { - Picker(selection: Binding( - get: { configuration.retentionDays }, - set: { retention in - Task { - await autoBackupService.setRetention(retention) - } - }, - )) { - ForEach(AutoBackupRetention.allCases, id: \.self) { retention in - Text(retention.localizedTitle).tag(retention) - } - } label: { - FormRow(image: Image(systemName: "clock.arrow.circlepath"), color: .blue) { - Text("Keep Backups For") - } - } - } - - private func errorRow(_ error: AutoBackupError) -> some View { - FormRow( - image: Image(systemName: "exclamationmark.triangle.fill"), - color: .orange, - alignment: .firstTextBaseline, - ) { - TextAndSubtitle( - title: error.errorDescription ?? "An error occurred", - subtitle: error.recoverySuggestion, - ) - } - } - - private var backupNowButton: some View { - AsyncButton { - await autoBackupService.forceBackup() - } label: { - FormRow(image: Image(systemName: "arrow.clockwise.icloud"), color: .accentColor) { - Text("Backup Now") - } - } loading: { - FormRow(image: Image(systemName: "arrow.clockwise.icloud"), color: .accentColor) { - ProgressView() - } - } - .disabled(isBackingUp) - } - - // MARK: - Helpers - - /// When auto-backup is off the footer explains what the feature does; once it is on, the footer - /// carries the live status so the state is visible without a separate status row. - private var footerText: String { - guard configuration.isEnabled else { - return "Enable to automatically back up your vault to cloud storage whenever changes are made." - } - return statusDescription - } - - private var statusColor: Color { - switch status { - case .disabled: - .gray - case .idle, .completed: - .green - case .backingUp, .cleaningUp: - .accentColor - case .error: - .orange - } - } - - private var statusIconName: String { - switch status { - case .disabled: - "icloud.slash" - case .idle: - "icloud" - case .backingUp, .cleaningUp: - "arrow.clockwise.icloud" - case .completed: - "checkmark.icloud" - case .error: - "exclamationmark.icloud" - } - } - - private var statusDescription: String { - switch status { - case .disabled: - "Automatic backups are disabled" - case .idle: - "Ready to back up when changes occur" - case .backingUp: - "Backing up..." - case .cleaningUp: - "Cleaning up old backups..." - case let .completed(date): - "Last backup: \(date.formatted(date: .abbreviated, time: .shortened))" - case .error: - "Backup failed" - } - } - - private var isBackingUp: Bool { - if case .backingUp = status { - return true - } - if case .cleaningUp = status { - return true - } - return false - } - - private var selectedProviderIsConfigured: Bool { - guard let providerID = configuration.providerID else { return false } - return providerConfigStates[providerID] ?? false - } - - private var selectedProviderSummary: String? { - guard let providerID = configuration.providerID else { return nil } - return providerConfigSummaries[providerID] - } - - private func loadProviderConfigStates() async { - for provider in autoBackupService.availableProviders { - providerConfigStates[provider.id] = await provider.isConfigured - if let summary = await provider.configurationSummary { - providerConfigSummaries[provider.id] = summary - } - } - } - - private func configureSelectedProvider(with url: URL) { - guard let providerID = selectedProviderID, - let provider = autoBackupService.availableProviders.first(where: { $0.id == providerID }) - else { return } - - Task { - do { - try await provider.configure(with: url) - // Save the configuration to persistent storage - await autoBackupService.saveProviderConfiguration() - await loadProviderConfigStates() - // Trigger a backup now that it's configured - await autoBackupService.triggerBackupIfNeeded() - } catch { - // Configuration failed - the provider will remain unconfigured - } - } - } -} diff --git a/Vault/Sources/VaultiOS/Views/Backup/AutoBackupView.swift b/Vault/Sources/VaultiOS/Views/Backup/AutoBackupView.swift new file mode 100644 index 000000000..02ac9a525 --- /dev/null +++ b/Vault/Sources/VaultiOS/Views/Backup/AutoBackupView.swift @@ -0,0 +1,251 @@ +import Foundation +import SwiftUI +import UniformTypeIdentifiers +import VaultFeed + +/// Screen for configuring and monitoring auto-backup. +@MainActor +struct AutoBackupView: View { + @Environment(VaultDataModel.self) var dataModel + @Environment(DeviceAuthenticationService.self) var authenticationService + @Environment(VaultInjector.self) var injector + @State private var viewModel: AutoBackupViewModel + @State private var isShowingFolderPicker = false + @State private var isShowingCreatePassword = false + + init(viewModel: AutoBackupViewModel) { + _viewModel = .init(wrappedValue: viewModel) + } + + var body: some View { + Form { + switch dataModel.backupPassword { + case .error: + authenticateSection(isError: true) + case .notFetched: + authenticateSection(isError: false) + case .notCreated: + createPasswordSection + case .fetched: + enabledSection + + if viewModel.configuration.isEnabled { + destinationSection + + if viewModel.isDestinationConfigured { + retentionSection + backupNowSection + } + } + } + } + .navigationTitle("Auto-Backup") + .navigationBarTitleDisplayMode(.inline) + .fileImporter( + isPresented: $isShowingFolderPicker, + allowedContentTypes: [.folder], + ) { result in + // A cancelled picker is not an error worth surfacing; the provider + // reports any real configuration failure through its own status. + guard case let .success(url) = result else { return } + Task { + await viewModel.configureDestination(url: url) + } + } + .task { + await viewModel.onAppear() + } + .sheet(isPresented: $isShowingCreatePassword) { + NavigationStack { + BackupKeyChangeView(viewModel: .init( + dataModel: dataModel, + authenticationService: authenticationService, + deriverFactory: injector.vaultKeyDeriverFactory, + )) + } + } + } + + // MARK: - Authenticate Section + + private func authenticateSection(isError: Bool) -> some View { + Section { + AsyncButton { + await dataModel.loadBackupPassword() + } label: { + FormRow(image: Image(systemName: "key.horizontal.fill"), color: .accentColor) { + Text("Authenticate") + } + } loading: { + FormRow(image: Image(systemName: "key.horizontal.fill"), color: .accentColor) { + ProgressView() + } + } + } header: { + Text(isError ? "Authentication Failed" : "Locked") + } footer: { + Text( + isError + ? "Unable to verify your identity. Please try again." + : "Authenticate to view auto-backup settings.", + ) + .foregroundStyle(isError ? Color.red : Color.secondary) + } + } + + // MARK: - Create Password Section + + private var createPasswordSection: some View { + Section { + Button { + isShowingCreatePassword = true + } label: { + FormRow(image: Image(systemName: "key.horizontal.fill"), color: .accentColor) { + Text("Create Backup Password") + } + } + } header: { + Text("Backup Password") + } footer: { + Text("Auto-backup needs a backup password before it can run.") + } + } + + // MARK: - Enabled Section + + private var enabledSection: some View { + Section { + Toggle(isOn: Binding( + get: { viewModel.configuration.isEnabled }, + set: { enabled in + Task { + await viewModel.setEnabled(enabled) + } + }, + )) { + FormRow(image: Image(systemName: viewModel.statusIconName), color: statusColor) { + Text("Auto-Backup") + } + } + + if case let .error(error) = viewModel.status { + errorRow(error) + } + } footer: { + Text(viewModel.footerText) + } + } + + // MARK: - Destination Section + + private var destinationSection: some View { + Section { + if let provider = viewModel.activeProvider, provider.isConfigured { + Button { + changeDestination() + } label: { + LabeledContent { + Text("Change") + } label: { + FormRow(image: Image(systemName: provider.iconSystemName), color: .green) { + TextAndSubtitle(title: provider.displayName, subtitle: provider.folderSummary) + } + } + } + } else { + Button { + changeDestination() + } label: { + FormRow(image: Image(systemName: "folder.fill"), color: .accentColor) { + Text("Choose Folder") + } + } + } + + if let error = viewModel.configureError { + errorRow(error) + } + } header: { + Text("Destination") + } footer: { + Text("Backups are saved as encrypted PDFs in a folder you choose.") + } + } + + private func changeDestination() { + Task { + await viewModel.beginDestinationSelection() + isShowingFolderPicker = true + } + } + + // MARK: - Retention Section + + private var retentionSection: some View { + Section { + Picker(selection: Binding( + get: { viewModel.configuration.retentionDays }, + set: { retention in + Task { + await viewModel.setRetention(retention) + } + }, + )) { + ForEach(AutoBackupRetention.allCases, id: \.self) { retention in + Text(retention.localizedTitle).tag(retention) + } + } label: { + FormRow(image: Image(systemName: "clock.arrow.circlepath"), color: .blue) { + Text("Keep Backups For") + } + } + } + } + + // MARK: - Backup Now Section + + private var backupNowSection: some View { + Section { + AsyncButton { + await viewModel.backupNow() + } label: { + FormRow(image: Image(systemName: "arrow.clockwise.icloud"), color: .accentColor) { + Text("Backup Now") + } + } loading: { + FormRow(image: Image(systemName: "arrow.clockwise.icloud"), color: .accentColor) { + ProgressView() + } + } + .disabled(viewModel.isBackingUp) + } + } + + // MARK: - Helpers + + private func errorRow(_ error: AutoBackupError) -> some View { + FormRow( + image: Image(systemName: "exclamationmark.triangle.fill"), + color: .orange, + alignment: .firstTextBaseline, + ) { + TextAndSubtitle( + title: error.errorDescription ?? "An error occurred", + subtitle: error.recoverySuggestion, + ) + } + } + + private var statusColor: Color { + switch viewModel.status { + case .disabled: + .gray + case .idle, .completed: + .green + case .backingUp, .cleaningUp: + .accentColor + case .error: + .orange + } + } +} diff --git a/Vault/Sources/VaultiOS/Views/Backup/BackupCreateView.swift b/Vault/Sources/VaultiOS/Views/Backup/BackupExportView.swift similarity index 82% rename from Vault/Sources/VaultiOS/Views/Backup/BackupCreateView.swift rename to Vault/Sources/VaultiOS/Views/Backup/BackupExportView.swift index a0217e8f1..de44a4fbd 100644 --- a/Vault/Sources/VaultiOS/Views/Backup/BackupCreateView.swift +++ b/Vault/Sources/VaultiOS/Views/Backup/BackupExportView.swift @@ -3,12 +3,12 @@ import SwiftUI import VaultFeed import VaultKeygen +/// Screen for exporting the vault: PDF backup and device transfer. @MainActor -struct BackupCreateView: View { +struct BackupExportView: View { @Environment(VaultDataModel.self) var dataModel @Environment(DeviceAuthenticationService.self) var authenticationService @Environment(VaultInjector.self) var injector - @State private var viewModel = BackupCreateViewModel() @State private var modal: Modal? @State private var pdfNavigationPath = NavigationPath() @@ -28,13 +28,12 @@ struct BackupCreateView: View { case .notCreated: passwordNotCreatedSection case let .fetched(password): - passwordExistsSection - AutoBackupSettingsView(autoBackupService: injector.autoBackupService) pdfBackupSection(password: password) deviceTransferSection(password: password) } } - .navigationTitle(Text(viewModel.strings.homeTitle)) + .navigationTitle(Text("Export")) + .navigationBarTitleDisplayMode(.inline) .task { await dataModel.reloadItems() } @@ -113,16 +112,12 @@ struct BackupCreateView: View { } } } header: { - Text( - isError - ? viewModel.strings.backupPasswordErrorTitle - : viewModel.strings.backupPasswordLoadingTitle, - ) + Text(isError ? "Authentication Failed" : "Locked") } footer: { Text( isError - ? viewModel.strings.backupPasswordErrorDetail - : "Authenticate to access backup settings.", + ? "Unable to verify your identity. Please try again." + : "Authenticate to export your vault.", ) .foregroundStyle(isError ? Color.red : Color.secondary) } @@ -146,30 +141,6 @@ struct BackupCreateView: View { } } - // MARK: - Password Exists Section - - private var passwordExistsSection: some View { - Section { - LabeledContent { - Text("Active") - } label: { - FormRow(image: Image(systemName: "checkmark.shield.fill"), color: .green) { - Text("Backup Password") - } - } - - Button { - modal = .updatePassword - } label: { - FormRow(image: Image(systemName: "key.2.on.ring.fill"), color: .gray) { - Text("Change Password") - } - } - } footer: { - Text("Your backups are protected with encryption.") - } - } - // MARK: - PDF Backup Section private func pdfBackupSection(password: DerivedEncryptionKey) -> some View { diff --git a/Vault/Sources/VaultiOS/Views/Backup/BackupHomeView.swift b/Vault/Sources/VaultiOS/Views/Backup/BackupHomeView.swift new file mode 100644 index 000000000..c34f6c4b9 --- /dev/null +++ b/Vault/Sources/VaultiOS/Views/Backup/BackupHomeView.swift @@ -0,0 +1,110 @@ +import Foundation +import SwiftUI +import VaultFeed + +/// Hub for everything backup: status, auto-backup, export, restore, and the +/// backup password. +/// +/// The hub itself is reachable without device authentication — it exposes +/// navigation and backup recency only. Every sub-surface that loads or uses +/// the backup key authenticates on entry. +@MainActor +struct BackupHomeView: View { + @Environment(VaultDataModel.self) var dataModel + @Environment(DeviceAuthenticationService.self) var authenticationService + @Environment(VaultInjector.self) var injector + @State private var isShowingPasswordSheet = false + + var body: some View { + Form { + summarySection + autoBackupSection + exportSection + restoreSection + passwordSection + } + .navigationTitle(Text("Backups")) + .sheet(isPresented: $isShowingPasswordSheet) { + NavigationStack { + BackupKeyChangeView(viewModel: .init( + dataModel: dataModel, + authenticationService: authenticationService, + deriverFactory: injector.vaultKeyDeriverFactory, + )) + } + } + } + + // MARK: - Summary Section + + private var summarySection: some View { + Section { + LastBackupSummaryView(lastBackup: dataModel.lastBackupEvent) + .listRowInsets(EdgeInsets()) + } + } + + // MARK: - Auto-Backup Section + + private var autoBackupSection: some View { + Section { + NavigationLink { + AutoBackupView(viewModel: .init(service: injector.autoBackupService)) + } label: { + FormRow(image: Image(systemName: "arrow.clockwise.icloud"), color: .accentColor) { + Text("Auto-Backup") + } + } + } footer: { + Text("Automatically back up your vault to cloud storage when changes are made.") + } + } + + // MARK: - Export Section + + private var exportSection: some View { + Section { + NavigationLink { + BackupExportView() + } label: { + FormRow(image: Image(systemName: "square.and.arrow.up.fill"), color: .accentColor) { + Text("Export") + } + } + } footer: { + Text("Create a PDF backup or transfer to another device.") + } + } + + // MARK: - Restore Section + + private var restoreSection: some View { + Section { + NavigationLink { + BackupRestoreView() + } label: { + FormRow(image: Image(systemName: "square.and.arrow.down.fill"), color: .accentColor) { + Text("Restore") + } + } + } footer: { + Text("Import items from a backup PDF or another device.") + } + } + + // MARK: - Password Section + + private var passwordSection: some View { + Section { + Button { + isShowingPasswordSheet = true + } label: { + FormRow(image: Image(systemName: "key.horizontal.fill"), color: .accentColor) { + Text("Backup Password") + } + } + } footer: { + Text("Set or change the password that protects your backups.") + } + } +} diff --git a/Vault/Sources/VaultiOS/Views/Backup/BackupKeyChangeView.swift b/Vault/Sources/VaultiOS/Views/Backup/BackupKeyChangeView.swift index 4251c327f..2dc98ed40 100644 --- a/Vault/Sources/VaultiOS/Views/Backup/BackupKeyChangeView.swift +++ b/Vault/Sources/VaultiOS/Views/Backup/BackupKeyChangeView.swift @@ -20,7 +20,6 @@ struct BackupKeyChangeView: View { authenticateSection(isError: false) case .allowed: passwordSection - generateSection detailsSection case .denied: authenticateSection(isError: true) @@ -112,49 +111,50 @@ struct BackupKeyChangeView: View { .foregroundStyle(viewModel.passwordConfirmMatches ? .green : .red) } .disabled(viewModel.newPassword.isLoading) + + Button { + keyGenerationTask?.cancel() + keyGenerationTask = Task { + await viewModel.saveEnteredPassword() + } + } label: { + FormRow(image: Image(systemName: "checkmark.shield.fill"), color: .accentColor) { + Text("Set Backup Password") + } + } + .animation(.none, value: viewModel.newPassword) + .disabled(!viewModel.canSetBackupPassword) } } header: { - Text("New Password") + Text("Backup Password") } footer: { - Text("Enter a new password to generate an encryption key.") - } - .animation(.snappy, value: viewModel.newlyEnteredPassword) - } - - // MARK: - Generate Section + VStack(alignment: .leading, spacing: 8) { + Text( + "Backups are encrypted with this password. You will need it to restore a backup, so keep it somewhere safe.", + ) - private var generateSection: some View { - Section { - Button { - keyGenerationTask?.cancel() - keyGenerationTask = Task { - await viewModel.saveEnteredPassword() - } - } label: { - FormRow(image: Image(systemName: "key.2.on.ring.fill"), color: .accentColor) { - Text("Generate Key") - } + setPasswordStatus } - .animation(.none, value: viewModel.newPassword) - .disabled(!viewModel.canGenerateNewPassword) - } footer: { - generationStatus } + .animation(.snappy, value: viewModel.newlyEnteredPassword) } @ViewBuilder - private var generationStatus: some View { + private var setPasswordStatus: some View { switch viewModel.newPassword { case .success: - Label("Vault encryption key updated successfully", systemImage: "checkmark.circle.fill") + Label("Backup password set", systemImage: "checkmark.circle.fill") .foregroundStyle(.green) - case .keygenError, .keygenCancelled: - Label("Error generating encryption key", systemImage: "xmark.octagon.fill") + case .keygenError: + Label("Something went wrong. Your backup password was not changed.", systemImage: "xmark.octagon.fill") + .foregroundStyle(.red) + case .keygenCancelled: + Label("Cancelled. Your backup password was not changed.", systemImage: "xmark.octagon.fill") .foregroundStyle(.red) case .creating: HStack(alignment: .center, spacing: 4) { ProgressView() - Text("Generating encryption key") + Text("Securing your password. This can take up to 3 minutes.") } case .passwordConfirmError: Label("Passwords do not match", systemImage: "xmark") @@ -168,24 +168,16 @@ struct BackupKeyChangeView: View { private var detailsSection: some View { Section { - DisclosureGroup { - Text( - "Changing your password will not update existing backups. To restore from a previous backup, you must use the password that was active when that backup was created.", - ) - .font(.callout) - .foregroundStyle(.secondary) - } label: { - Label("Historical Backups", systemImage: "clock.arrow.circlepath") - } - DisclosureGroup { VStack(alignment: .leading, spacing: 8) { - Text("Your password is used to generate an encryption key that is used to secure your vault.") Text( - "For security, this key generation process may take up to 3 minutes, even on a very fast device.", + "Your password is turned into an encryption key on this device. The password itself is never stored.", + ) + Text( + "Preparing the key is deliberately slow to resist guessing — up to 3 minutes, even on a fast device.", ) Text( - "Your encryption key is not shared between devices.", + "Each device prepares its own key. Keys are never shared between devices.", ) } .font(.callout) @@ -194,6 +186,16 @@ struct BackupKeyChangeView: View { Label("About", systemImage: "questionmark.circle.fill") } + DisclosureGroup { + Text( + "Changing your password will not update existing backups. To restore from a previous backup, you must use the password that was active when that backup was created.", + ) + .font(.callout) + .foregroundStyle(.secondary) + } label: { + Label("Historical Backups", systemImage: "clock.arrow.circlepath") + } + DisclosureGroup { LabeledContent { Text(viewModel.encryptionKeyDeriverSignature.userVisibleDescription) diff --git a/Vault/Sources/VaultiOS/Views/Backup/LastBackupSummaryView.swift b/Vault/Sources/VaultiOS/Views/Backup/LastBackupSummaryView.swift index 54e48609e..bff08b8e7 100644 --- a/Vault/Sources/VaultiOS/Views/Backup/LastBackupSummaryView.swift +++ b/Vault/Sources/VaultiOS/Views/Backup/LastBackupSummaryView.swift @@ -5,6 +5,8 @@ import VaultFeed struct LastBackupSummaryView: View { var lastBackup: VaultBackupEvent? + /// Injectable so snapshots can pin the staleness calculation. + var now: Date = .init() var body: some View { HStack(spacing: 0) { @@ -56,7 +58,7 @@ struct LastBackupSummaryView: View { private var accentColor: Color { guard let lastBackup else { return Color.red } - let daysSinceBackup = Calendar.current.dateComponents([.day], from: lastBackup.backupDate, to: Date()) + let daysSinceBackup = Calendar.current.dateComponents([.day], from: lastBackup.backupDate, to: now) .day ?? Int.max if daysSinceBackup < 7 { diff --git a/Vault/Sources/VaultiOS/Views/VaultMainNavigationView.swift b/Vault/Sources/VaultiOS/Views/VaultMainNavigationView.swift index e39926d94..aa7623095 100644 --- a/Vault/Sources/VaultiOS/Views/VaultMainNavigationView.swift +++ b/Vault/Sources/VaultiOS/Views/VaultMainNavigationView.swift @@ -18,8 +18,7 @@ struct VaultMainNavigationView: View { enum SidebarItem: Hashable { case items case tags - case backup - case restore + case backups case settings case about case demos @@ -38,11 +37,8 @@ struct VaultMainNavigationView: View { } Section { - NavigationLink(value: SidebarItem.backup) { - Label("Backup", systemImage: "arrow.up.doc.fill") - } - NavigationLink(value: SidebarItem.restore) { - Label("Restore", systemImage: "arrow.down.doc.fill") + NavigationLink(value: SidebarItem.backups) { + Label("Backups", systemImage: "externaldrive.fill") } } @@ -90,14 +86,9 @@ struct VaultMainNavigationView: View { VaultAboutView(viewModel: SettingsViewModel()) } .navigationBarTitleDisplayMode(.inline) - case .backup: - NavigationStack { - BackupCreateView() - } - .navigationBarTitleDisplayMode(.inline) - case .restore: + case .backups: NavigationStack { - BackupRestoreView() + BackupHomeView() } .navigationBarTitleDisplayMode(.inline) case .demos: diff --git a/Vault/Tests/VaultFeedTests/Presentation/AutoBackupViewModelTests.swift b/Vault/Tests/VaultFeedTests/Presentation/AutoBackupViewModelTests.swift new file mode 100644 index 000000000..329e8ab59 --- /dev/null +++ b/Vault/Tests/VaultFeedTests/Presentation/AutoBackupViewModelTests.swift @@ -0,0 +1,295 @@ +import Combine +import Foundation +import Testing +@testable import VaultFeed + +@MainActor +struct AutoBackupViewModelTests { + @Test + func init_seedsStatusAndConfigurationFromService() { + var configuration = AutoBackupConfiguration() + configuration.isEnabled = true + let service = AutoBackupServiceMock(status: .idle, configuration: configuration) + + let sut = makeSUT(service: service) + + #expect(sut.status == .idle) + #expect(sut.configuration == configuration) + } + + @Test + func init_hasNoSideEffects() { + let service = AutoBackupServiceMock(status: .disabled, configuration: .init()) + + _ = makeSUT(service: service) + + #expect(service.setEnabledCallCount == 0) + #expect(service.selectProviderCallCount == 0) + #expect(service.saveProviderConfigurationCallCount == 0) + #expect(service.triggerBackupIfNeededCallCount == 0) + #expect(service.forceBackupCallCount == 0) + } + + @Test + func statusPublisher_updatesStatus() { + let service = AutoBackupServiceMock(status: .disabled, configuration: .init()) + let sut = makeSUT(service: service) + + service.statusPublisherSubject.send(.backingUp) + + #expect(sut.status == .backingUp) + } + + @Test + func configurationPublisher_updatesConfiguration() { + let service = AutoBackupServiceMock(status: .disabled, configuration: .init()) + let sut = makeSUT(service: service) + + var newConfiguration = AutoBackupConfiguration() + newConfiguration.isEnabled = true + service.configurationPublisherSubject.send(newConfiguration) + + #expect(sut.configuration == newConfiguration) + } + + @Test + func refreshProviderStates_mapsAvailableProviders() async { + let provider = BackupStorageProviderMock( + id: "provider-1", + displayName: "Files", + iconSystemName: "folder", + isConfigured: true, + configurationSummary: "Vault Backups", + ) + let service = AutoBackupServiceMock( + status: .disabled, + configuration: .init(), + availableProviders: [provider], + ) + let sut = makeSUT(service: service) + + await sut.refreshProviderStates() + + #expect(sut.providerStates == [ + .init( + id: "provider-1", + displayName: "Files", + iconSystemName: "folder", + isConfigured: true, + folderSummary: "Vault Backups", + ), + ]) + } + + @Test + func onAppear_refreshesProviderStatesWhenNotSeeded() async { + let provider = BackupStorageProviderMock(id: "provider-1", displayName: "Files") + let service = AutoBackupServiceMock( + status: .disabled, + configuration: .init(), + availableProviders: [provider], + ) + let sut = makeSUT(service: service) + + await sut.onAppear() + + #expect(sut.providerStates.map(\.id) == ["provider-1"]) + } + + @Test + func onAppear_keepsSeededProviderStates() async { + let service = AutoBackupServiceMock(status: .disabled, configuration: .init()) + let sut = makeSUT(service: service, initialProviderStates: [anyProviderState(id: "provider-1")]) + + await sut.onAppear() + + #expect(sut.providerStates.map(\.id) == ["provider-1"]) + } + + @Test + func activeProvider_prefersConfiguredProviderIDOverFirst() { + var configuration = AutoBackupConfiguration() + configuration.providerID = "provider-2" + let service = AutoBackupServiceMock(status: .disabled, configuration: configuration) + + let sut = makeSUT(service: service, initialProviderStates: [ + anyProviderState(id: "provider-1"), + anyProviderState(id: "provider-2"), + ]) + + #expect(sut.activeProvider?.id == "provider-2") + } + + @Test + func activeProvider_fallsBackToFirstWhenNoProviderSelected() { + let service = AutoBackupServiceMock(status: .disabled, configuration: .init()) + + let sut = makeSUT(service: service, initialProviderStates: [ + anyProviderState(id: "provider-1"), + anyProviderState(id: "provider-2"), + ]) + + #expect(sut.activeProvider?.id == "provider-1") + } + + @Test + func beginDestinationSelection_selectsActiveProviderAndClearsError() async { + let service = AutoBackupServiceMock(status: .disabled, configuration: .init()) + let sut = makeSUT(service: service, initialProviderStates: [anyProviderState(id: "provider-1")]) + sut.configureError = .accessDenied + + await sut.beginDestinationSelection() + + #expect(service.selectProviderCallCount == 1) + #expect(service.selectProviderArgValues == ["provider-1"]) + #expect(sut.configureError == nil) + } + + @Test + func configureDestination_successConfiguresSavesAndTriggersBackup() async { + let provider = BackupStorageProviderMock(id: "provider-1") + let service = AutoBackupServiceMock( + status: .disabled, + configuration: .init(), + availableProviders: [provider], + ) + let sut = makeSUT(service: service, initialProviderStates: [anyProviderState(id: "provider-1")]) + + await sut.configureDestination(url: anyFolderURL()) + + #expect(provider.configureCallCount == 1) + #expect(service.saveProviderConfigurationCallCount == 1) + #expect(service.triggerBackupIfNeededCallCount == 1) + #expect(sut.configureError == nil) + } + + @Test + func configureDestination_failureSurfacesErrorAndDoesNotSaveOrTrigger() async { + let provider = BackupStorageProviderMock(id: "provider-1") + provider.configureHandler = { _ in throw AutoBackupError.accessDenied } + let service = AutoBackupServiceMock( + status: .disabled, + configuration: .init(), + availableProviders: [provider], + ) + let sut = makeSUT(service: service, initialProviderStates: [anyProviderState(id: "provider-1")]) + + await sut.configureDestination(url: anyFolderURL()) + + #expect(sut.configureError == .accessDenied) + #expect(service.saveProviderConfigurationCallCount == 0) + #expect(service.triggerBackupIfNeededCallCount == 0) + } + + @Test + func configureDestination_unknownFailureMapsToUnknownError() async { + struct SomeError: Error {} + let provider = BackupStorageProviderMock(id: "provider-1") + provider.configureHandler = { _ in throw SomeError() } + let service = AutoBackupServiceMock( + status: .disabled, + configuration: .init(), + availableProviders: [provider], + ) + let sut = makeSUT(service: service, initialProviderStates: [anyProviderState(id: "provider-1")]) + + await sut.configureDestination(url: anyFolderURL()) + + guard case .unknown = sut.configureError else { + Issue.record("Expected .unknown, got \(String(describing: sut.configureError))") + return + } + } + + @Test + func setEnabled_forwardsToService() async { + let service = AutoBackupServiceMock(status: .disabled, configuration: .init()) + let sut = makeSUT(service: service) + + await sut.setEnabled(true) + + #expect(service.setEnabledCallCount == 1) + #expect(service.setEnabledArgValues == [true]) + } + + @Test + func setRetention_forwardsToService() async { + let service = AutoBackupServiceMock(status: .disabled, configuration: .init()) + let sut = makeSUT(service: service) + + await sut.setRetention(.days7) + + #expect(service.setRetentionCallCount == 1) + } + + @Test + func backupNow_forcesBackup() async { + let service = AutoBackupServiceMock(status: .disabled, configuration: .init()) + let sut = makeSUT(service: service) + + await sut.backupNow() + + #expect(service.forceBackupCallCount == 1) + } + + @Test + func isBackingUp_trueOnlyWhileWorking() { + let service = AutoBackupServiceMock(status: .disabled, configuration: .init()) + let sut = makeSUT(service: service) + + service.statusPublisherSubject.send(.backingUp) + #expect(sut.isBackingUp) + + service.statusPublisherSubject.send(.cleaningUp) + #expect(sut.isBackingUp) + + service.statusPublisherSubject.send(.idle) + #expect(!sut.isBackingUp) + } + + @Test + func footerText_explainsFeatureWhenDisabled() { + let service = AutoBackupServiceMock(status: .disabled, configuration: .init()) + let sut = makeSUT(service: service) + + #expect(sut.footerText.contains("Enable to automatically back up")) + } + + @Test + func footerText_carriesLiveStatusWhenEnabled() { + var configuration = AutoBackupConfiguration() + configuration.isEnabled = true + let service = AutoBackupServiceMock(status: .backingUp, configuration: configuration) + let sut = makeSUT(service: service) + + #expect(sut.footerText == sut.statusDescription) + } +} + +// MARK: - Helpers + +extension AutoBackupViewModelTests { + private func makeSUT( + service: AutoBackupServiceMock, + initialProviderStates: [AutoBackupViewModel.ProviderDisplayState] = [], + ) -> AutoBackupViewModel { + AutoBackupViewModel(service: service, initialProviderStates: initialProviderStates) + } + + private func anyProviderState( + id: String, + isConfigured: Bool = false, + ) -> AutoBackupViewModel.ProviderDisplayState { + .init( + id: id, + displayName: "Files", + iconSystemName: "folder", + isConfigured: isConfigured, + folderSummary: isConfigured ? "Vault Backups" : nil, + ) + } + + private func anyFolderURL() -> URL { + URL(fileURLWithPath: "/tmp/backups", isDirectory: true) + } +} diff --git a/Vault/Tests/VaultFeedTests/Presentation/LightweightViewModelCoverageTests.swift b/Vault/Tests/VaultFeedTests/Presentation/LightweightViewModelCoverageTests.swift index 152d64ea2..b07fb9156 100644 --- a/Vault/Tests/VaultFeedTests/Presentation/LightweightViewModelCoverageTests.swift +++ b/Vault/Tests/VaultFeedTests/Presentation/LightweightViewModelCoverageTests.swift @@ -4,20 +4,6 @@ import Testing @MainActor struct LightweightViewModelCoverageTests { - @Test - func backupCreateViewModel_exposesStrings() { - let sut = BackupCreateViewModel() - - #expect(sut.strings.homeTitle.isEmpty == false) - #expect(sut.strings.backupPasswordSectionTitle.isEmpty == false) - #expect(sut.strings.backupPasswordCreateTitle.isEmpty == false) - #expect(sut.strings.backupPasswordUpdateTitle.isEmpty == false) - #expect(sut.strings.backupPasswordExportTitle.isEmpty == false) - #expect(sut.strings.backupPasswordLoadingTitle.isEmpty == false) - #expect(sut.strings.backupPasswordErrorTitle.isEmpty == false) - #expect(sut.strings.backupPasswordErrorDetail.isEmpty == false) - } - @Test func backupRestoreViewModel_exposesStrings() { let sut = BackupRestoreViewModel() diff --git a/Vault/Tests/VaultiOSTests/AutoBackupViewSnapshotTests.swift b/Vault/Tests/VaultiOSTests/AutoBackupViewSnapshotTests.swift new file mode 100644 index 000000000..8f26a5ce7 --- /dev/null +++ b/Vault/Tests/VaultiOSTests/AutoBackupViewSnapshotTests.swift @@ -0,0 +1,176 @@ +import Foundation +import SwiftUI +import TestHelpers +import Testing +@testable import VaultFeed +@testable import VaultiOS + +@MainActor +struct AutoBackupViewSnapshotTests { + @Test + func locked() { + let sut = makeSUT( + dataModel: anyVaultDataModel(), + viewModel: makeViewModel(), + ) + + assertSnapshot(of: sut, as: .image) + } + + @Test + func passwordNotCreated() async { + let sut = makeSUT( + dataModel: await passwordNotCreatedDataModel(), + viewModel: makeViewModel(), + ) + + assertSnapshot(of: sut, as: .image) + } + + @Test + func disabled() async { + let sut = makeSUT( + dataModel: await passwordFetchedDataModel(), + viewModel: makeViewModel(), + ) + + assertSnapshot(of: sut, as: .image) + } + + @Test + func enabledUnconfigured() async { + let sut = makeSUT( + dataModel: await passwordFetchedDataModel(), + viewModel: makeViewModel( + status: .idle, + configuration: enabledConfiguration(), + providerStates: [unconfiguredProviderState()], + ), + ) + + assertSnapshot(of: sut, as: .image) + } + + @Test + func enabledConfigured() async { + let sut = makeSUT( + dataModel: await passwordFetchedDataModel(), + viewModel: makeViewModel( + status: .completed(Date(timeIntervalSince1970: 1_700_000_000)), + configuration: enabledConfiguration(providerID: "icloud-drive"), + providerStates: [configuredProviderState()], + ), + ) + + assertSnapshot(of: sut, as: .image) + } + + @Test + func statusError() async { + let sut = makeSUT( + dataModel: await passwordFetchedDataModel(), + viewModel: makeViewModel( + status: .error(.writeFailed(reason: "The folder could not be reached")), + configuration: enabledConfiguration(providerID: "icloud-drive"), + providerStates: [configuredProviderState()], + ), + ) + + assertSnapshot(of: sut, as: .image) + } + + @Test + func configureError() async { + let viewModel = makeViewModel( + status: .idle, + configuration: enabledConfiguration(), + providerStates: [unconfiguredProviderState()], + ) + viewModel.configureError = .accessDenied + let sut = makeSUT( + dataModel: await passwordFetchedDataModel(), + viewModel: viewModel, + ) + + assertSnapshot(of: sut, as: .image) + } +} + +// MARK: - Helpers + +extension AutoBackupViewSnapshotTests { + private func makeSUT( + dataModel: VaultDataModel, + viewModel: AutoBackupViewModel, + ) -> some View { + NavigationStack { + AutoBackupView(viewModel: viewModel) + } + .environment(dataModel) + .environment(DeviceAuthenticationService(policy: .alwaysAllow)) + .environment(anyVaultInjector()) + .framedForTest() + } + + private func makeViewModel( + status: AutoBackupStatus = .disabled, + configuration: AutoBackupConfiguration = .init(), + providerStates: [AutoBackupViewModel.ProviderDisplayState] = [], + ) -> AutoBackupViewModel { + AutoBackupViewModel( + service: AutoBackupServiceMock(status: status, configuration: configuration), + initialProviderStates: providerStates, + ) + } + + private func passwordNotCreatedDataModel() async -> VaultDataModel { + let backupPasswordStore = BackupPasswordStoreMock() + backupPasswordStore.fetchPasswordHandler = { nil } + let dataModel = anyVaultDataModel(backupPasswordStore: backupPasswordStore) + await dataModel.loadBackupPassword() + return dataModel + } + + private func passwordFetchedDataModel() async -> VaultDataModel { + let backupPasswordStore = BackupPasswordStoreMock() + backupPasswordStore.fetchPasswordHandler = { .init( + key: .random(), + salt: .random(count: 32), + keyDervier: .testing, + ) } + let dataModel = anyVaultDataModel(backupPasswordStore: backupPasswordStore) + await dataModel.loadBackupPassword() + return dataModel + } + + private func enabledConfiguration(providerID: String? = nil) -> AutoBackupConfiguration { + AutoBackupConfiguration( + isEnabled: true, + retentionDays: .days30, + providerID: providerID, + providerConfigs: [:], + lastBackupHash: nil, + lastBackupDate: nil, + ) + } + + private func unconfiguredProviderState() -> AutoBackupViewModel.ProviderDisplayState { + .init( + id: "icloud-drive", + displayName: "iCloud Drive", + iconSystemName: "icloud", + isConfigured: false, + folderSummary: nil, + ) + } + + private func configuredProviderState() -> AutoBackupViewModel.ProviderDisplayState { + .init( + id: "icloud-drive", + displayName: "iCloud Drive", + iconSystemName: "icloud", + isConfigured: true, + folderSummary: "Vault Backups", + ) + } +} diff --git a/Vault/Tests/VaultiOSTests/BackupViewSnapshotTests.swift b/Vault/Tests/VaultiOSTests/BackupViewSnapshotTests.swift index 5969d18a6..912930eb5 100644 --- a/Vault/Tests/VaultiOSTests/BackupViewSnapshotTests.swift +++ b/Vault/Tests/VaultiOSTests/BackupViewSnapshotTests.swift @@ -1,3 +1,4 @@ +import CryptoEngine import Foundation import SwiftUI import TestHelpers @@ -8,43 +9,63 @@ import VaultFeed @MainActor struct BackupViewSnapshotTests { @Test - func backupCreate_passwordNotFetched() { - let sut = makeBackupCreateSUT(dataModel: anyVaultDataModel()) - .framedForTest() + func backupHome_noBackup() { + let sut = makeBackupHomeSUT(dataModel: anyVaultDataModel()) + + assertSnapshot(of: sut, as: .image) + } + + @Test + func backupHome_staleBackup() { + let backupEventLogger = BackupEventLoggerMock() + backupEventLogger.lastBackupEventHandler = { + VaultBackupEvent( + backupDate: Date(timeIntervalSince1970: 1_600_000_000), + eventDate: Date(timeIntervalSince1970: 1_600_000_000), + kind: .exportedToPDF, + payloadHash: .init(value: Data(repeating: 0xAB, count: 32)), + ) + } + let sut = makeBackupHomeSUT(dataModel: anyVaultDataModel(backupEventLogger: backupEventLogger)) + + assertSnapshot(of: sut, as: .image) + } + + @Test + func backupExport_passwordNotFetched() { + let sut = makeBackupExportSUT(dataModel: anyVaultDataModel()) assertSnapshot(of: sut, as: .image) } @Test - func backupCreate_passwordError() async { + func backupExport_passwordError() async { let backupPasswordStore = BackupPasswordStoreMock() backupPasswordStore.fetchPasswordHandler = { throw TestError() } let dataModel = anyVaultDataModel(backupPasswordStore: backupPasswordStore) await dataModel.loadBackupPassword() await dataModel.reloadData() - let sut = makeBackupCreateSUT(dataModel: dataModel) - .framedForTest() + let sut = makeBackupExportSUT(dataModel: dataModel) assertSnapshot(of: sut, as: .image) } @Test - func backupCreate_passwordNotCreated() async { + func backupExport_passwordNotCreated() async { let backupPasswordStore = BackupPasswordStoreMock() backupPasswordStore.fetchPasswordHandler = { nil } let dataModel = anyVaultDataModel(backupPasswordStore: backupPasswordStore) await dataModel.loadBackupPassword() await dataModel.reloadData() - let sut = makeBackupCreateSUT(dataModel: dataModel) - .framedForTest() + let sut = makeBackupExportSUT(dataModel: dataModel) assertSnapshot(of: sut, as: .image) } @Test - func backupCreate_passwordFetched() async { + func backupExport_passwordFetched() async { let backupPasswordStore = BackupPasswordStoreMock() backupPasswordStore.fetchPasswordHandler = { .init( key: .random(), @@ -55,8 +76,7 @@ struct BackupViewSnapshotTests { await dataModel.loadBackupPassword() await dataModel.reloadData() - let sut = makeBackupCreateSUT(dataModel: dataModel) - .framedForTest() + let sut = makeBackupExportSUT(dataModel: dataModel) assertSnapshot(of: sut, as: .image) } @@ -89,14 +109,28 @@ struct BackupViewSnapshotTests { } extension BackupViewSnapshotTests { - private func makeBackupCreateSUT( + private func makeBackupHomeSUT( dataModel: VaultDataModel, ) -> some View { - let injector = anyVaultInjector() - return BackupCreateView() - .environment(dataModel) - .environment(DeviceAuthenticationService(policy: .alwaysAllow)) - .environment(injector) + NavigationStack { + BackupHomeView() + } + .environment(dataModel) + .environment(DeviceAuthenticationService(policy: .alwaysAllow)) + .environment(anyVaultInjector()) + .framedForTest() + } + + private func makeBackupExportSUT( + dataModel: VaultDataModel, + ) -> some View { + NavigationStack { + BackupExportView() + } + .environment(dataModel) + .environment(DeviceAuthenticationService(policy: .alwaysAllow)) + .environment(anyVaultInjector()) + .framedForTest() } private func makeBackupRestoreSUT( diff --git a/Vault/Tests/VaultiOSTests/LastBackupSummaryViewSnapshotTests.swift b/Vault/Tests/VaultiOSTests/LastBackupSummaryViewSnapshotTests.swift new file mode 100644 index 000000000..0738f31fc --- /dev/null +++ b/Vault/Tests/VaultiOSTests/LastBackupSummaryViewSnapshotTests.swift @@ -0,0 +1,61 @@ +import CryptoEngine +import Foundation +import SwiftUI +import TestHelpers +import Testing +import VaultFeed +@testable import VaultiOS + +@MainActor +struct LastBackupSummaryViewSnapshotTests { + /// Pinned reference instant so the staleness thresholds are deterministic. + private let now = Date(timeIntervalSince1970: 1_700_000_000) + + @Test + func noBackup() { + let sut = makeSUT(lastBackup: nil) + + assertSnapshot(of: sut, as: .image) + } + + @Test + func recentBackup() { + let sut = makeSUT(lastBackup: event(daysAgo: 1)) + + assertSnapshot(of: sut, as: .image) + } + + @Test + func agingBackup() { + let sut = makeSUT(lastBackup: event(daysAgo: 10)) + + assertSnapshot(of: sut, as: .image) + } + + @Test + func staleBackup() { + let sut = makeSUT(lastBackup: event(daysAgo: 100)) + + assertSnapshot(of: sut, as: .image) + } +} + +extension LastBackupSummaryViewSnapshotTests { + private func makeSUT(lastBackup: VaultBackupEvent?) -> some View { + List { + LastBackupSummaryView(lastBackup: lastBackup, now: now) + .listRowInsets(EdgeInsets()) + } + .framedForTest() + } + + private func event(daysAgo: Int) -> VaultBackupEvent { + let backupDate = now.addingTimeInterval(-Double(daysAgo) * 24 * 60 * 60) + return VaultBackupEvent( + backupDate: backupDate, + eventDate: backupDate, + kind: .exportedToPDF, + payloadHash: .init(value: Data(repeating: 0xAB, count: 32)), + ) + } +} diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/AutoBackupViewSnapshotTests/configureError.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/AutoBackupViewSnapshotTests/configureError.1.png new file mode 100644 index 000000000..79cf98599 Binary files /dev/null and b/Vault/Tests/VaultiOSTests/__Snapshots__/AutoBackupViewSnapshotTests/configureError.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/AutoBackupViewSnapshotTests/disabled.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/AutoBackupViewSnapshotTests/disabled.1.png new file mode 100644 index 000000000..30b1c63c9 Binary files /dev/null and b/Vault/Tests/VaultiOSTests/__Snapshots__/AutoBackupViewSnapshotTests/disabled.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/AutoBackupViewSnapshotTests/enabledConfigured.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/AutoBackupViewSnapshotTests/enabledConfigured.1.png new file mode 100644 index 000000000..d8d78b14b Binary files /dev/null and b/Vault/Tests/VaultiOSTests/__Snapshots__/AutoBackupViewSnapshotTests/enabledConfigured.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/AutoBackupViewSnapshotTests/enabledUnconfigured.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/AutoBackupViewSnapshotTests/enabledUnconfigured.1.png new file mode 100644 index 000000000..c96ff2269 Binary files /dev/null and b/Vault/Tests/VaultiOSTests/__Snapshots__/AutoBackupViewSnapshotTests/enabledUnconfigured.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/AutoBackupViewSnapshotTests/locked.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/AutoBackupViewSnapshotTests/locked.1.png new file mode 100644 index 000000000..0b6459481 Binary files /dev/null and b/Vault/Tests/VaultiOSTests/__Snapshots__/AutoBackupViewSnapshotTests/locked.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/AutoBackupViewSnapshotTests/passwordNotCreated.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/AutoBackupViewSnapshotTests/passwordNotCreated.1.png new file mode 100644 index 000000000..e31895491 Binary files /dev/null and b/Vault/Tests/VaultiOSTests/__Snapshots__/AutoBackupViewSnapshotTests/passwordNotCreated.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/AutoBackupViewSnapshotTests/statusError.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/AutoBackupViewSnapshotTests/statusError.1.png new file mode 100644 index 000000000..9c440c1e8 Binary files /dev/null and b/Vault/Tests/VaultiOSTests/__Snapshots__/AutoBackupViewSnapshotTests/statusError.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.dark_medium.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.dark_medium.png index aaf5b0155..f397eaf05 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.dark_medium.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.dark_medium.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.dark_xSmall.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.dark_xSmall.png index 977c9df79..dd36bc1ba 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.dark_xSmall.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.dark_xSmall.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.dark_xxLarge.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.dark_xxLarge.png index a1104eb32..950130627 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.dark_xxLarge.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.dark_xxLarge.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.light_medium.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.light_medium.png index aaf5b0155..f397eaf05 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.light_medium.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.light_medium.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.light_xSmall.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.light_xSmall.png index 977c9df79..dd36bc1ba 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.light_xSmall.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.light_xSmall.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.light_xxLarge.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.light_xxLarge.png index a1104eb32..950130627 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.light_xxLarge.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutAuthenticated.light_xxLarge.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutCreatingState.dark_medium.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutCreatingState.dark_medium.png index 980e10544..98ffd6dde 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutCreatingState.dark_medium.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutCreatingState.dark_medium.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutCreatingState.light_medium.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutCreatingState.light_medium.png index 980e10544..98ffd6dde 100644 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutCreatingState.light_medium.png and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupKeyChangeViewSnapshotTests/layoutCreatingState.light_medium.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupCreate_passwordError.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupCreate_passwordError.1.png deleted file mode 100644 index 54e2b37aa..000000000 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupCreate_passwordError.1.png and /dev/null differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupCreate_passwordFetched.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupCreate_passwordFetched.1.png deleted file mode 100644 index 9c9ab4589..000000000 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupCreate_passwordFetched.1.png and /dev/null differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupCreate_passwordNotCreated.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupCreate_passwordNotCreated.1.png deleted file mode 100644 index 92b572cf6..000000000 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupCreate_passwordNotCreated.1.png and /dev/null differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupCreate_passwordNotFetched.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupCreate_passwordNotFetched.1.png deleted file mode 100644 index f9ae1f287..000000000 Binary files a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupCreate_passwordNotFetched.1.png and /dev/null differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupExport_passwordError.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupExport_passwordError.1.png new file mode 100644 index 000000000..355817e1e Binary files /dev/null and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupExport_passwordError.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupExport_passwordFetched.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupExport_passwordFetched.1.png new file mode 100644 index 000000000..1fd2eac8f Binary files /dev/null and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupExport_passwordFetched.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupExport_passwordNotCreated.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupExport_passwordNotCreated.1.png new file mode 100644 index 000000000..b49df5c62 Binary files /dev/null and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupExport_passwordNotCreated.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupExport_passwordNotFetched.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupExport_passwordNotFetched.1.png new file mode 100644 index 000000000..7aaf5e436 Binary files /dev/null and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupExport_passwordNotFetched.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupHome_noBackup.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupHome_noBackup.1.png new file mode 100644 index 000000000..8a94b298b Binary files /dev/null and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupHome_noBackup.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupHome_staleBackup.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupHome_staleBackup.1.png new file mode 100644 index 000000000..b3cc979c4 Binary files /dev/null and b/Vault/Tests/VaultiOSTests/__Snapshots__/BackupViewSnapshotTests/backupHome_staleBackup.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/LastBackupSummaryViewSnapshotTests/agingBackup.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/LastBackupSummaryViewSnapshotTests/agingBackup.1.png new file mode 100644 index 000000000..7be18a773 Binary files /dev/null and b/Vault/Tests/VaultiOSTests/__Snapshots__/LastBackupSummaryViewSnapshotTests/agingBackup.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/LastBackupSummaryViewSnapshotTests/noBackup.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/LastBackupSummaryViewSnapshotTests/noBackup.1.png new file mode 100644 index 000000000..1cc077a3f Binary files /dev/null and b/Vault/Tests/VaultiOSTests/__Snapshots__/LastBackupSummaryViewSnapshotTests/noBackup.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/LastBackupSummaryViewSnapshotTests/recentBackup.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/LastBackupSummaryViewSnapshotTests/recentBackup.1.png new file mode 100644 index 000000000..5c87d7ae5 Binary files /dev/null and b/Vault/Tests/VaultiOSTests/__Snapshots__/LastBackupSummaryViewSnapshotTests/recentBackup.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/LastBackupSummaryViewSnapshotTests/staleBackup.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/LastBackupSummaryViewSnapshotTests/staleBackup.1.png new file mode 100644 index 000000000..6e207c368 Binary files /dev/null and b/Vault/Tests/VaultiOSTests/__Snapshots__/LastBackupSummaryViewSnapshotTests/staleBackup.1.png differ