diff --git a/Vault/Sources/VaultBackup/Export/VaultExportPDFDocumentRenderer.swift b/Vault/Sources/VaultBackup/Export/VaultExportPDFDocumentRenderer.swift index 681cab439..3e63c1b10 100644 --- a/Vault/Sources/VaultBackup/Export/VaultExportPDFDocumentRenderer.swift +++ b/Vault/Sources/VaultBackup/Export/VaultExportPDFDocumentRenderer.swift @@ -22,18 +22,22 @@ struct VaultExportPDFDocumentRenderer: PDFDocumen self.attacher = attacher } - func render(document: VaultExportPayload) throws -> PDFDocument { + func render(document: VaultExportPayload, progress: @escaping (Double) -> Void) throws -> PDFDocument { let generator = VaultExportDataBlockGenerator(payload: document, dataShardBuilder: dataShardBuilder) - func render(totalPageCount: Int?) throws -> PDFDocument { + /// Each pass renders every code, so each pass owns half of the overall progress. + func render(totalPageCount: Int?, progressRange: ClosedRange) throws -> PDFDocument { let finalPageCount = totalPageCount ?? 0 let document = try generator.makeDocument(knownPageCount: finalPageCount) - return try renderer.render(document: document) + return try renderer.render(document: document) { fraction in + let span = progressRange.upperBound - progressRange.lowerBound + progress(progressRange.lowerBound + span * fraction) + } } // The first pass render determines how many pages there actually are. - let firstPassRender = try render(totalPageCount: nil) - var finalRender = try render(totalPageCount: firstPassRender.pageCount) + let firstPassRender = try render(totalPageCount: nil, progressRange: 0 ... 0.5) + var finalRender = try render(totalPageCount: firstPassRender.pageCount, progressRange: 0.5 ... 1) // Attach the encrypted vault as well, so we can read it easily and automatically. try attacher.attach(vault: document.encryptedVault, to: &finalRender) diff --git a/Vault/Sources/VaultBackup/VaultBackupPDFGenerator.swift b/Vault/Sources/VaultBackup/VaultBackupPDFGenerator.swift index 4d8ff076d..8024127f2 100644 --- a/Vault/Sources/VaultBackup/VaultBackupPDFGenerator.swift +++ b/Vault/Sources/VaultBackup/VaultBackupPDFGenerator.swift @@ -17,7 +17,13 @@ public struct VaultBackupPDFGenerator { self.authorName = authorName } - public func makePDF(payload: VaultExportPayload) throws -> PDFDocument { + /// Renders the payload to a PDF. + /// + /// `progress` is called synchronously on the rendering thread with values in `0...1`, ending at `1`. + public func makePDF( + payload: VaultExportPayload, + progress: @escaping (Double) -> Void = { _ in }, + ) throws -> PDFDocument { let blockDocumentRenderer = PDFDataBlockDocumentRenderer( documentSize: size, rendererFactory: PDFDocumentPageRendererFactory( @@ -40,7 +46,7 @@ public struct VaultBackupPDFGenerator { dataShardBuilder: dataShardBuilder, attacher: VaultBackupPDFAttacherImpl(), ) - return try documentRenderer.render(document: payload) + return try documentRenderer.render(document: payload, progress: progress) } private var dataShardBuilder: DataShardBuilder { diff --git a/Vault/Sources/VaultExport/PDF/PDFDataBlockDocumentRenderer.swift b/Vault/Sources/VaultExport/PDF/PDFDataBlockDocumentRenderer.swift index bcc779955..77390b1d2 100644 --- a/Vault/Sources/VaultExport/PDF/PDFDataBlockDocumentRenderer.swift +++ b/Vault/Sources/VaultExport/PDF/PDFDataBlockDocumentRenderer.swift @@ -26,8 +26,11 @@ public struct PDFDataBlockDocumentRenderer< self.blockLayout = blockLayout } - public func render(document: DataBlockDocument) throws -> PDFDocument { + public func render(document: DataBlockDocument, progress: @escaping (Double) -> Void) throws -> PDFDocument { let renderer = rendererFactory.makeRenderer() + // Images dominate rendering time, so progress is measured in images processed. + let totalImages = document.totalImageCount + var processedImages = 0 var renderingError: (any Error)? let data = renderer.pdfData { context in let drawer = PDFDocumentDrawerHelper( @@ -48,7 +51,10 @@ public struct PDFDataBlockDocumentRenderer< images: imageData, imageRenderer: imageRenderer, rectSeriesLayout: blockLayout, - ) + ) { + processedImages += 1 + progress(Double(processedImages) / Double(totalImages)) + } } } } catch { @@ -58,6 +64,9 @@ public struct PDFDataBlockDocumentRenderer< if let renderingError { throw renderingError } else if let document = PDFDocument(data: data) { + if totalImages == 0 { + progress(1) + } return document } else { throw PDFRenderingError.invalidData @@ -122,10 +131,13 @@ private final class PDFDocumentDrawerHelper { } /// Throws if unable to draw. + /// + /// `onImageProcessed` is called after each image is dealt with, whether it was drawn or skipped. func draw( images: [Data], imageRenderer: some ImageDataRenderer, rectSeriesLayout: @escaping (CGRect) -> some RectSeriesLayout, + onImageProcessed: () -> Void, ) throws(PDFContentDrawerer.DrawError) { var currentImageNumberOnPage: UInt = 0 var currentLayoutEngine = rectSeriesLayout(contentArea.currentBounds) @@ -150,6 +162,7 @@ private final class PDFDocumentDrawerHelper { } try drawerer.drawContent() + onImageProcessed() } } @@ -186,6 +199,19 @@ private final class PDFDocumentDrawerHelper { } } +// MARK: - Progress + +extension DataBlockDocument { + fileprivate var totalImageCount: Int { + content.reduce(0) { count, item in + switch item { + case .title: count + case let .dataBlock(images): count + images.count + } + } + } +} + // MARK: - Positioning extension DataBlockHeader { diff --git a/Vault/Sources/VaultExport/PDF/PDFDocumentRenderer.swift b/Vault/Sources/VaultExport/PDF/PDFDocumentRenderer.swift index 044ae2c75..3a133b6a1 100644 --- a/Vault/Sources/VaultExport/PDF/PDFDocumentRenderer.swift +++ b/Vault/Sources/VaultExport/PDF/PDFDocumentRenderer.swift @@ -4,5 +4,17 @@ import PDFKit /// @mockable(typealias: Document = DataBlockDocument) public protocol PDFDocumentRenderer { associatedtype Document - func render(document: Document) throws -> PDFDocument + + /// Renders the document, reporting progress as it goes. + /// + /// `progress` is called synchronously on the rendering thread with values in `0...1`. A successful + /// render always ends with a report of `1`. + func render(document: Document, progress: @escaping (Double) -> Void) throws -> PDFDocument +} + +extension PDFDocumentRenderer { + /// Renders the document without observing progress. + public func render(document: Document) throws -> PDFDocument { + try render(document: document, progress: { _ in }) + } } diff --git a/Vault/Sources/VaultFeed/Presentation/Backup/AutoBackupViewModel.swift b/Vault/Sources/VaultFeed/Presentation/Backup/AutoBackupViewModel.swift index fecb859c1..ac7bef6a3 100644 --- a/Vault/Sources/VaultFeed/Presentation/Backup/AutoBackupViewModel.swift +++ b/Vault/Sources/VaultFeed/Presentation/Backup/AutoBackupViewModel.swift @@ -32,17 +32,27 @@ public final class AutoBackupViewModel { public private(set) var configuration: AutoBackupConfiguration public private(set) var providerStates: [ProviderDisplayState] public internal(set) var configureError: AutoBackupError? + /// Briefly true after a backup finishes, so the screen can confirm it rather than silently + /// re-enabling the button. Driven by the transition into `.completed`, not by the sticky + /// `.completed` status itself, which is what the service rests on between backups. + public internal(set) var showsBackupCompleteNotice = false private let service: any AutoBackupService private let providerStatesWereSeeded: Bool + private let completionNoticeDuration: Duration @ObservationIgnored private var cancellables = Set() + @ObservationIgnored private var completionNoticeTask: Task? + /// Set when a backup starts and cleared when it completes, so only a real backup earns the notice. + @ObservationIgnored private var isAwaitingBackupCompletion = false public init( service: any AutoBackupService, initialProviderStates: [ProviderDisplayState] = [], + completionNoticeDuration: Duration = .seconds(2), ) { self.service = service providerStatesWereSeeded = !initialProviderStates.isEmpty + self.completionNoticeDuration = completionNoticeDuration // Seed synchronously: the service's publishers do not replay, so // waiting for an emission would leave the screen stuck on defaults. status = service.status @@ -51,7 +61,7 @@ public final class AutoBackupViewModel { service.statusPublisher .sink { [weak self] newStatus in - self?.status = newStatus + self?.apply(newStatus) } .store(in: &cancellables) @@ -150,6 +160,49 @@ public final class AutoBackupViewModel { } } + /// Progress of the in-flight backup, or nil when none is running. + public var backupProgress: AutoBackupProgress? { + if case let .backingUp(progress) = status { + progress + } else { + nil + } + } + + private func apply(_ newStatus: AutoBackupStatus) { + status = newStatus + switch newStatus { + case .backingUp: + isAwaitingBackupCompletion = true + hideCompletionNotice() + case .completed where isAwaitingBackupCompletion: + isAwaitingBackupCompletion = false + showCompletionNotice() + case .error, .disabled, .idle: + isAwaitingBackupCompletion = false + hideCompletionNotice() + case .completed, .cleaningUp: + // A retention change alone cleans up and re-emits `.completed`; that is not a backup. + break + } + } + + private func showCompletionNotice() { + completionNoticeTask?.cancel() + showsBackupCompleteNotice = true + completionNoticeTask = Task { [weak self, completionNoticeDuration] in + try? await Task.sleep(for: completionNoticeDuration) + guard !Task.isCancelled else { return } + self?.showsBackupCompleteNotice = false + } + } + + private func hideCompletionNotice() { + completionNoticeTask?.cancel() + completionNoticeTask = nil + showsBackupCompleteNotice = false + } + public var statusIconName: String { switch status { case .disabled: diff --git a/Vault/Sources/VaultFeed/Storage/Backup/AutoBackup/AutoBackupProgress.swift b/Vault/Sources/VaultFeed/Storage/Backup/AutoBackup/AutoBackupProgress.swift new file mode 100644 index 000000000..ef4ee1b3f --- /dev/null +++ b/Vault/Sources/VaultFeed/Storage/Backup/AutoBackup/AutoBackupProgress.swift @@ -0,0 +1,55 @@ +import Foundation + +/// How far an in-flight backup has got. +/// +/// Progress is reported per phase; `fractionCompleted` folds the phases into a single `0...1` value +/// weighted by how long each phase typically takes, so a bar driven by it moves at a steady pace. +public struct AutoBackupProgress: Equatable, Sendable { + public enum Phase: Equatable, Sendable, CaseIterable { + /// Reading the vault out of storage. + case exporting + /// Encrypting the exported vault with the backup password. + case encrypting + /// Drawing the encrypted vault as QR codes into the PDF. Dominates the backup time. + case rendering + /// Writing the PDF to the storage provider. + case saving + + public var localizedTitle: String { + switch self { + case .exporting: "Exporting vault…" + case .encrypting: "Encrypting…" + case .rendering: "Rendering QR codes…" + case .saving: "Saving…" + } + } + + /// The slice of overall progress this phase occupies. + var overallRange: ClosedRange { + switch self { + case .exporting: 0 ... 0.05 + case .encrypting: 0.05 ... 0.15 + case .rendering: 0.15 ... 0.95 + case .saving: 0.95 ... 1 + } + } + } + + public let phase: Phase + /// Progress within `phase`, in `0...1`. + public let phaseFraction: Double + + public init(phase: Phase, phaseFraction: Double = 0) { + self.phase = phase + self.phaseFraction = min(max(phaseFraction, 0), 1) + } + + /// Overall progress in `0...1`. + public var fractionCompleted: Double { + let range = phase.overallRange + return range.lowerBound + (range.upperBound - range.lowerBound) * phaseFraction + } + + /// The state of a backup that has just begun. + public static let starting = AutoBackupProgress(phase: .exporting) +} diff --git a/Vault/Sources/VaultFeed/Storage/Backup/AutoBackup/AutoBackupService.swift b/Vault/Sources/VaultFeed/Storage/Backup/AutoBackup/AutoBackupService.swift index 260f8680e..89cdf6ed8 100644 --- a/Vault/Sources/VaultFeed/Storage/Backup/AutoBackup/AutoBackupService.swift +++ b/Vault/Sources/VaultFeed/Storage/Backup/AutoBackup/AutoBackupService.swift @@ -10,7 +10,7 @@ public enum AutoBackupStatus: Equatable, Sendable { case idle /// A backup is currently in progress. - case backingUp + case backingUp(AutoBackupProgress) /// Cleaning up old backups. case cleaningUp diff --git a/Vault/Sources/VaultFeed/Storage/Backup/AutoBackup/AutoBackupServiceImpl.swift b/Vault/Sources/VaultFeed/Storage/Backup/AutoBackup/AutoBackupServiceImpl.swift index edd2c7734..08215e063 100644 --- a/Vault/Sources/VaultFeed/Storage/Backup/AutoBackup/AutoBackupServiceImpl.swift +++ b/Vault/Sources/VaultFeed/Storage/Backup/AutoBackup/AutoBackupServiceImpl.swift @@ -48,6 +48,8 @@ public final class AutoBackupServiceImpl: AutoBackupService { private var debounceTask: Task? private var restoreTask: Task? + /// The most recently queued backup; the next one waits on it. See `enqueueBackup`. + private var backupChain: Task? private static let configKey = Key(VaultIdentifiers.AutoBackup.configuration) private static let debounceSeconds: UInt64 = 5 @@ -110,23 +112,22 @@ public final class AutoBackupServiceImpl: AutoBackupService { public func triggerBackupIfNeeded() async { guard configuration.isEnabled else { return } - guard let provider = selectedProvider else { return } - guard await provider.isConfigured else { return } - // Check if hash has changed since last backup - if let currentHash = dataModel.currentPayloadHash?.value.base64EncodedString(), - let lastHash = configuration.lastBackupHash, - currentHash == lastHash - { - // No changes since last backup - return + // The checks run once any in-flight backup has finished, so a change made during that backup + // is still picked up rather than compared against a stale hash. + await enqueueBackup { [weak self] in + guard let self else { return } + guard let provider = selectedProvider else { return } + guard await provider.isConfigured else { return } + guard hasChangesSinceLastBackup else { return } + await performBackup() } - - await performBackup() } public func forceBackup() async { - await performBackup() + await enqueueBackup { [weak self] in + await self?.performBackup() + } } public func saveProviderConfiguration() async { @@ -175,6 +176,30 @@ public final class AutoBackupServiceImpl: AutoBackupService { // MARK: - Private + private var hasChangesSinceLastBackup: Bool { + guard let currentHash = dataModel.currentPayloadHash?.value.base64EncodedString(), + let lastHash = configuration.lastBackupHash + else { return true } + return currentHash != lastHash + } + + /// Backups run one at a time, in the order requested. `performBackup` suspends several times, so + /// without this a debounced auto-backup could interleave with a manual one: two files written and + /// the two progress sequences fighting over `status`. + private func enqueueBackup(_ operation: @escaping @MainActor () async -> Void) async { + let previous = backupChain + let task = Task { @MainActor in + await previous?.value + await operation() + } + backupChain = task + await task.value + // Don't hold on to a finished task; a later caller would only wait on it needlessly. + if backupChain == task { + backupChain = nil + } + } + private func performBackup() async { guard let provider = selectedProvider else { setStatus(.error(.noProviderSelected)) @@ -196,17 +221,21 @@ public final class AutoBackupServiceImpl: AutoBackupService { return } - setStatus(.backingUp) + setStatus(.backingUp(.starting)) do { + // Export on the main actor; it is an in-memory read of the current vault. + let payload = try await dataModel.makeExport(userDescription: "Auto-backup") + // Generate PDF - let pdfData = try await generateBackupPDF(backupPassword: backupPassword) + let pdfData = try await renderBackupPDF(payload: payload, backupPassword: backupPassword) // Create filename with timestamp let timestamp = VaultDateFormatter(timezone: .current).formatForFileName(date: clock.currentDate) let filename = "vault-auto-backup-\(timestamp).pdf" // Write to provider + setStatus(.backingUp(.init(phase: .saving))) try await provider.write(data: pdfData, filename: filename) // Update configuration with last backup info @@ -237,11 +266,35 @@ public final class AutoBackupServiceImpl: AutoBackupService { } } - private func generateBackupPDF(backupPassword: DerivedEncryptionKey) async throws -> Data { - // Get vault payload - let payload = try await dataModel.makeExport(userDescription: "Auto-backup") + /// Encrypts and renders the backup off the main actor, mirroring each progress update into `status`. + /// + /// Progress goes through a stream rather than ad-hoc main-actor hops so that updates land in order and + /// every one of them is applied before this returns; a late `.backingUp` must never overwrite `.completed`. + private func renderBackupPDF( + payload: VaultApplicationPayload, + backupPassword: DerivedEncryptionKey, + ) async throws -> Data { + let (progress, continuation) = AsyncStream.makeStream( + of: AutoBackupProgress.self, + bufferingPolicy: .bufferingNewest(1), + ) + async let pdfData = encryptAndRender(payload: payload, backupPassword: backupPassword, progress: continuation) + for await update in progress { + setStatus(.backingUp(update)) + } + return try await pdfData + } + + /// Runs off the main actor: rendering every QR code twice is slow and would otherwise freeze the UI. + private nonisolated func encryptAndRender( + payload: VaultApplicationPayload, + backupPassword: DerivedEncryptionKey, + progress: AsyncStream.Continuation, + ) async throws -> Data { + defer { progress.finish() } // Encrypt the payload + progress.yield(.init(phase: .encrypting)) let encoder = EncryptedVaultEncoder(clock: clock, backupPassword: backupPassword) let encryptedVault = try encoder.encryptAndEncode(payload: payload) @@ -260,7 +313,10 @@ public final class AutoBackupServiceImpl: AutoBackupService { authorName: "Vault", ) - let pdfDocument = try pdfGenerator.makePDF(payload: exportPayload) + progress.yield(.init(phase: .rendering)) + let pdfDocument = try pdfGenerator.makePDF(payload: exportPayload) { fraction in + progress.yield(.init(phase: .rendering, phaseFraction: fraction)) + } guard let pdfData = pdfDocument.dataRepresentation() else { throw AutoBackupError.pdfGenerationFailed(reason: "Failed to get PDF data") diff --git a/Vault/Sources/VaultiOS/Views/Backup/AutoBackupView.swift b/Vault/Sources/VaultiOS/Views/Backup/AutoBackupView.swift index 02ac9a525..2bed725ef 100644 --- a/Vault/Sources/VaultiOS/Views/Backup/AutoBackupView.swift +++ b/Vault/Sources/VaultiOS/Views/Backup/AutoBackupView.swift @@ -204,21 +204,49 @@ struct AutoBackupView: View { // MARK: - Backup Now Section + /// The row is driven by the service status so that auto-triggered backups show the same + /// progress as a tap on the button: both are the same operation on the same screen. private var backupNowSection: some View { Section { - AsyncButton { - await viewModel.backupNow() - } label: { + if let progress = viewModel.backupProgress { FormRow(image: Image(systemName: "arrow.clockwise.icloud"), color: .accentColor) { - Text("Backup Now") + VStack(alignment: .leading, spacing: 6) { + Text(progress.phase.localizedTitle) + ProgressView(value: progress.fractionCompleted) + .animation(.linear(duration: 0.2), value: progress.fractionCompleted) + } } - } loading: { + } else if case .cleaningUp = viewModel.status { FormRow(image: Image(systemName: "arrow.clockwise.icloud"), color: .accentColor) { - ProgressView() + VStack(alignment: .leading, spacing: 6) { + Text("Cleaning up old backups…") + ProgressView(value: 1) + } + } + } else if viewModel.showsBackupCompleteNotice { + FormRow(image: Image(systemName: "checkmark.icloud"), color: .green) { + Text("Backup Complete") } + } else { + // Only disable while the tap is in flight: the status row replaces this button as soon as + // the service reports progress, so the button's own spinner would only flash. + AsyncButton( + action: { + await viewModel.backupNow() + }, + actionOptions: [.disableButton], + label: { + FormRow(image: Image(systemName: "arrow.clockwise.icloud"), color: .accentColor) { + Text("Backup Now") + } + }, + loading: { + EmptyView() + }, + ) } - .disabled(viewModel.isBackingUp) } + .animation(.default, value: viewModel.showsBackupCompleteNotice) } // MARK: - Helpers diff --git a/Vault/Tests/VaultBackupTests/VaultExportPDFDocumentRendererTests.swift b/Vault/Tests/VaultBackupTests/VaultExportPDFDocumentRendererTests.swift index 3e1f809c9..ec21b82a2 100644 --- a/Vault/Tests/VaultBackupTests/VaultExportPDFDocumentRendererTests.swift +++ b/Vault/Tests/VaultBackupTests/VaultExportPDFDocumentRendererTests.swift @@ -64,6 +64,22 @@ struct VaultExportPDFDocumentRendererTests { #expect(attacher.attachCallCount == 1) } + + @Test + func render_mapsFirstPassToFirstHalfAndSecondPassToSecondHalf() throws { + let renderer = PDFDocumentRendererMock() + renderer.renderHandler = { _, progress in + progress(0.5) + progress(1) + return PDFDocument() + } + let sut = makeSUT(documentRenderer: renderer) + var reported = [Double]() + + _ = try sut.render(document: anyExportPayload()) { reported.append($0) } + + #expect(reported == [0.25, 0.5, 0.75, 1]) + } } // MARK: - Helpers @@ -81,10 +97,25 @@ extension VaultExportPDFDocumentRendererTests { attacher: attacher, ) } + + private func anyExportPayload() -> VaultExportPayload { + VaultExportPayload( + encryptedVault: EncryptedVault( + version: "1.0.0", + data: Data(), + authentication: Data(), + encryptionIV: Data(), + keygenSalt: Data(), + keygenSignature: "my-signature", + ), + userDescription: "my vault", + created: Date(timeIntervalSince1970: 2000), + ) + } } private func makeRendererMock(pdfDocument: PDFDocument = PDFDocument()) -> PDFDocumentRendererMock { let renderer = PDFDocumentRendererMock() - renderer.renderHandler = { _ in pdfDocument } + renderer.renderHandler = { _, _ in pdfDocument } return renderer } diff --git a/Vault/Tests/VaultExportTests/PDFDataBlockDocumentRendererUnitTests.swift b/Vault/Tests/VaultExportTests/PDFDataBlockDocumentRendererUnitTests.swift index 41e6dd104..8f5564389 100644 --- a/Vault/Tests/VaultExportTests/PDFDataBlockDocumentRendererUnitTests.swift +++ b/Vault/Tests/VaultExportTests/PDFDataBlockDocumentRendererUnitTests.swift @@ -56,6 +56,52 @@ struct PDFDataBlockDocumentRendererUnitTests { try sut.render(document: anyDataBlockExportDocument()) } } + + @Test + func render_reportsProgressAfterEachImage() throws { + let rendererFactory = makeRendererFactory(renderer: UIGraphicsPDFRenderer(bounds: .init())) + let sut = makeSUT(rendererFactory: rendererFactory) + var reported = [Double]() + + _ = try sut.render(document: documentWithImages(counts: [3])) { reported.append($0) } + + #expect(reported == [1 / 3, 2 / 3, 1]) + } + + @Test + func render_reportsProgressAcrossMultipleDataBlocks() throws { + let rendererFactory = makeRendererFactory(renderer: UIGraphicsPDFRenderer(bounds: .init())) + let sut = makeSUT(rendererFactory: rendererFactory) + var reported = [Double]() + + _ = try sut.render(document: documentWithImages(counts: [2, 2])) { reported.append($0) } + + #expect(reported == [0.25, 0.5, 0.75, 1]) + } + + @Test + func render_reportsCompleteProgressForDocumentWithoutImages() throws { + let rendererFactory = makeRendererFactory(renderer: UIGraphicsPDFRenderer(bounds: .init())) + let sut = makeSUT(rendererFactory: rendererFactory) + var reported = [Double]() + + _ = try sut.render(document: anyDataBlockExportDocument()) { reported.append($0) } + + #expect(reported == [1]) + } + + @Test + func render_doesNotReportProgressWhenRenderingFails() { + let renderer = UIGraphicsPDFRendererStub() + renderer.pdfDataValue = makeInvalidPDFData() + let rendererFactory = makeRendererFactory(renderer: renderer) + let sut = makeSUT(rendererFactory: rendererFactory) + var reported = [Double]() + + _ = try? sut.render(document: documentWithImages(counts: [2])) { reported.append($0) } + + #expect(reported.isEmpty) + } } // MARK: - Helpers @@ -81,6 +127,16 @@ extension PDFDataBlockDocumentRendererUnitTests { ) } + /// One `.dataBlock` per entry in `counts`, each holding that many images. + private func documentWithImages(counts: [Int]) -> DataBlockDocument { + DataBlockDocument( + headerGenerator: DataBlockHeaderGeneratorMock(), + content: counts.map { count in + .dataBlock(Array(repeating: Data([0xFF]), count: count)) + }, + ) + } + private func makeInvalidPDFData() -> Data { // empty data consistutes an invalid PDF document - at least at the time of writing Data() diff --git a/Vault/Tests/VaultFeedTests/Presentation/AutoBackupViewModelTests.swift b/Vault/Tests/VaultFeedTests/Presentation/AutoBackupViewModelTests.swift index 329e8ab59..3b69bfe67 100644 --- a/Vault/Tests/VaultFeedTests/Presentation/AutoBackupViewModelTests.swift +++ b/Vault/Tests/VaultFeedTests/Presentation/AutoBackupViewModelTests.swift @@ -1,5 +1,6 @@ import Combine import Foundation +import TestHelpers import Testing @testable import VaultFeed @@ -35,9 +36,9 @@ struct AutoBackupViewModelTests { let service = AutoBackupServiceMock(status: .disabled, configuration: .init()) let sut = makeSUT(service: service) - service.statusPublisherSubject.send(.backingUp) + service.statusPublisherSubject.send(.backingUp(.starting)) - #expect(sut.status == .backingUp) + #expect(sut.status == .backingUp(.starting)) } @Test @@ -237,7 +238,7 @@ struct AutoBackupViewModelTests { let service = AutoBackupServiceMock(status: .disabled, configuration: .init()) let sut = makeSUT(service: service) - service.statusPublisherSubject.send(.backingUp) + service.statusPublisherSubject.send(.backingUp(.starting)) #expect(sut.isBackingUp) service.statusPublisherSubject.send(.cleaningUp) @@ -247,6 +248,110 @@ struct AutoBackupViewModelTests { #expect(!sut.isBackingUp) } + @Test + func backupProgress_isNilUnlessBackingUp() { + let service = AutoBackupServiceMock(status: .idle, configuration: .init()) + let sut = makeSUT(service: service) + + #expect(sut.backupProgress == nil) + + service.statusPublisherSubject.send(.cleaningUp) + #expect(sut.backupProgress == nil) + + service.statusPublisherSubject.send(.completed(Date())) + #expect(sut.backupProgress == nil) + } + + @Test + func backupProgress_reflectsPublishedProgress() { + let service = AutoBackupServiceMock(status: .idle, configuration: .init()) + let sut = makeSUT(service: service) + let progress = AutoBackupProgress(phase: .rendering, phaseFraction: 0.4) + + service.statusPublisherSubject.send(.backingUp(progress)) + + #expect(sut.backupProgress == progress) + } + + @Test + func statusPublisher_showsCompletionNoticeWhenBackupCompletes() { + let service = AutoBackupServiceMock(status: .idle, configuration: .init()) + let sut = makeSUT(service: service) + + service.statusPublisherSubject.send(.backingUp(.starting)) + #expect(!sut.showsBackupCompleteNotice) + + service.statusPublisherSubject.send(.completed(Date())) + #expect(sut.showsBackupCompleteNotice) + } + + @Test + func statusPublisher_keepsCompletionNoticeThroughCleanup() { + let service = AutoBackupServiceMock(status: .idle, configuration: .init()) + let sut = makeSUT(service: service) + + service.statusPublisherSubject.send(.backingUp(.starting)) + service.statusPublisherSubject.send(.completed(Date())) + service.statusPublisherSubject.send(.cleaningUp) + service.statusPublisherSubject.send(.completed(Date())) + + #expect(sut.showsBackupCompleteNotice) + } + + @Test + func statusPublisher_doesNotShowCompletionNoticeWithoutABackup() { + let service = AutoBackupServiceMock(status: .idle, configuration: .init()) + let sut = makeSUT(service: service) + + // Enabling the feature rests on the last backup date; changing retention cleans up and re-emits it. + service.statusPublisherSubject.send(.completed(Date())) + #expect(!sut.showsBackupCompleteNotice) + + service.statusPublisherSubject.send(.cleaningUp) + service.statusPublisherSubject.send(.completed(Date())) + #expect(!sut.showsBackupCompleteNotice) + } + + @Test + func statusPublisher_doesNotShowCompletionNoticeWhenBackupFails() { + let service = AutoBackupServiceMock(status: .idle, configuration: .init()) + let sut = makeSUT(service: service) + + service.statusPublisherSubject.send(.backingUp(.starting)) + service.statusPublisherSubject.send(.error(.writeFailed(reason: "disk full"))) + #expect(!sut.showsBackupCompleteNotice) + + // The failed attempt must not be credited to a later, unrelated `.completed`. + service.statusPublisherSubject.send(.completed(Date())) + #expect(!sut.showsBackupCompleteNotice) + } + + @Test + func statusPublisher_hidesCompletionNoticeWhenNewBackupStarts() { + let service = AutoBackupServiceMock(status: .idle, configuration: .init()) + let sut = makeSUT(service: service) + service.statusPublisherSubject.send(.backingUp(.starting)) + service.statusPublisherSubject.send(.completed(Date())) + #expect(sut.showsBackupCompleteNotice) + + service.statusPublisherSubject.send(.backingUp(.starting)) + + #expect(!sut.showsBackupCompleteNotice) + } + + @Test + func statusPublisher_hidesCompletionNoticeAfterDuration() async throws { + let service = AutoBackupServiceMock(status: .idle, configuration: .init()) + let sut = makeSUT(service: service, completionNoticeDuration: .milliseconds(1)) + service.statusPublisherSubject.send(.backingUp(.starting)) + service.statusPublisherSubject.send(.completed(Date())) + #expect(sut.showsBackupCompleteNotice) + + try await sut.waitForChange(to: \.showsBackupCompleteNotice, timeout: .seconds(5)) {} + + #expect(!sut.showsBackupCompleteNotice) + } + @Test func footerText_explainsFeatureWhenDisabled() { let service = AutoBackupServiceMock(status: .disabled, configuration: .init()) @@ -259,7 +364,7 @@ struct AutoBackupViewModelTests { func footerText_carriesLiveStatusWhenEnabled() { var configuration = AutoBackupConfiguration() configuration.isEnabled = true - let service = AutoBackupServiceMock(status: .backingUp, configuration: configuration) + let service = AutoBackupServiceMock(status: .backingUp(.starting), configuration: configuration) let sut = makeSUT(service: service) #expect(sut.footerText == sut.statusDescription) @@ -272,8 +377,13 @@ extension AutoBackupViewModelTests { private func makeSUT( service: AutoBackupServiceMock, initialProviderStates: [AutoBackupViewModel.ProviderDisplayState] = [], + completionNoticeDuration: Duration = .seconds(2), ) -> AutoBackupViewModel { - AutoBackupViewModel(service: service, initialProviderStates: initialProviderStates) + AutoBackupViewModel( + service: service, + initialProviderStates: initialProviderStates, + completionNoticeDuration: completionNoticeDuration, + ) } private func anyProviderState( diff --git a/Vault/Tests/VaultFeedTests/Storage/AutoBackupProgressTests.swift b/Vault/Tests/VaultFeedTests/Storage/AutoBackupProgressTests.swift new file mode 100644 index 000000000..f07e6a137 --- /dev/null +++ b/Vault/Tests/VaultFeedTests/Storage/AutoBackupProgressTests.swift @@ -0,0 +1,64 @@ +import Foundation +import TestHelpers +import Testing +@testable import VaultFeed + +struct AutoBackupProgressTests { + @Test + func fractionCompleted_isZeroAtStart() { + #expect(AutoBackupProgress.starting.fractionCompleted == 0) + } + + @Test + func fractionCompleted_isOneWhenSavingFinishes() { + let sut = AutoBackupProgress(phase: .saving, phaseFraction: 1) + + #expect(sut.fractionCompleted == 1) + } + + @Test + func fractionCompleted_mapsPhaseFractionIntoPhaseRange() { + let start = AutoBackupProgress(phase: .rendering, phaseFraction: 0) + let half = AutoBackupProgress(phase: .rendering, phaseFraction: 0.5) + let end = AutoBackupProgress(phase: .rendering, phaseFraction: 1) + + #expect(start.fractionCompleted < half.fractionCompleted) + #expect(half.fractionCompleted < end.fractionCompleted) + #expect(half.fractionCompleted.isApproximatelyEqual( + to: (start.fractionCompleted + end.fractionCompleted) / 2, + absoluteTolerance: 1e-9, + )) + } + + @Test + func fractionCompleted_isContinuousAcrossPhaseBoundaries() { + let phases = AutoBackupProgress.Phase.allCases + for (phase, next) in zip(phases, phases.dropFirst()) { + let endOfPhase = AutoBackupProgress(phase: phase, phaseFraction: 1) + let startOfNext = AutoBackupProgress(phase: next, phaseFraction: 0) + #expect( + endOfPhase.fractionCompleted.isApproximatelyEqual( + to: startOfNext.fractionCompleted, + absoluteTolerance: 1e-9, + ), + "\(phase) -> \(next)", + ) + } + } + + @Test + func fractionCompleted_neverDecreasesAcrossPhases() { + let phases = AutoBackupProgress.Phase.allCases + for (phase, next) in zip(phases, phases.dropFirst()) { + let startOfPhase = AutoBackupProgress(phase: phase, phaseFraction: 0) + let startOfNext = AutoBackupProgress(phase: next, phaseFraction: 0) + #expect(startOfPhase.fractionCompleted < startOfNext.fractionCompleted, "\(phase) -> \(next)") + } + } + + @Test + func init_clampsPhaseFraction() { + #expect(AutoBackupProgress(phase: .rendering, phaseFraction: -0.5).phaseFraction == 0) + #expect(AutoBackupProgress(phase: .rendering, phaseFraction: 1.5).phaseFraction == 1) + } +} diff --git a/Vault/Tests/VaultFeedTests/Storage/AutoBackupServiceImplTests.swift b/Vault/Tests/VaultFeedTests/Storage/AutoBackupServiceImplTests.swift index 0bd5df478..c6b95deb6 100644 --- a/Vault/Tests/VaultFeedTests/Storage/AutoBackupServiceImplTests.swift +++ b/Vault/Tests/VaultFeedTests/Storage/AutoBackupServiceImplTests.swift @@ -307,6 +307,118 @@ struct AutoBackupServiceImplTests { #expect(exportDescriptions == ["", "Auto-backup"]) } + @Test @LeakTracked + func forceBackup_publishesOrderedProgressEndingInCompleted() async throws { + let clock = EpochClockMock(currentTime: 100) + let provider = BackupStorageProviderStub(id: "test") + let dataModel = anyVaultDataModel() + try await dataModel.store(backupPassword: anyBackupPassword()) + await dataModel.setup() + let sut = try makeSUT(clock: clock, providers: [provider], dataModel: dataModel) + await Task.yield() + await sut.setRetention(.forever) + await sut.selectProvider(id: "test") + var statuses = [AutoBackupStatus]() + var bag = Set() + sut.statusPublisher.sink { statuses.append($0) }.store(in: &bag) + + await sut.forceBackup() + + let progress = statuses.compactMap { status -> AutoBackupProgress? in + if case let .backingUp(progress) = status { + progress + } else { + nil + } + } + #expect(statuses.first == .backingUp(.starting)) + #expect(statuses.last == .completed(clock.currentDate)) + #expect(statuses.count == progress.count + 1, "Only progress precedes completion") + let fractions = progress.map(\.fractionCompleted) + #expect(fractions == fractions.sorted(), "Progress never goes backwards") + #expect(progress.contains(.init(phase: .rendering, phaseFraction: 1)), "Rendering is reported complete") + #expect(progress.last == .init(phase: .saving), "Saving is the final phase") + } + + @Test @LeakTracked + func forceBackup_whileBackupInFlight_runsSequentially() async throws { + let provider = BackupStorageProviderStub(id: "test") + let dataModel = anyVaultDataModel() + try await dataModel.store(backupPassword: anyBackupPassword()) + await dataModel.setup() + let sut = try makeSUT(providers: [provider], dataModel: dataModel) + await Task.yield() + await sut.setRetention(.forever) + await sut.selectProvider(id: "test") + let firstWriteStarted = Pending.signal() + let releaseFirstWrite = Pending.signal() + provider.writeHandler = { _, _ in + await firstWriteStarted.fulfill() + try await releaseFirstWrite.wait() + } + + var statuses = [AutoBackupStatus]() + var bag = Set() + sut.statusPublisher.sink { statuses.append($0) }.store(in: &bag) + + let first = Task { await sut.forceBackup() } + try await firstWriteStarted.wait() + provider.writeHandler = nil + let second = Task { await sut.forceBackup() } + // Give the second backup every chance to run ahead of the first if it were allowed to. + try await Task.sleep(for: .milliseconds(100)) + await releaseFirstWrite.fulfill() + await first.value + await second.value + + #expect(provider.writeCallCount == 2) + let starts = statuses.indices.filter { statuses[$0] == .backingUp(.starting) } + let completions = statuses.indices.filter { + if case .completed = statuses[$0] { + true + } else { + false + } + } + #expect(starts.count == 2) + #expect(completions.count == 2) + #expect( + try #require(completions.first) < #require(starts.last), + "Second backup starts only after the first completes", + ) + } + + @Test @LeakTracked + func triggerBackupIfNeeded_whileBackupInFlight_rechecksHashAfterItFinishes() async throws { + let provider = BackupStorageProviderStub(id: "test") + let dataModel = anyVaultDataModel() + try await dataModel.store(backupPassword: anyBackupPassword()) + await dataModel.setup() + let sut = try makeSUT(providers: [provider], dataModel: dataModel) + await Task.yield() + await sut.setEnabled(true) + await sut.setRetention(.forever) + await sut.selectProvider(id: "test") + let firstWriteStarted = Pending.signal() + let releaseFirstWrite = Pending.signal() + provider.writeHandler = { _, _ in + await firstWriteStarted.fulfill() + try await releaseFirstWrite.wait() + } + + let first = Task { await sut.forceBackup() } + try await firstWriteStarted.wait() + provider.writeHandler = nil + let second = Task { await sut.triggerBackupIfNeeded() } + // Give the trigger every chance to run ahead of the first backup if it were allowed to. + try await Task.sleep(for: .milliseconds(100)) + await releaseFirstWrite.fulfill() + await first.value + await second.value + + #expect(provider.writeCallCount == 1, "Nothing changed while the first backup ran, so no second backup") + } + // MARK: - Cleanup Old Backups @Test @LeakTracked @@ -547,9 +659,13 @@ final class BackupStorageProviderStub: BackupStorageProvider, Sendable { func configure(with _: URL) async throws {} + /// Awaited after the write is recorded; lets a test hold a backup mid-flight. + var writeHandler: ((Data, String) async throws -> Void)? + func write(data: Data, filename: String) async throws { writeCallCount += 1 writtenData.append((data: data, filename: filename)) + try await writeHandler?(data, filename) } func listBackups() async throws -> [BackupFileInfo] { diff --git a/Vault/Tests/VaultiOSTests/AutoBackupViewSnapshotTests.swift b/Vault/Tests/VaultiOSTests/AutoBackupViewSnapshotTests.swift index 8f26a5ce7..016ada95e 100644 --- a/Vault/Tests/VaultiOSTests/AutoBackupViewSnapshotTests.swift +++ b/Vault/Tests/VaultiOSTests/AutoBackupViewSnapshotTests.swift @@ -65,6 +65,50 @@ struct AutoBackupViewSnapshotTests { assertSnapshot(of: sut, as: .image) } + @Test + func backingUp() async { + let sut = makeSUT( + dataModel: await passwordFetchedDataModel(), + viewModel: makeViewModel( + status: .backingUp(.init(phase: .rendering, phaseFraction: 0.4)), + configuration: enabledConfiguration(providerID: "icloud-drive"), + providerStates: [configuredProviderState()], + ), + ) + + assertSnapshot(of: sut, as: .image) + } + + @Test + func cleaningUp() async { + let sut = makeSUT( + dataModel: await passwordFetchedDataModel(), + viewModel: makeViewModel( + status: .cleaningUp, + configuration: enabledConfiguration(providerID: "icloud-drive"), + providerStates: [configuredProviderState()], + ), + ) + + assertSnapshot(of: sut, as: .image) + } + + @Test + func backupComplete() async { + let viewModel = makeViewModel( + status: .completed(Date(timeIntervalSince1970: 1_700_000_000)), + configuration: enabledConfiguration(providerID: "icloud-drive"), + providerStates: [configuredProviderState()], + ) + viewModel.showsBackupCompleteNotice = true + let sut = makeSUT( + dataModel: await passwordFetchedDataModel(), + viewModel: viewModel, + ) + + assertSnapshot(of: sut, as: .image) + } + @Test func statusError() async { let sut = makeSUT( diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/AutoBackupViewSnapshotTests/backingUp.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/AutoBackupViewSnapshotTests/backingUp.1.png new file mode 100644 index 000000000..c70127999 Binary files /dev/null and b/Vault/Tests/VaultiOSTests/__Snapshots__/AutoBackupViewSnapshotTests/backingUp.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/AutoBackupViewSnapshotTests/backupComplete.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/AutoBackupViewSnapshotTests/backupComplete.1.png new file mode 100644 index 000000000..5862b695e Binary files /dev/null and b/Vault/Tests/VaultiOSTests/__Snapshots__/AutoBackupViewSnapshotTests/backupComplete.1.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/AutoBackupViewSnapshotTests/cleaningUp.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/AutoBackupViewSnapshotTests/cleaningUp.1.png new file mode 100644 index 000000000..57e81ee06 Binary files /dev/null and b/Vault/Tests/VaultiOSTests/__Snapshots__/AutoBackupViewSnapshotTests/cleaningUp.1.png differ