diff --git a/ios/HouseScan/HouseScanApp.swift b/ios/HouseScan/HouseScanApp.swift index a136ba964..1818e887c 100644 --- a/ios/HouseScan/HouseScanApp.swift +++ b/ios/HouseScan/HouseScanApp.swift @@ -2,6 +2,14 @@ import SwiftUI @main struct HouseScanApp: App { + /// Runs once per process, before any window exists: deletes the share copies earlier runs + /// left, whose share sheets can't still be reading them. This run's copies live in its own + /// session folder (`SavedScansLocation.staging`), so they are never among them. + init() { + let staging = SavedScansLocation.staging + Task.detached(priority: .utility) { staging.removeOtherSessions() } + } + var body: some Scene { WindowGroup { Group { diff --git a/ios/HouseScan/Runtime/KeyframeStore.swift b/ios/HouseScan/Runtime/KeyframeStore.swift index b081627b1..77df294d3 100644 --- a/ios/HouseScan/Runtime/KeyframeStore.swift +++ b/ios/HouseScan/Runtime/KeyframeStore.swift @@ -57,13 +57,18 @@ final class KeyframeStore { /// listed here, before a newer store can exist, and deleted later off the main actor /// (`ScanFolderCleanup`), so a deletion that runs late can't take a newer scan's folder. init() { - let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] - let scans = caches.appending(path: "Scans", directoryHint: .isDirectory) + let scans = Self.scansRoot directory = scans.appending(path: UUID().uuidString, directoryHint: .isDirectory) try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) Self.delete(ScanFolderCleanup(root: scans, keeping: directory.lastPathComponent)) } + /// Caches/Scans: one folder per scan. Saved scans lists the completed ones here. + nonisolated static var scansRoot: URL { + FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + .appending(path: "Scans", directoryHint: .isDirectory) + } + func nextKeyframeIndex() -> Int { defer { nextIndex += 1 } return nextIndex diff --git a/ios/HouseScan/UI/SavedScans/ActivitySheet.swift b/ios/HouseScan/UI/SavedScans/ActivitySheet.swift new file mode 100644 index 000000000..70ec65a64 --- /dev/null +++ b/ios/HouseScan/UI/SavedScans/ActivitySheet.swift @@ -0,0 +1,23 @@ +import SwiftUI +import UIKit + +/// The system share sheet for one file, reporting when it is done with it. +/// +/// `ShareLink` needs its file to exist before it is tapped and never says when the sheet lets go +/// of it. Saved scans makes its copy only after the tap, and deletes the copy once the sheet +/// finishes, so it needs `UIActivityViewController`'s completion. +struct ActivitySheet: UIViewControllerRepresentable { + let file: URL + /// Called once the share completes or is cancelled; the file is no longer read after this. + let onFinish: @MainActor () -> Void + + func makeUIViewController(context: Context) -> UIActivityViewController { + let controller = UIActivityViewController(activityItems: [file], applicationActivities: nil) + // UIKit calls this on the main thread; the hop keeps it correct whatever the handler's + // declared isolation. + controller.completionWithItemsHandler = { _, _, _, _ in Task { @MainActor in onFinish() } } + return controller + } + + func updateUIViewController(_ controller: UIActivityViewController, context: Context) {} +} diff --git a/ios/HouseScan/UI/SavedScans/SavedScanRow.swift b/ios/HouseScan/UI/SavedScans/SavedScanRow.swift new file mode 100644 index 000000000..88dabc35a --- /dev/null +++ b/ios/HouseScan/UI/SavedScans/SavedScanRow.swift @@ -0,0 +1,97 @@ +import HouseScanKit +import SwiftUI + +/// One saved scan: when its bundle was saved, its size, a Practice tag when the stamp says so, and +/// its Share button. Nothing in the row reads as a result or a place on a wall. +struct SavedScanRow: View { + let scan: SavedScan + /// This scan's copy is being made for the share sheet. + let isPreparing: Bool + /// Some scan's copy is being made; Share waits for it. + let isBusy: Bool + let onShare: () -> Void + + @Environment(\.dynamicTypeSize) private var typeSize + + var body: some View { + let layout = typeSize.isAccessibilitySize + ? AnyLayout(VStackLayout(alignment: .leading, spacing: 12)) + : AnyLayout(HStackLayout(alignment: .center, spacing: 14)) + layout { + HStack(alignment: .top, spacing: 14) { + Image(systemName: scan.practice == true ? "wrench.and.screwdriver.fill" : "doc.zipper") + .font(.title3.weight(.semibold)) + .foregroundStyle(scan.practice == true ? Palette.ink : Palette.signal) + .frame(width: 44, height: 44) + .background(scan.practice == true ? Palette.caution : Palette.signal.opacity(0.1), in: .circle) + .accessibilityHidden(true) + details + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + shareButton + } + .padding(.vertical, 6) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("savedScan.\(scan.id)") + } + + private var details: some View { + VStack(alignment: .leading, spacing: 4) { + Text(scan.savedAt.map { SavedScansCopy.saved($0) } ?? SavedScansCopy.unknownTime) + .font(Typeface.hint.weight(.semibold)) + .foregroundStyle(Palette.ink) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 6) { + if scan.practice == true { + Text(SavedScansCopy.practice) + .font(Typeface.caption) + .foregroundStyle(Palette.ink) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Palette.caution, in: .capsule) + .accessibilityIdentifier("savedScan.practice") + } + Text(scan.byteCount, format: .byteCount(style: .file)) + .font(.footnote) + .fixedSize(horizontal: false, vertical: true) + .foregroundStyle(Palette.muted) + .monospacedDigit() + } + if scan.practice == true { + Text(SavedScansCopy.practiceDetail) + .font(.footnote) + .foregroundStyle(Palette.muted) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + /// The quiet button's look (`QuietButtonStyle`) at row size: a tinted capsule, 44 pt tall. + private var shareButton: some View { + Button(action: onShare) { + HStack(spacing: 6) { + if isPreparing { + ProgressView() + .controlSize(.small) + .tint(Palette.signalText) + } else { + Image(systemName: "square.and.arrow.up") + } + Text(SavedScansCopy.share) + } + .font(Typeface.caption) + .foregroundStyle(Palette.signalText) + .padding(.horizontal, 16) + .frame(minHeight: Metrics.minTarget) + .background(Palette.signal.opacity(0.1), in: .capsule) + .contentShape(.capsule) + .opacity(isBusy && !isPreparing ? 0.45 : 1) + } + .buttonStyle(PressableStyle()) + .disabled(isBusy) + .accessibilityLabel(SavedScansCopy.share) + .accessibilityHint("Opens the share sheet for this scan") + .accessibilityIdentifier("action.shareSavedScan") + } +} diff --git a/ios/HouseScan/UI/SavedScans/SavedScansButton.swift b/ios/HouseScan/UI/SavedScans/SavedScansButton.swift new file mode 100644 index 000000000..2d3b33b90 --- /dev/null +++ b/ios/HouseScan/UI/SavedScans/SavedScansButton.swift @@ -0,0 +1,33 @@ +import SwiftUI + +/// The way into Saved scans, in the onboarding's top bar. Quiet like the Skip beside it: the +/// onboarding's one prominent action stays starting a new scan. +/// +/// The button only asks for the sheet; the screen holds it (`savedScansSheet(isPresented:)`). The +/// top bar swaps layouts at accessibility text sizes, which makes a new button, and a sheet owned +/// by the button closed whenever the text size changed under it. +struct SavedScansButton: View { + let action: () -> Void + + var body: some View { + Button(action: action) { + Label(SavedScansCopy.entry, systemImage: "tray.full") + .font(Typeface.hint.weight(.semibold)) + .foregroundStyle(Palette.signalText) + .fixedSize(horizontal: false, vertical: true) + .frame(minWidth: Metrics.minTarget, minHeight: Metrics.minTarget) + .contentShape(.rect) + } + .buttonStyle(PressableStyle()) + .accessibilityIdentifier("action.savedScans") + } +} + +extension View { + /// Saved scans, presented from a view that stays put while the text size changes. + func savedScansSheet(isPresented: Binding) -> some View { + sheet(isPresented: isPresented) { + SavedScansSheet(catalog: SavedScansLocation.catalog(), staging: SavedScansLocation.staging) + } + } +} diff --git a/ios/HouseScan/UI/SavedScans/SavedScansCopy.swift b/ios/HouseScan/UI/SavedScans/SavedScansCopy.swift new file mode 100644 index 000000000..158394571 --- /dev/null +++ b/ios/HouseScan/UI/SavedScans/SavedScansCopy.swift @@ -0,0 +1,62 @@ +import Foundation +import HouseScanKit + +/// The words of Saved scans. A saved scan can be shared and nothing else: none of these lines may +/// suggest it can be reopened, continued or rechecked. +enum SavedScansCopy { + static let title = "Saved scans" + static let entry = "Saved scans" + + /// How many completed scans the phone keeps, in words, from the cleanup's own count. + static var kept: String { + let count = ScanFolderCleanup.defaultKeepCompleted + return count == 1 ? "your most recent finished scan" : "your \(count) most recent finished scans" + } + + static var intro: String { + "This phone keeps \(kept). You can send one to the House Scan team. A saved scan can't be reopened or continued." + } + + static let shareFooter = "Sharing sends the scan's photos and measurements. Nothing leaves your phone until you choose where to send it." + + static let emptyTitle = "No saved scans" + static var emptyDetail: String { + "When you finish a scan, it's saved here so you can share it later. This phone keeps \(kept)." + } + + static let share = "Share" + static let practice = "Practice" + /// Under a practice scan: a drawn sample stood in for the meter and its close-up, so the + /// meter's place and number describe no real meter. The wall walk itself was real. + static let practiceDetail = "Practice scan with a sample meter" + static let unknownTime = "Saved time unknown" + + static func saved(_ date: Date, now: Date = .now, calendar: Calendar = .current) -> String { + let time = date.formatted(date: .omitted, time: .shortened) + if calendar.isDate(date, inSameDayAs: now) { return "Saved today at \(time)" } + if let yesterday = calendar.date(byAdding: .day, value: -1, to: now), calendar.isDate(date, inSameDayAs: yesterday) { + return "Saved yesterday at \(time)" + } + let sameYear = calendar.component(.year, from: date) == calendar.component(.year, from: now) + let day = sameYear + ? date.formatted(.dateTime.month(.abbreviated).day()) + : date.formatted(.dateTime.month(.abbreviated).day().year()) + return "Saved \(day) at \(time)" + } + + static func problemTitle(_ error: SavedScanShareError) -> String { + switch error { + case .missing: "This scan is no longer on this phone" + case .damaged: "This scan can't be shared" + case .copyFailed: "Couldn't get the scan ready" + } + } + + static func problemDetail(_ error: SavedScanShareError) -> String { + switch error { + case .missing: "A newer scan replaced it, or the phone cleared space. The list now shows what's left." + case .damaged: "Its file was not saved completely. The list now shows the scans that can be shared." + case .copyFailed: "Your phone may be low on storage. Free up some space, then try again." + } + } +} diff --git a/ios/HouseScan/UI/SavedScans/SavedScansLocation.swift b/ios/HouseScan/UI/SavedScans/SavedScansLocation.swift new file mode 100644 index 000000000..508ba5bf9 --- /dev/null +++ b/ios/HouseScan/UI/SavedScans/SavedScansLocation.swift @@ -0,0 +1,24 @@ +import Foundation +import HouseScanKit + +/// Where Saved scans reads scans from and stages the copies it shares. +enum SavedScansLocation { + /// The scan store's folder (`KeyframeStore.scansRoot`). Debug builds take + /// `-savedScansRoot ` instead, so a UI test can list synthetic scans it wrote; the + /// argument only changes which folder is listed, never what the store writes or deletes. + static func catalog(arguments: [String] = ProcessInfo.processInfo.arguments) -> SavedScanCatalog { + #if DEBUG + if let index = arguments.firstIndex(of: "-savedScansRoot"), index + 1 < arguments.count { + return SavedScanCatalog(root: URL(fileURLWithPath: arguments[index + 1], isDirectory: true)) + } + #endif + return SavedScanCatalog(root: KeyframeStore.scansRoot) + } + + /// In the app's temporary folder, apart from the scan folders, so the store's cleanup never + /// sees a copy and a copy never outlives the system's own tmp purge. One session per process: + /// a static is made once. + static let staging = SavedScanStaging( + root: FileManager.default.temporaryDirectory.appending(path: "SavedScanShares", directoryHint: .isDirectory), + session: UUID().uuidString) +} diff --git a/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift b/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift new file mode 100644 index 000000000..c8add1fb3 --- /dev/null +++ b/ios/HouseScan/UI/SavedScans/SavedScansSheet.swift @@ -0,0 +1,198 @@ +import HouseScanKit +import SwiftUI + +/// The completed scans still on the phone, each with a Share button. Opened from the onboarding. +/// +/// Export only: a saved scan is not reopened, its result is not shown, and nothing is sent until +/// the homeowner taps Share and then picks a destination in the system share sheet. Share copies +/// the bundle out of the scan folder first (`SavedScanStaging`), so a cleanup can't delete it +/// while the sheet reads it, and deletes the copy when the sheet closes. +struct SavedScansSheet: View { + let catalog: SavedScanCatalog + let staging: SavedScanStaging + + /// Nil until the first read finishes, so an empty list is never shown before it is known. + @State private var scans: [SavedScan]? + @State private var preparing: SavedScan.ID? + @State private var sharing: StagedCopy? + /// The copy being shared, kept past `sharing` going nil so `finishSharing` can delete it. + @State private var lastShared: URL? + @State private var problem: SavedScanShareError? + @State private var showsProblem = false + @Environment(\.dismiss) private var dismiss + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + struct StagedCopy: Identifiable { + let url: URL + var id: URL { url } + } + + // Built like the app's light screens rather than a system list in a half-height sheet. In + // that sheet the onboarding showed through the glass behind the text (contrast), the list's + // header and footer and the navigation bar's Done didn't fully follow Dynamic Type, and large + // text ran off the sheet's bottom (hosted run 37051620836). + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + header + content + } + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 32) + .frame(maxWidth: 560) + .frame(maxWidth: .infinity) + } + .scrollBounceBehavior(.basedOnSize) + .background(Palette.canvas.ignoresSafeArea()) + .presentationBackground(Palette.canvas) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + // A copy being made belongs to the share sheet that follows it; closing now would leave + // the copy with no sheet to delete it. + .interactiveDismissDisabled(preparing != nil) + .task { await open() } + .sheet(item: $sharing, onDismiss: finishSharing) { copy in + ActivitySheet(file: copy.url) { sharing = nil } + .presentationDetents([.medium, .large]) + .ignoresSafeArea() + } + .alert( + problem.map(SavedScansCopy.problemTitle) ?? "", + isPresented: $showsProblem, + presenting: problem + ) { _ in + Button("OK") {} + } message: { error in + Text(SavedScansCopy.problemDetail(error)) + } + .accessibilityIdentifier("savedScans") + } + + private var header: some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(SavedScansCopy.title) + .font(Typeface.instruction) + .foregroundStyle(Palette.ink) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityAddTraits(.isHeader) + Button("Done") { dismiss() } + .font(Typeface.hint.weight(.semibold)) + .foregroundStyle(Palette.signalText) + .frame(minWidth: Metrics.minTarget, minHeight: Metrics.minTarget) + .contentShape(.rect) + .buttonStyle(PressableStyle()) + .disabled(preparing != nil) + .opacity(preparing != nil ? 0.45 : 1) + .accessibilityIdentifier("action.closeSavedScans") + } + } + + @ViewBuilder + private var content: some View { + if let scans { + if scans.isEmpty { + VStack(spacing: 12) { + Image(systemName: "tray") + .font(.system(size: 44, weight: .regular)) + .foregroundStyle(Palette.muted) + .accessibilityHidden(true) + Text(SavedScansCopy.emptyTitle) + .font(Typeface.sectionTitle) + .foregroundStyle(Palette.ink) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + Text(SavedScansCopy.emptyDetail) + .font(Typeface.hint) + .foregroundStyle(Palette.muted) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 12) + .padding(.top, 48) + .frame(maxWidth: .infinity) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("savedScans.empty") + } else { + Text(SavedScansCopy.intro) + .font(Typeface.hint) + .foregroundStyle(Palette.muted) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("savedScans.intro") + VStack(spacing: 0) { + ForEach(scans) { scan in + if scan.id != scans.first?.id { + Divider().padding(.leading, 58) + } + SavedScanRow(scan: scan, isPreparing: preparing == scan.id, isBusy: preparing != nil) { + share(scan) + } + .padding(.vertical, 8) + } + } + .padding(.horizontal, 16) + .background(Palette.surface, in: .rect(cornerRadius: Metrics.cardRadius, style: .continuous)) + Label(SavedScansCopy.shareFooter, systemImage: "lock.fill") + .font(.footnote) + .foregroundStyle(Palette.muted) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("savedScans.privacy") + } + } + // Until the first read finishes there is nothing: it takes milliseconds, and a spinner + // would only flash. + } + + /// Copies earlier runs left go too, in case launch's pass hasn't run yet; this run's own copy + /// is deleted when its share sheet closes, so nothing here can touch a share in progress. + private func open() async { + let catalog = catalog, staging = staging + let found = await Task.detached(priority: .userInitiated) { () -> [SavedScan] in + staging.removeOtherSessions() + return catalog.scans() + }.value + scans = found + } + + private func reload() async { + let catalog = catalog + let found = await Task.detached(priority: .userInitiated) { catalog.scans() }.value + withAnimation(reduceMotion ? nil : Motion.settle) { scans = found } + } + + private func share(_ scan: SavedScan) { + guard preparing == nil else { return } + preparing = scan.id + let staging = staging + Task { + let staged = await Task.detached(priority: .userInitiated) { () -> Result in + do throws(SavedScanShareError) { + return .success(try staging.stage(scan)) + } catch { + return .failure(error) + } + }.value + preparing = nil + switch staged { + case .success(let url): + lastShared = url + sharing = StagedCopy(url: url) + case .failure(let error): + problem = error + showsProblem = true + // A missing or damaged scan drops out of the list; a failed copy leaves it to retry. + if error != .copyFailed { await reload() } + } + } + } + + /// The share sheet is closed, by finishing, cancelling or a swipe: its copy goes. + private func finishSharing() { + guard let copy = lastShared else { return } + lastShared = nil + let staging = staging + Task.detached(priority: .utility) { staging.remove(copy) } + } + +} diff --git a/ios/HouseScan/UI/Screens/OnboardingScreen.swift b/ios/HouseScan/UI/Screens/OnboardingScreen.swift index 5a935c8f0..bbf45605a 100644 --- a/ios/HouseScan/UI/Screens/OnboardingScreen.swift +++ b/ios/HouseScan/UI/Screens/OnboardingScreen.swift @@ -7,6 +7,7 @@ struct OnboardingScreen: View { let actions: any ScanActions @State private var page = 0 + @State private var showsSavedScans = false @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.dynamicTypeSize) private var typeSize @@ -49,13 +50,16 @@ struct OnboardingScreen: View { Group { if typeSize.isAccessibilitySize { // The replay badge, Practice label and Skip squeezed the label into - // single syllables at AX5. Give Practice its own full-width row. + // single syllables at AX5. Give Practice its own full-width row, and Saved + // scans too. VStack(alignment: .leading, spacing: 4) { HStack { ModeBadge(isReplay: state.isReplay, isAutopilot: state.isAutopilot) Spacer() skipButton } + SavedScansButton { showsSavedScans = true } + .frame(maxWidth: .infinity, alignment: .leading) DeveloperOptionsButton() .frame(maxWidth: .infinity, alignment: .leading) } @@ -64,6 +68,7 @@ struct OnboardingScreen: View { ModeBadge(isReplay: state.isReplay, isAutopilot: state.isAutopilot) DeveloperOptionsButton() Spacer() + SavedScansButton { showsSavedScans = true } skipButton } } @@ -122,6 +127,7 @@ struct OnboardingScreen: View { .accessibilityIdentifier("onboarding.footer") } .background(Palette.canvas.ignoresSafeArea()) + .savedScansSheet(isPresented: $showsSavedScans) } @ViewBuilder diff --git a/ios/HouseScan/UI/Screens/UploadingScreen.swift b/ios/HouseScan/UI/Screens/UploadingScreen.swift index 9cf8b9d92..9c535a3cf 100644 --- a/ios/HouseScan/UI/Screens/UploadingScreen.swift +++ b/ios/HouseScan/UI/Screens/UploadingScreen.swift @@ -48,11 +48,13 @@ struct UploadingScreen: View { switch state.upload { case .failed: VStack(spacing: 16) { - // Packets stay on disk, but the app has no way to reopen them after - // relaunch. Offer export only once the shareable packet exists. + // The app can't reopen a scan after relaunch, so retrying needs it open. + // Once packaged, its bundle stays among the kept scans, and Saved scans on + // the first screen can still share it. The Share scan button below covers + // sharing now. Text(state.shareableScan == nil ? "Keep this scan open to try again. House Scan can't reopen it after you close the app." - : "Keep this scan open to try again, or use Share scan to save a copy. House Scan can't reopen it after you close the app.") + : "Keep this scan open to try again. If you close the app, you can still share its saved file from Saved scans on the first screen.") .font(Typeface.hint) .foregroundStyle(Palette.muted) .multilineTextAlignment(.center) diff --git a/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/PacketArchiveCheck.swift b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/PacketArchiveCheck.swift new file mode 100644 index 000000000..8db0606e8 --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/PacketArchiveCheck.swift @@ -0,0 +1,120 @@ +import Foundation + +/// Whether a file is a zip written to the end in the capture packet's layout, read from its two +/// ends without unpacking it. +/// +/// `scan.zip` is written in place (`ZipWriter.write`), so a scan quit or crashed during the write, +/// or a bundle still being written when a list is made, leaves a partial file under the name that +/// marks a completed scan. The zip's last record (the end of central directory) is written last, +/// so a file whose tail holds that record, pointing at a central directory that ends right before +/// it, was written to the end. The first entry must be `manifest.json`, which the packet always +/// writes first (`KeyframeStore.zipPacket`), so a zip that doesn't start with it is not taken for a +/// scan. +/// The central directory's first record must name the same entry at offset 0, which ties the +/// footer, the directory and the first entry together. A symbolic link is refused, so a copy of +/// the file is always the bytes themselves. +/// +/// This detects a bundle that wasn't written to the end. It does not establish that a file is a +/// capture packet: any zip whose first entry is named `manifest.json` passes, whatever the +/// manifest says, and the entries' contents and CRCs are not read. Only the app writes into its +/// scan folders, which is what makes the name a sufficient marker there. +/// +/// The check reads at most 64 KiB from each end, whatever the archive's size. It does not check +/// each entry's CRC: that would read every photo, and a write that reached the last record wrote +/// every entry before it. +public enum PacketArchiveCheck { + public enum Failure: Error, Equatable, Sendable { + /// The file can't be opened or read. + case unreadable + /// The end of central directory is missing or points somewhere else: a partial write. + case incomplete + /// A whole zip, but not a capture packet: its first entry isn't manifest.json. + case notAPacket + /// A symbolic link, a folder or anything else that isn't a plain file. + case notAFile + } + + static let firstEntryName = "manifest.json" + private static let localHeaderSignature: UInt32 = 0x0403_4B50 + private static let centralHeaderSignature: UInt32 = 0x0201_4B50 + private static let endSignature: UInt32 = 0x0605_4B50 + private static let localHeaderSize = 30 + private static let endRecordSize = 22 + private static let centralHeaderSize = 46 + + /// Returns the archive's size in bytes when it is a whole capture packet. + public static func verify(_ url: URL) throws(Failure) -> Int64 { + // attributesOfItem doesn't follow a final symbolic link. + guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) else { throw .unreadable } + guard attributes[.type] as? FileAttributeType == .typeRegular else { throw .notAFile } + guard let handle = try? FileHandle(forReadingFrom: url) else { throw .unreadable } + defer { try? handle.close() } + do { + let size = try handle.seekToEnd() + guard size >= UInt64(localHeaderSize + endRecordSize) else { throw Failure.incomplete } + + // The first local header, and its name. + try handle.seek(toOffset: 0) + let header = try handle.read(upToCount: localHeaderSize) ?? Data() + guard header.count == localHeaderSize, header.le(UInt32.self, at: 0) == localHeaderSignature else { + throw Failure.notAPacket + } + let nameLength = Int(header.le(UInt16.self, at: 26)) + let name = try handle.read(upToCount: nameLength) ?? Data() + guard name == Data(firstEntryName.utf8) else { throw Failure.notAPacket } + + // The end record sits in the last 22 bytes plus its comment, at most 65535 bytes. + let tailLength = min(size, UInt64(endRecordSize + 0xFFFF)) + let tailStart = size - tailLength + try handle.seek(toOffset: tailStart) + let tail = try handle.read(upToCount: Int(tailLength)) ?? Data() + guard tail.count == Int(tailLength), let end = endRecord(in: tail) else { throw Failure.incomplete } + let endOffset = tailStart + UInt64(end) + let entries = tail.le(UInt16.self, at: end + 10) + let directorySize = UInt64(tail.le(UInt32.self, at: end + 12)) + let directoryOffset = UInt64(tail.le(UInt32.self, at: end + 16)) + guard tail.le(UInt16.self, at: end + 4) == 0, tail.le(UInt16.self, at: end + 6) == 0, + tail.le(UInt16.self, at: end + 8) == entries, entries > 0, + directoryOffset + directorySize == endOffset else { throw Failure.incomplete } + + // The central directory starts where the record says, and its first record is the + // manifest at offset 0. + guard directorySize >= UInt64(centralHeaderSize) * UInt64(entries) else { throw Failure.incomplete } + try handle.seek(toOffset: directoryOffset) + let first = try handle.read(upToCount: centralHeaderSize) ?? Data() + guard first.count == centralHeaderSize, first.le(UInt32.self, at: 0) == centralHeaderSignature else { throw Failure.incomplete } + let firstName = try handle.read(upToCount: Int(first.le(UInt16.self, at: 28))) ?? Data() + guard firstName == Data(firstEntryName.utf8), first.le(UInt32.self, at: 42) == 0 else { throw Failure.incomplete } + return Int64(size) + } catch let failure as Failure { + throw failure + } catch { + throw .unreadable + } + } + + /// The offset in `tail` of the end record whose comment runs exactly to the end, searching + /// from the end; nil when there is none. + private static func endRecord(in tail: Data) -> Int? { + var offset = tail.count - endRecordSize + while offset >= 0 { + if tail.le(UInt32.self, at: offset) == endSignature, + Int(tail.le(UInt16.self, at: offset + 20)) == tail.count - offset - endRecordSize { + return offset + } + offset -= 1 + } + return nil + } +} + +private extension Data { + /// A little-endian integer at `offset` from the start of this data. + func le(_: T.Type, at offset: Int) -> T { + var value: T = 0 + for byte in 0...size { + value |= T(self[startIndex + offset + byte]) << (8 * byte) + } + return value + } +} diff --git a/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScan.swift b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScan.swift new file mode 100644 index 000000000..84ce3c49d --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScan.swift @@ -0,0 +1,45 @@ +import Foundation + +/// A completed scan still on the phone, as the Saved scans list shows it: its bundle and what the +/// files say about it. Nothing here is read from the capture itself, so a field the files don't +/// carry is nil rather than guessed. +public struct SavedScan: Sendable, Equatable, Identifiable { + /// The scan folder's name, a UUID the store made. Not shown to the homeowner. + public let id: String + /// The bundle Share scan offers, `/scan.zip`. + public let archive: URL + /// When the bundle was last written: the file's modification time. The bundle is written when + /// the scan is uploaded and again on a retry, so this is when the scan was saved, not when its + /// photos were taken. Nil when the file system gives no date. + public let savedAt: Date? + /// The bundle's size in bytes. + public let byteCount: Int64 + /// `scan-stamp.json`'s `practice`: true for a practice scan, false for a real one, nil when + /// the folder has no readable stamp. Nil is "unknown", never "real". + public let practice: Bool? + + public init(id: String, archive: URL, savedAt: Date?, byteCount: Int64, practice: Bool?) { + self.id = id + self.archive = archive + self.savedAt = savedAt + self.byteCount = byteCount + self.practice = practice + } + + /// The name the shared copy carries, so a recipient with several scans can tell them apart: + /// "House Scan 2026-10-02 14.05.zip" in the phone's time zone, "House Scan practice …" for a + /// practice scan, and plain "House Scan.zip" without a date. No colons, which Files and + /// Windows refuse. + public func shareFileName(timeZone: TimeZone = .current) -> String { + var parts = ["House Scan"] + if practice == true { parts.append("practice") } + if let savedAt { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = timeZone + formatter.dateFormat = "yyyy-MM-dd HH.mm" + parts.append(formatter.string(from: savedAt)) + } + return parts.joined(separator: " ") + ".zip" + } +} diff --git a/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift new file mode 100644 index 000000000..a64b4cce5 --- /dev/null +++ b/ios/HouseScanKit/Sources/HouseScanKit/SavedScans/SavedScanCatalog.swift @@ -0,0 +1,146 @@ +import Foundation + +/// The completed scans a homeowner can still share, read from the scan folders on disk. +/// +/// A scan counts by the same rule the cleanup keeps it by (`ScanFolderCleanup`): a folder whose +/// `scan.zip` is a whole capture packet (`PacketArchiveCheck`), newest bundle first, at most the +/// `defaultKeepCompleted` the cleanup keeps. Listing the cleanup's own choice means the list never +/// shows an older scan that the next cleanup is about to delete. Each listed bundle is checked +/// again here, since it can change between the cleanup's reading and this one. +/// +/// The list is a reading of the folders at one moment, and a scan can still go after it. A new +/// scan's cleanup deletes what its store listed when it was made, a little later and off the main +/// actor, so a bundle an earlier scan finished writing in between can be listed here and then +/// deleted. Sharing it then reports it missing (`SavedScanShareError.missing`) rather than +/// failing silently; listing exactly the cleanup's pending set would mean sharing state with +/// the store's cleanup, which this feature leaves as it is. +/// +/// Everything here reads files synchronously; the app calls it off the main actor. +public struct SavedScanCatalog: Sendable { + /// The folder holding one folder per scan, Caches/Scans in the app. + public let root: URL + /// The largest stamp read for its `practice` flag. A stamp is a few hundred bytes; anything + /// past this is not one. + static let stampByteLimit = 64 * 1024 + + public init(root: URL) { + self.root = root + } + + /// The completed scans, newest first. Folders without a whole bundle are left out. + public func scans() -> [SavedScan] { + // No folder is the scan in use here: the cleanup's `keeping` excludes a name, and the + // empty name matches no folder, so every folder competes by the cleanup's rule. + ScanFolderCleanup(root: root, keeping: "").keptCompleted.compactMap(Self.scan(in:)) + } + + static func scan(in folder: URL) -> SavedScan? { + // The scan folders are the app's own; a link among them is not a scan. + guard (try? FileManager.default.attributesOfItem(atPath: folder.path))?[.type] as? FileAttributeType == .typeDirectory else { return nil } + let archive = folder.appending(path: ScanFolderCleanup.bundleName) + guard let size = try? PacketArchiveCheck.verify(archive) else { return nil } + let attributes = try? FileManager.default.attributesOfItem(atPath: archive.path) + return SavedScan( + id: folder.lastPathComponent, archive: archive, + savedAt: attributes?[.modificationDate] as? Date, + byteCount: size, + practice: practice(stamp: folder.appending(path: ScanStamp.fileName))) + } + + /// The stamp's `practice` flag when the stamp is readable and holds a JSON boolean there; + /// otherwise nil. A missing, oversized or corrupt stamp says nothing about the scan. + static func practice(stamp: URL) -> Bool? { + // Not a link (attributesOfItem doesn't follow one), and never more read than the limit. + guard (try? FileManager.default.attributesOfItem(atPath: stamp.path))?[.type] as? FileAttributeType == .typeRegular, + let handle = try? FileHandle(forReadingFrom: stamp) else { return nil } + defer { try? handle.close() } + guard let data = try? handle.read(upToCount: stampByteLimit + 1), data.count <= stampByteLimit else { return nil } + struct Flag: Decodable { let practice: Bool } + return (try? JSONDecoder().decode(Flag.self, from: data))?.practice + } +} + +/// Why a saved scan couldn't be handed to the share sheet. +public enum SavedScanShareError: Error, Equatable, Sendable { + /// The bundle is no longer on the phone: a newer scan's cleanup deleted it, or the system + /// cleared the app's caches. + case missing + /// The bundle is there but not a whole capture packet. + case damaged + /// The copy for the share sheet couldn't be made, for example with the phone's storage full. + case copyFailed +} + +/// Copies of saved scans made for the share sheet, each in a folder of its own under `root`. +/// +/// The share sheet reads the file it is given for as long as it is open, and a scan's own bundle +/// can be deleted under it by a new scan's cleanup. A copy outside the scan folders can't be. +/// The copy can be a full copy of the bundle's bytes, so it can fail on a full disk +/// (`SavedScanShareError.copyFailed`), and is deleted as soon as the sheet closes. Only this type +/// creates or deletes anything under `root`; the scan folders are never written. +public struct SavedScanStaging: Sendable { + public let root: URL + /// This app run's folder under `root`. Each run stages into its own, so a launch can delete + /// the copies earlier runs left without judging by time, which a clock change would upset. + public let session: String + + public init(root: URL, session: String = UUID().uuidString) { + self.root = root + self.session = session + } + + private var sessionRoot: URL { + root.appending(path: session, directoryHint: .isDirectory) + } + + /// A whole copy of `scan`'s bundle under `root`, named `scan.shareFileName()`. + public func stage(_ scan: SavedScan) throws(SavedScanShareError) -> URL { + let files = FileManager.default + let source = scan.archive + func failure(_ check: PacketArchiveCheck.Failure) -> SavedScanShareError { + guard files.fileExists(atPath: source.path) else { return .missing } + return check == .unreadable ? .copyFailed : .damaged + } + do { + _ = try PacketArchiveCheck.verify(source) + } catch { + throw failure(error) + } + let folder = sessionRoot.appending(path: UUID().uuidString, directoryHint: .isDirectory) + let copy = folder.appending(path: scan.shareFileName()) + do { + try files.createDirectory(at: folder, withIntermediateDirectories: true) + try files.copyItem(at: source, to: copy) + } catch { + try? files.removeItem(at: folder) + throw files.fileExists(atPath: source.path) ? .copyFailed : .missing + } + // The bundle could have been replaced or cut short while it was copied. + do { + _ = try PacketArchiveCheck.verify(copy) + } catch { + try? files.removeItem(at: folder) + throw failure(error) + } + return copy + } + + /// Deletes a copy `stage` made, with its folder. A URL that isn't one of this staging's copies + /// is left alone. + public func remove(_ copy: URL) { + let folder = copy.deletingLastPathComponent().standardizedFileURL + guard folder.deletingLastPathComponent().path == sessionRoot.standardizedFileURL.path else { return } + try? FileManager.default.removeItem(at: folder) + } + + /// Deletes the copies every other app run made, leaving this run's. At launch that is every + /// copy an earlier run left (a share sheet open when the app was killed never reports back), + /// while a share this run starts keeps its copy. + public func removeOtherSessions() { + let files = FileManager.default + guard let sessions = try? files.contentsOfDirectory(atPath: root.path) else { return } + for name in sessions where name != session { + try? files.removeItem(at: root.appending(path: name)) + } + } +} diff --git a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift index 3bd3bbd3b..3335f8361 100644 --- a/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift +++ b/ios/HouseScanKit/Sources/HouseScanKit/Scene/ScanFolderCleanup.swift @@ -2,49 +2,142 @@ import Foundation /// The old scan folders a new scan's store deletes, and the ones it keeps. /// -/// Kept: the new scan's own folder, and the `keepCompleted` most recent completed scans (those -/// with a packaged bundle, `bundleName`, newest bundle first), until newer completed scans push -/// them out. Every other entry goes: scans never packaged, and older completed ones. A scan the -/// homeowner finished survives the app being quit and relaunched, which makes a new store; before, -/// every relaunch deleted it, and a field run's export was lost that way. +/// Kept: the new scan's own folder, the `keepCompleted` most recent completed scans (those whose +/// bundle, `bundleName`, is a whole capture packet, newest bundle first) until newer completed +/// scans push them out, and every folder whose bundle is there but not whole. Every other entry +/// goes: scans never packaged, and older completed ones. A scan the homeowner finished survives +/// the app being quit and relaunched, which makes a new store; before, every relaunch deleted it, +/// and a field run's export was lost that way. +/// +/// A bundle counts as completed only when `PacketArchiveCheck` finds it written to the end. The +/// zip is written in place, so a quit or crash during the write leaves a partial `scan.zip`. When +/// the file's presence alone counted, that partial bundle took one of the kept places and an +/// intact older scan was deleted to make room. +/// +/// A partial or unreadable bundle takes no kept place, and its folder is not deleted, however old +/// it is. Nothing here can tell an abandoned partial from one whose writer is still going: Start +/// over makes a new store while the previous scan's bundle may still be being written, and a +/// suspended phone can pause a write for any length of time, so the file's age proves nothing. +/// Deleting it could take the folder from under that write. The cost is that an abandoned partial +/// bundle stays on the phone. Reclaiming it needs a way to prove no writer holds it, which this +/// type does not have. /// /// The folders to delete are listed when the new scan's folder is made, before any newer scan /// can exist, and only those are deleted, whenever the deletion gets to run. Listing at deletion /// time instead let a cleanup that ran late delete a scan started after it: store A is made, /// Start over makes B, and A's cleanup then found B in the listing and deleted the scan in use. public struct ScanFolderCleanup: Sendable { - /// The file whose presence marks a completed scan: the bundle Share scan offers. + /// The bundle Share scan offers. A scan is completed when this file is a whole capture packet + /// (`PacketArchiveCheck`); the file being there is not enough. public static let bundleName = "scan.zip" /// The most recent completed scan, and one more: a homeowner who starts another scan still /// has the one before it. Each is a few tens of megabytes of photos. public static let defaultKeepCompleted = 2 - /// The folders beside the kept one when the cleanup was made, to delete. public let obsolete: [URL] /// The completed scans kept, newest first. public let keptCompleted: [URL] + /// Folders whose bundle is there but not whole: left alone, holding no kept place. + public let incompleteBundles: [URL] + /// What each obsolete entry was when listed. `run` deletes an entry only if it still is. + private let listedAs: [URL: Entry] /// Lists `root` now. An unreadable root lists nothing. public init(root: URL, keeping kept: String, keepCompleted: Int = defaultKeepCompleted) { let files = FileManager.default let names = ((try? files.contentsOfDirectory(atPath: root.path)) ?? []).filter { $0 != kept }.sorted() - let completed = names.compactMap { name -> (name: String, date: Date)? in - let bundle = root.appending(path: name).appending(path: Self.bundleName) - guard let attributes = try? files.attributesOfItem(atPath: bundle.path) else { return nil } - return (name, attributes[.modificationDate] as? Date ?? .distantPast) + var completed: [(name: String, date: Date)] = [] + var incomplete: [String] = [] + var entries: [String: Entry] = [:] + for name in names { + let entry = Self.entry(at: root.appending(path: name)) + entries[name] = entry + switch entry { + case .file, .folder(.absent): + continue + case .folder(.complete(let date, _)): + completed.append((name, date)) + case .unjudged, .folder(.incomplete): + incomplete.append(name) + } + } + completed.sort { ($0.date, $0.name) > ($1.date, $1.name) } + let keptNames = completed.prefix(max(0, keepCompleted)).map(\.name) + let keep = Set(keptNames + incomplete) + keptCompleted = keptNames.map { root.appending(path: $0) } + incompleteBundles = incomplete.map { root.appending(path: $0) } + let obsoleteNames = names.filter { !keep.contains($0) } + obsolete = obsoleteNames.map { root.appending(path: $0) } + listedAs = Dictionary(uniqueKeysWithValues: obsoleteNames.compactMap { name in entries[name].map { (root.appending(path: name), $0) } }) + } + + /// One entry beside the scan in use. + enum Entry: Equatable, Sendable { + /// A plain file: no bundle, so it goes, as before. + case file + /// A link or an entry whose type can't be read. A link (attributesOfItem doesn't follow + /// one) would reach another folder's bundle, so it and its target could take both kept + /// places; it holds no place and is left alone. + case unjudged + /// A real folder, judged by its bundle. + case folder(BundleState) + } + + static func entry(at url: URL) -> Entry { + switch (try? FileManager.default.attributesOfItem(atPath: url.path))?[.type] as? FileAttributeType { + case .typeDirectory?: .folder(bundleState(url.appending(path: bundleName))) + case .typeRegular?: .file + default: .unjudged + } + } + + enum BundleState: Equatable, Sendable { + case absent + /// `file` is the bundle's file number: a rewrite makes a new file, whatever its date. + case complete(savedAt: Date, file: UInt64) + case incomplete + } + + /// Only a bundle known to be missing is absent. A metadata read that fails for another reason + /// is incomplete: the folder may hold a bundle, so it is kept without a place. + /// + /// A whole bundle must still be the same file after the check. A retry rewrites `scan.zip` in + /// place (`ZipWriter.write` removes it and starts a new file), and a check that opened the old + /// file can finish on it after the new partial one has replaced it; the file number tells them + /// apart. A rewrite that starts after the second read is not caught: the folder then holds its + /// place while its bundle is partial, until the next cleanup. Closing that needs the writer to + /// replace the file atomically, which the packet code doesn't do. + /// `verify` is `PacketArchiveCheck.verify`; tests pass one that rewrites the file mid-check. + static func bundleState(_ bundle: URL, verify: (URL) throws -> Int64 = PacketArchiveCheck.verify) -> BundleState { + let files = FileManager.default + let before: [FileAttributeKey: Any] + do { + before = try files.attributesOfItem(atPath: bundle.path) + } catch let error as CocoaError where error.code == .fileReadNoSuchFile || error.code == .fileNoSuchFile { + return .absent + } catch { + return .incomplete } - .sorted { ($0.date, $0.name) > ($1.date, $1.name) } - let keep = Set(completed.prefix(max(0, keepCompleted)).map(\.name)) - keptCompleted = completed.prefix(max(0, keepCompleted)).map { root.appending(path: $0.name) } - obsolete = names.filter { !keep.contains($0) }.map { root.appending(path: $0) } + guard (try? verify(bundle)) != nil, + let after = try? files.attributesOfItem(atPath: bundle.path), + let number = before[.systemFileNumber] as? UInt64, + after[.systemFileNumber] as? UInt64 == number, + after[.size] as? UInt64 == before[.size] as? UInt64 else { return .incomplete } + return .complete(savedAt: before[.modificationDate] as? Date ?? .distantPast, file: number) } /// Deletes the listed folders; returns each one that could not be deleted with the reason. A /// folder already gone is not an error. + /// + /// The deletion runs later than the listing, off the main actor, and a folder can change in + /// between: a bundle write can start in a folder listed as never packaged, or finish in one. + /// Each entry is read again and deleted only if it is still what it was when listed; one that + /// changed is left for the next cleanup. A change after this second read is not caught. public func run() -> [(url: URL, error: any Error)] { let files = FileManager.default return obsolete.compactMap { url in guard files.fileExists(atPath: url.path) else { return nil } + guard let listed = listedAs[url], Self.entry(at: url) == listed else { return nil } do { try files.removeItem(at: url) return nil diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift index 6f164478f..521549436 100644 --- a/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/KeptScansTests.swift @@ -10,17 +10,28 @@ import Testing return root } - /// A scan folder, completed (with its bundle, written at `minutesAgo`) or not. - static func scan(_ name: String, in root: URL, bundledMinutesAgo minutesAgo: Double? = nil) throws { + /// A capture packet as the app zips it, manifest.json first. Synthetic, never a capture. + static let packet: Data = try! ZipWriter.archive([ + ZipEntry(name: "manifest.json", data: Data(#"{"version":"1.1"}"#.utf8)), + ZipEntry(name: "photos/p00001.jpg", data: Data((0..<4096).map { UInt8($0 % 251) })), + ]) + + /// A scan folder, completed (with its bundle, written at `minutesAgo`) or not. A `partial` + /// bundle is the packet cut short, as a quit during its write leaves it. + static func scan(_ name: String, in root: URL, bundledMinutesAgo minutesAgo: Double? = nil, partial: Bool = false) throws { let folder = root.appending(path: name) try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) try Data("photo".utf8).write(to: folder.appending(path: "k00001.jpg")) guard let minutesAgo else { return } let bundle = folder.appending(path: ScanFolderCleanup.bundleName) - try Data("zip".utf8).write(to: bundle) + try (partial ? packet.prefix(packet.count / 2) : packet).write(to: bundle) try FileManager.default.setAttributes([.modificationDate: Date(timeIntervalSinceNow: -minutesAgo * 60)], ofItemAtPath: bundle.path) } + static func names(_ urls: [URL]) -> [String] { + urls.map(\.lastPathComponent) + } + /// A relaunch makes a new store: the two most recent completed scans stay, with their /// bundles; an older completed scan and one never packaged go. Before, all of them went. @Test func aRelaunchKeepsTheLastCompletedScans() throws { @@ -58,6 +69,170 @@ import Testing #expect(ScanFolderCleanup(root: root, keeping: "later").keptCompleted.map(\.lastPathComponent) == ["run3", "run2"]) } + /// A partial bundle takes no kept place, however new: both intact scans stay. Its folder + /// is left alone too, however old, since nothing shows its writer has stopped. Before, + /// presence alone counted, so the newer partial kept a place and an intact scan was deleted. + @Test(arguments: [0.5, 20.0, 60.0 * 24 * 365]) + func aPartialBundleNeitherEvictsIntactScansNorIsDeleted(minutesAgo: Double) throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + try Self.scan("intact1", in: root, bundledMinutesAgo: 40) + try Self.scan("intact2", in: root, bundledMinutesAgo: 90) + try Self.scan("intact3", in: root, bundledMinutesAgo: 120) + // Newer than every intact scan, unless it is the year-old one. + try Self.scan("partial", in: root, bundledMinutesAgo: minutesAgo, partial: true) + try Self.scan("unpackaged", in: root) + + let cleanup = ScanFolderCleanup(root: root, keeping: "now") + #expect(Self.names(cleanup.keptCompleted) == ["intact1", "intact2"]) + #expect(Self.names(cleanup.incompleteBundles) == ["partial"]) + #expect(Self.names(cleanup.obsolete) == ["intact3", "unpackaged"]) + #expect(cleanup.run().isEmpty) + #expect(try Set(FileManager.default.contentsOfDirectory(atPath: root.path)) == ["intact1", "intact2", "partial"]) + #expect(try Data(contentsOf: root.appending(path: "partial/\(ScanFolderCleanup.bundleName)")) == Self.packet.prefix(Self.packet.count / 2)) + } + + /// A bundle that fails the check for any reason (empty, not a packet) is treated like a + /// partial one; once its write finishes it competes like any completed scan. + @Test func anUnreadableBundleIsLeftAloneUntilItIsWhole() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + try Self.scan("intact1", in: root, bundledMinutesAgo: 40) + try Self.scan("intact2", in: root, bundledMinutesAgo: 90) + let empty = root.appending(path: "empty") + try FileManager.default.createDirectory(at: empty, withIntermediateDirectories: true) + try Data().write(to: empty.appending(path: ScanFolderCleanup.bundleName)) + + let cleanup = ScanFolderCleanup(root: root, keeping: "now") + #expect(Self.names(cleanup.incompleteBundles) == ["empty"]) + #expect(cleanup.obsolete.isEmpty) + + try Self.packet.write(to: empty.appending(path: ScanFolderCleanup.bundleName)) + let later = ScanFolderCleanup(root: root, keeping: "now") + #expect(Self.names(later.keptCompleted) == ["empty", "intact1"]) + #expect(Self.names(later.obsolete) == ["intact2"]) + } + + /// A folder whose bundle can't be read, or whose bundle is a link or a folder, and a link in + /// place of a scan folder hold no place and are not deleted. + @Test func bundlesThatCannotBeJudgedAreLeftAlone() throws { + let root = try Self.makeRoot() + let files = FileManager.default + try Self.scan("intact1", in: root, bundledMinutesAgo: 40) + try Self.scan("intact2", in: root, bundledMinutesAgo: 90) + try Self.scan("locked", in: root, bundledMinutesAgo: 1) + let locked = root.appending(path: "locked") + try files.setAttributes([.posixPermissions: 0o000], ofItemAtPath: locked.path) + defer { + try? files.setAttributes([.posixPermissions: 0o755], ofItemAtPath: locked.path) + try? files.removeItem(at: root) + } + try Self.scan("linked", in: root) + try files.createSymbolicLink(at: root.appending(path: "linked/\(ScanFolderCleanup.bundleName)"), + withDestinationURL: root.appending(path: "intact1/\(ScanFolderCleanup.bundleName)")) + try Self.scan("folderBundle", in: root) + try files.createDirectory(at: root.appending(path: "folderBundle/\(ScanFolderCleanup.bundleName)"), withIntermediateDirectories: true) + + try Data("stray".utf8).write(to: root.appending(path: "stray.txt")) + // A link to the newest intact scan, named to sort ahead of it: it must not take a place. + try files.createSymbolicLink(at: root.appending(path: "zalias"), withDestinationURL: root.appending(path: "intact1")) + + let cleanup = ScanFolderCleanup(root: root, keeping: "now") + #expect(Self.names(cleanup.keptCompleted) == ["intact1", "intact2"]) + #expect(Set(Self.names(cleanup.incompleteBundles)) == ["locked", "linked", "folderBundle", "zalias"]) + // A stray file is not a scan folder and goes, as before. + #expect(Self.names(cleanup.obsolete) == ["stray.txt"]) + #expect(cleanup.run().isEmpty) + #expect(try Set(files.contentsOfDirectory(atPath: root.path)) == ["intact1", "intact2", "locked", "linked", "folderBundle", "zalias"]) + } + + /// A retry rewrites scan.zip in place while the check may still be reading the old file. A + /// check that passes on the old file must not count the new, partial one as complete. + @Test func aBundleReplacedDuringTheCheckIsNotComplete() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + try Self.scan("rewritten", in: root, bundledMinutesAgo: 5) + let bundle = root.appending(path: "rewritten/\(ScanFolderCleanup.bundleName)") + + let state = ScanFolderCleanup.bundleState(bundle) { url in + let size = try PacketArchiveCheck.verify(url) + // As ZipWriter.write does: remove the file, then start a new one. + try FileManager.default.removeItem(at: url) + try Self.packet.prefix(Self.packet.count / 2).write(to: url) + return size + } + #expect(state == .incomplete) + + // Left alone, the same bundle is complete. + try Self.packet.write(to: bundle) + let settled = ScanFolderCleanup.bundleState(bundle) + #expect(settled != .incomplete && settled != .absent) + #expect(ScanFolderCleanup.bundleState(root.appending(path: "none/\(ScanFolderCleanup.bundleName)")) == .absent) + } + + /// The scan in use is never judged, whatever its bundle; a folder made after the listing is + /// never deleted by it. + @Test func theScanInUseAndLaterFoldersAreSafe() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + try Self.scan("current", in: root, bundledMinutesAgo: 60, partial: true) + try Self.scan("old", in: root) + let cleanup = ScanFolderCleanup(root: root, keeping: "current") + #expect(!(cleanup.obsolete + cleanup.keptCompleted + cleanup.incompleteBundles).map(\.lastPathComponent).contains("current")) + #expect(Self.names(cleanup.obsolete) == ["old"]) + + try Self.scan("later", in: root, bundledMinutesAgo: 1) + try Self.scan("laterPartial", in: root, bundledMinutesAgo: 60, partial: true) + #expect(cleanup.run().isEmpty) + #expect(try Set(FileManager.default.contentsOfDirectory(atPath: root.path)) == ["current", "later", "laterPartial"]) + } + + /// The deletion runs after the listing. A folder whose bundle appeared since, partial or + /// whole, is left for the next cleanup, and so is a bundle a retry rewrote; one still as + /// listed is deleted. + @Test func runDeletesOnlyEntriesStillAsListed() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let files = FileManager.default + let bundle = { (name: String) in root.appending(path: "\(name)/\(ScanFolderCleanup.bundleName)") } + try Self.scan("intact1", in: root, bundledMinutesAgo: 40) + try Self.scan("intact2", in: root, bundledMinutesAgo: 90) + try Self.scan("intact3", in: root, bundledMinutesAgo: 120) + try Self.scan("rewritten", in: root, bundledMinutesAgo: 150) + // The same date before and after the rewrite: only the new file tells them apart. + let saved = Date(timeIntervalSinceReferenceDate: 800_000_000) + try files.setAttributes([.modificationDate: saved], ofItemAtPath: bundle("rewritten").path) + try Self.scan("startsWriting", in: root) + try Self.scan("finishesWriting", in: root) + try Self.scan("stillBare", in: root) + let cleanup = ScanFolderCleanup(root: root, keeping: "now") + #expect(Self.names(cleanup.obsolete) == ["finishesWriting", "intact3", "rewritten", "startsWriting", "stillBare"]) + + try Self.packet.prefix(Self.packet.count / 2).write(to: bundle("startsWriting")) + try Self.packet.write(to: bundle("finishesWriting")) + // A retry rewrites in place: the old file goes and a new one takes its name. + try files.removeItem(at: bundle("rewritten")) + try Self.packet.write(to: bundle("rewritten")) + try files.setAttributes([.modificationDate: saved], ofItemAtPath: bundle("rewritten").path) + + #expect(cleanup.run().isEmpty) + #expect(try Set(files.contentsOfDirectory(atPath: root.path)) == ["intact1", "intact2", "rewritten", "startsWriting", "finishesWriting"]) + } + + /// Bundles saved at the same moment keep the order by name, newest name first, as before. + @Test func equalSaveTimesFallBackToNameOrder() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let bundled = Date(timeIntervalSinceNow: -30 * 60) + for name in ["a", "b", "c"] { + try Self.scan(name, in: root, bundledMinutesAgo: 30) + try FileManager.default.setAttributes([.modificationDate: bundled], ofItemAtPath: root.appending(path: "\(name)/\(ScanFolderCleanup.bundleName)").path) + } + let cleanup = ScanFolderCleanup(root: root, keeping: "new") + #expect(Self.names(cleanup.keptCompleted) == ["c", "b"]) + #expect(Self.names(cleanup.obsolete) == ["a"]) + } + /// The stamp carries the build, the server and the answer's rules, with nulls where there /// are none, so the file always has the same shape. @Test func theStampSaysWhatBuiltAndJudgedTheScan() throws { diff --git a/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift b/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift new file mode 100644 index 000000000..27fcef093 --- /dev/null +++ b/ios/HouseScanKit/Tests/HouseScanKitTests/SavedScanCatalogTests.swift @@ -0,0 +1,322 @@ +import Foundation +@testable import HouseScanKit +import Testing + +/// Saved scans: the completed scans the cleanup keeps, read from synthetic scan folders, and the +/// copies handed to the share sheet. Every archive here is made by `ZipWriter`, never a capture. +@Suite struct SavedScanCatalogTests { + let root: URL + let files = FileManager.default + + init() throws { + root = files.temporaryDirectory.appending(path: "saved-scans-\(UUID().uuidString)") + try files.createDirectory(at: root, withIntermediateDirectories: true) + } + + /// A packet as the app zips it: manifest.json first, then a photo. + static let packet: Data = { + // A fixed photo payload so every test archive has the same bytes. + let photo = Data((0..<4096).map { UInt8($0 % 251) }) + return try! ZipWriter.archive([ + ZipEntry(name: "manifest.json", data: Data(#"{"version":"1.1"}"#.utf8)), + ZipEntry(name: "photos/p00001.jpg", data: photo), + ]) + }() + + /// A scan folder with a keyframe, and optionally a bundle saved `minutesAgo` and a stamp. + @discardableResult + func scan(_ name: String, bundle: Data? = packet, minutesAgo: Double = 10, stamp: String? = nil) throws -> URL { + let folder = root.appending(path: name) + try files.createDirectory(at: folder, withIntermediateDirectories: true) + try Data("photo".utf8).write(to: folder.appending(path: "k00001.jpg")) + if let bundle { + let archive = folder.appending(path: ScanFolderCleanup.bundleName) + try bundle.write(to: archive) + try files.setAttributes([.modificationDate: Date(timeIntervalSinceNow: -minutesAgo * 60)], ofItemAtPath: archive.path) + } + if let stamp { + try Data(stamp.utf8).write(to: folder.appending(path: ScanStamp.fileName)) + } + return folder + } + + func cleanUp() { + try? files.removeItem(at: root) + } + + // MARK: Listing + + /// The list is the cleanup's kept set, newest first, with each bundle's saved time and size. + /// A folder never packaged is not a scan; an older completed one the cleanup will delete is + /// not offered. + @Test func listsTheCompletedScansTheCleanupKeeps() throws { + defer { cleanUp() } + try scan("unfinished", bundle: nil) + try scan("oldest", minutesAgo: 90) + try scan("older", minutesAgo: 30) + try scan("newest", minutesAgo: 5) + + let scans = SavedScanCatalog(root: root).scans() + #expect(scans.map(\.id) == ["newest", "older"]) + #expect(scans.map(\.id) == ScanFolderCleanup(root: root, keeping: "").keptCompleted.map(\.lastPathComponent)) + let newest = try #require(scans.first) + #expect(newest.byteCount == Int64(Self.packet.count)) + #expect(newest.archive == root.appending(path: "newest/\(ScanFolderCleanup.bundleName)")) + let saved = try #require(newest.savedAt) + #expect(abs(saved.timeIntervalSinceNow + 5 * 60) < 5) + } + + /// A relaunch makes a new store: its cleanup runs, and a fresh catalog over the same folders + /// still lists the completed scans. The new scan's empty folder is not one of them. + @Test func aCompletedScanIsListedAfterARelaunch() throws { + defer { cleanUp() } + try scan("finished", minutesAgo: 20) + try scan("quitMidWalk", bundle: nil) + try scan("relaunch", bundle: nil) + + let failed = ScanFolderCleanup(root: root, keeping: "relaunch").run() + #expect(failed.isEmpty) + #expect(SavedScanCatalog(root: root).scans().map(\.id) == ["finished"]) + } + + /// An empty or missing root lists nothing rather than failing. + @Test func noFoldersListNothing() { + defer { cleanUp() } + #expect(SavedScanCatalog(root: root).scans().isEmpty) + #expect(SavedScanCatalog(root: root.appending(path: "absent")).scans().isEmpty) + } + + /// A bundle cut short, as a quit during its write leaves it, is not a completed scan, wherever + /// it was cut. Nor is a zip that isn't a packet, or an empty file. + @Test(arguments: [1, 21, 22, 23, 200, SavedScanCatalogTests.packet.count / 2, SavedScanCatalogTests.packet.count - 31]) + func aPartialBundleIsLeftOut(bytesMissing: Int) throws { + defer { cleanUp() } + try scan("partial", bundle: Self.packet.dropLast(bytesMissing), minutesAgo: 1) + try scan("whole", minutesAgo: 30) + #expect(SavedScanCatalog(root: root).scans().map(\.id) == ["whole"]) + } + + @Test func aBundleThatIsNotAPacketIsLeftOut() throws { + defer { cleanUp() } + let other = try ZipWriter.archive([ZipEntry(name: "notes.txt", data: Data("hello".utf8))]) + try scan("other", bundle: other) + try scan("empty", bundle: Data()) + #expect(SavedScanCatalog(root: root).scans().isEmpty) + let otherURL = root.appending(path: "other/\(ScanFolderCleanup.bundleName)") + #expect(throws: PacketArchiveCheck.Failure.notAPacket) { try PacketArchiveCheck.verify(otherURL) } + let emptyURL = root.appending(path: "empty/\(ScanFolderCleanup.bundleName)") + #expect(throws: PacketArchiveCheck.Failure.incomplete) { try PacketArchiveCheck.verify(emptyURL) } + #expect(throws: PacketArchiveCheck.Failure.unreadable) { try PacketArchiveCheck.verify(root.appending(path: "none.zip")) } + } + + /// Bytes after the end record are a partial rewrite or something else, not a whole packet. + @Test func bytesAfterTheEndRecordAreRefused() throws { + defer { cleanUp() } + try scan("padded", bundle: Self.packet + Data(repeating: 0, count: 8)) + #expect(SavedScanCatalog(root: root).scans().isEmpty) + } + + /// A central directory whose first record doesn't point at the manifest at offset 0 doesn't + /// belong with the entries before it. + @Test func aDirectoryThatDisagreesWithTheFirstEntryIsRefused() throws { + defer { cleanUp() } + var bytes = Self.packet + let end = bytes.count - 22 + let directoryOffset = bytes[(end + 16)..<(end + 20)].enumerated().reduce(0) { $0 | Int($1.element) << (8 * $1.offset) } + bytes[directoryOffset + 42] = 1 + try scan("forged", bundle: bytes) + #expect(SavedScanCatalog(root: root).scans().isEmpty) + } + + /// A link is never listed, whether the bundle or the scan folder is the link, and a linked + /// stamp says nothing: a copy of a link would not be the bundle's bytes. + @Test func linksAreNotScans() throws { + defer { cleanUp() } + let real = try scan("real", minutesAgo: 30) + let elsewhere = root.appending(path: "elsewhere.zip") + try Self.packet.write(to: elsewhere) + let linked = try scan("linkedBundle", bundle: nil) + try files.createSymbolicLink(at: linked.appending(path: ScanFolderCleanup.bundleName), withDestinationURL: elsewhere) + try files.createSymbolicLink(at: root.appending(path: "linkedFolder"), withDestinationURL: real) + let stamp = root.appending(path: "stamp.json") + try Data(#"{"practice": true}"#.utf8).write(to: stamp) + try files.createSymbolicLink(at: real.appending(path: ScanStamp.fileName), withDestinationURL: stamp) + + let listed = SavedScanCatalog(root: root).scans() + #expect(listed.map(\.id) == ["real"]) + #expect(listed.first?.practice == nil) + let link = linked.appending(path: ScanFolderCleanup.bundleName) + #expect(throws: PacketArchiveCheck.Failure.notAFile) { try PacketArchiveCheck.verify(link) } + } + + // MARK: Practice + + /// The stamp's practice flag is passed through only when it is a JSON boolean. A missing, + /// corrupt or oddly typed stamp leaves it unknown, and the scan is still listed. + @Test(arguments: [ + (#"{"practice": true, "app": {}}"#, true as Bool?), + (#"{"practice": false}"#, false), + (#"{"app": {}}"#, nil), + (#"{"practice": 1}"#, nil), + (#"{"practice": "true"}"#, nil), + ("{not json", nil), + ("", nil), + ]) + func practiceComesOnlyFromAReadableStamp(stamp: String, practice: Bool?) throws { + defer { cleanUp() } + try scan("one", stamp: stamp) + let listed = try #require(SavedScanCatalog(root: root).scans().first) + #expect(listed.practice == practice) + } + + @Test func noStampAndAnOversizedStampSayNothing() throws { + defer { cleanUp() } + try scan("bare", minutesAgo: 5) + let padding = String(repeating: " ", count: SavedScanCatalog.stampByteLimit) + try scan("huge", minutesAgo: 10, stamp: #"{"practice": true}"# + padding) + let scans = SavedScanCatalog(root: root).scans() + #expect(scans.map(\.id) == ["bare", "huge"]) + #expect(scans.allSatisfy { $0.practice == nil }) + } + + // MARK: Sharing + + /// The share sheet gets a copy outside the scan folder with the bundle's exact bytes and a + /// name a recipient can tell apart. Removing it deletes only the copy. + @Test func stagingCopiesTheBundleExactly() throws { + defer { cleanUp() } + let folder = try scan("one", stamp: #"{"practice": false}"#) + let staging = SavedScanStaging(root: root.appending(path: "share")) + let listed = try #require(SavedScanCatalog(root: root).scans().first) + + let copy = try staging.stage(listed) + #expect(try Data(contentsOf: copy) == Self.packet) + #expect(copy.lastPathComponent == listed.shareFileName()) + #expect(!copy.path.hasPrefix(folder.path)) + + staging.remove(copy) + #expect(!files.fileExists(atPath: copy.deletingLastPathComponent().path)) + #expect(try Data(contentsOf: listed.archive) == Self.packet) + } + + /// A scan the cleanup deleted after the list was made is reported missing, and leaves no copy. + @Test func aBundleGoneSinceListingIsMissing() throws { + defer { cleanUp() } + let folder = try scan("gone") + let staging = SavedScanStaging(root: root.appending(path: "share")) + let listed = try #require(SavedScanCatalog(root: root).scans().first) + try files.removeItem(at: folder) + + #expect(throws: SavedScanShareError.missing) { try staging.stage(listed) } + #expect((try? files.contentsOfDirectory(atPath: staging.root.path))?.isEmpty ?? true) + } + + /// A bundle cut short after the list was made is reported damaged, and leaves no copy. + @Test func aBundleCutShortSinceListingIsDamaged() throws { + defer { cleanUp() } + try scan("cut") + let staging = SavedScanStaging(root: root.appending(path: "share")) + let listed = try #require(SavedScanCatalog(root: root).scans().first) + try Self.packet.prefix(100).write(to: listed.archive) + + #expect(throws: SavedScanShareError.damaged) { try staging.stage(listed) } + #expect((try? files.contentsOfDirectory(atPath: staging.root.path))?.isEmpty ?? true) + } + + /// `remove` deletes only folders it made, and removing copies never touches a scan. + @Test func removalStaysInsideTheStagingFolder() throws { + defer { cleanUp() } + let folder = try scan("keep") + let staging = SavedScanStaging(root: root.appending(path: "share")) + let listed = try #require(SavedScanCatalog(root: root).scans().first) + + staging.remove(listed.archive) + #expect(files.fileExists(atPath: listed.archive.path)) + + let first = try staging.stage(listed) + let second = try staging.stage(listed) + #expect(first != second) + staging.remove(first) + #expect(!files.fileExists(atPath: first.path) && files.fileExists(atPath: second.path)) + SavedScanStaging(root: staging.root).removeOtherSessions() + #expect(!files.fileExists(atPath: second.path)) + #expect(files.fileExists(atPath: folder.appending(path: ScanFolderCleanup.bundleName).path)) + } + + /// At launch, the copies earlier runs left go and this run's stay, whatever the clock says. + @Test func launchRemovesOnlyOtherRunsCopies() throws { + defer { cleanUp() } + try scan("one") + let shares = root.appending(path: "share") + let earlier = SavedScanStaging(root: shares, session: "earlier") + let current = SavedScanStaging(root: shares, session: "current") + let listed = try #require(SavedScanCatalog(root: root).scans().first) + let left = try earlier.stage(listed) + let mine = try current.stage(listed) + // A clock set back between runs: the earlier copy looks newer than this run's. + try files.setAttributes([.creationDate: Date(timeIntervalSinceNow: 3600)], ofItemAtPath: left.deletingLastPathComponent().path) + + current.removeOtherSessions() + #expect(!files.fileExists(atPath: left.path)) + #expect(files.fileExists(atPath: mine.path)) + #expect(files.fileExists(atPath: listed.archive.path)) + // A run removes only its own session's copies one by one. + current.remove(left) + earlier.remove(mine) + #expect(files.fileExists(atPath: mine.path)) + SavedScanStaging(root: root.appending(path: "absent")).removeOtherSessions() + } + + /// Two Saved scans sheets in one app run (two windows, if the app ever allows them) share a + /// session: opening one, which clears other runs' copies, never deletes the copy the other + /// is sharing, and each removes only the copy it made. + @Test func sheetsInOneRunNeverDeleteEachOthersCopies() throws { + defer { cleanUp() } + try scan("one") + let shares = root.appending(path: "share") + let first = SavedScanStaging(root: shares, session: "run") + let second = SavedScanStaging(root: shares, session: "run") + let listed = try #require(SavedScanCatalog(root: root).scans().first) + let sharing = try first.stage(listed) + + second.removeOtherSessions() + let other = try second.stage(listed) + second.remove(other) + #expect(try Data(contentsOf: sharing) == Self.packet) + #expect(!files.fileExists(atPath: other.path)) + } + + /// A share outlives a new scan: the new store's cleanup deletes the scan's own folder, and + /// the copy the share sheet is reading, in its own folder, keeps the bundle's bytes. + @Test func aCopyBeingSharedSurvivesANewScansCleanup() throws { + defer { cleanUp() } + try scan("old", minutesAgo: 90) + try scan("newer", minutesAgo: 30) + try scan("newest", minutesAgo: 5) + let staging = SavedScanStaging(root: files.temporaryDirectory.appending(path: "share-\(UUID().uuidString)")) + defer { try? files.removeItem(at: staging.root) } + let older = try #require(SavedScanCatalog(root: root).scans().last) + let copy = try staging.stage(older) + + // Two newer completed scans push it out: a new scan's store deletes its folder. + try scan("evenNewer", minutesAgo: 1) + let cleanup = ScanFolderCleanup(root: root, keeping: "fresh") + #expect(cleanup.obsolete.map(\.lastPathComponent).contains(older.id)) + #expect(cleanup.run().isEmpty) + #expect(!files.fileExists(atPath: older.archive.path)) + #expect(try Data(contentsOf: copy) == Self.packet) + } + + // MARK: Names + + @Test func theShareNameSaysWhenAndWhetherPractice() { + let archive = URL(fileURLWithPath: "/x/scan.zip") + let saved = Date(timeIntervalSince1970: 1_790_000_000) // 2026-09-21 14:13:20 UTC + let utc = TimeZone(identifier: "UTC")! + #expect(SavedScan(id: "a", archive: archive, savedAt: saved, byteCount: 1, practice: false).shareFileName(timeZone: utc) + == "House Scan 2026-09-21 14.13.zip") + #expect(SavedScan(id: "a", archive: archive, savedAt: saved, byteCount: 1, practice: true).shareFileName(timeZone: utc) + == "House Scan practice 2026-09-21 14.13.zip") + #expect(SavedScan(id: "a", archive: archive, savedAt: nil, byteCount: 1, practice: nil).shareFileName() == "House Scan.zip") + } +} diff --git a/ios/HouseScanUITests/SavedScansUITests.swift b/ios/HouseScanUITests/SavedScansUITests.swift new file mode 100644 index 000000000..74b8bff59 --- /dev/null +++ b/ios/HouseScanUITests/SavedScansUITests.swift @@ -0,0 +1,206 @@ +import XCTest + +/// Saved scans from the onboarding, on synthetic scan folders the test writes and the app lists +/// through `-savedScansRoot` (Debug builds only). A completed scan is listed with its saved time +/// and a Practice tag only where its stamp says practice; an unfinished folder is not listed. +/// Share opens the system share sheet; a scan deleted since the list was made gets an error and +/// drops out; the list survives a relaunch; and a new scan still starts. +final class SavedScansUITests: XCTestCase { + private var root: URL! + + override func setUpWithError() throws { + continueAfterFailure = false + root = URL(fileURLWithPath: NSTemporaryDirectory()).appending(path: "housescan-saved-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: root) + } + + @MainActor + func testASavedScanSharesAfterARelaunchAndAMissingOneSaysSo() throws { + try scan("practice", minutesAgo: 5, stamp: #"{"practice": true}"#) + // A corrupt stamp says nothing: the scan is listed, untagged. + try scan("real", minutesAgo: 30, stamp: "{not json") + try scan("unfinished", minutesAgo: nil) + + let app = launch() + openSavedScans(app) + let practice = row(app, "practice") + let real = row(app, "real") + XCTAssertTrue(practice.waitForExistence(timeout: 10), "the practice scan is not listed") + XCTAssertTrue(real.exists, "the real scan is not listed") + XCTAssertFalse(row(app, "unfinished").exists, "a scan without a bundle is listed") + XCTAssertTrue(practice.descendants(matching: .any)["savedScan.practice"].exists, "the practice scan has no Practice tag") + XCTAssertFalse(real.descendants(matching: .any)["savedScan.practice"].exists, "a scan without a readable stamp is tagged Practice") + // A practice scan walked a real wall; only its meter was a sample. + // The row's text is one combined element, so match within labels. + func says(_ element: XCUIElement, _ text: String) -> Bool { + element.descendants(matching: .any).matching(NSPredicate(format: "label CONTAINS[c] %@", text)).firstMatch.exists + } + XCTAssertTrue(says(practice, "Practice scan with a sample meter"), "the practice scan doesn't say its meter was a sample") + XCTAssertFalse(says(app, "real wall"), "a row says a scan wasn't of a real wall") + attach(app, "savedScans-list") + // The audit's text-size check changes the onboarding's top bar layout; the sheet must + // stay open through it (it closed when the button owned it). + try audit(app) + + // Share opens the system sheet, and closing it returns to the list. + tapWhenReady(real.buttons["action.shareSavedScan"]) + let sheet = app.otherElements["ActivityListView"] + XCTAssertTrue(sheet.waitForExistence(timeout: 15), "the share sheet never appeared") + attach(app, "savedScans-shareSheet") + if app.buttons["Close"].waitForExistence(timeout: 3) { + app.buttons["Close"].tap() + } else { + sheet.swipeDown(velocity: .fast) + } + XCTAssertTrue(sheet.waitForNonExistence(timeout: 10), "the share sheet did not close") + + // The practice scan's folder goes after the list was made, as a cleanup would take it. + try FileManager.default.removeItem(at: root.appending(path: "practice")) + tapWhenReady(practice.buttons["action.shareSavedScan"]) + let alert = app.alerts["This scan is no longer on this phone"] + XCTAssertTrue(alert.waitForExistence(timeout: 10), "a missing scan gave no error") + attach(app, "savedScans-missing") + alert.buttons["OK"].tap() + XCTAssertTrue(practice.waitForNonExistence(timeout: 10), "the missing scan stayed in the list") + XCTAssertTrue(real.exists) + + // A relaunch lists the completed scan again. + app.terminate() + app.launch() + openSavedScans(app) + XCTAssertTrue(row(app, "real").waitForExistence(timeout: 10), "the saved scan is gone after a relaunch") + tapWhenReady(app.buttons["action.closeSavedScans"]) + + // A new scan starts as before. + if app.buttons["action.onboardingSkip"].waitForExistence(timeout: 5) { tapWhenReady(app.buttons["action.onboardingSkip"]) } + tapWhenReady(app.buttons["action.finishOnboarding"]) + XCTAssertTrue(app.descendants(matching: .any)["screen.findMeter"].waitForExistence(timeout: 15), "a new scan did not start") + } + + @MainActor + func testNoSavedScansSaysWhereTheyComeFrom() throws { + try scan("unfinished", minutesAgo: nil) + let app = launch() + openSavedScans(app) + let empty = app.descendants(matching: .any)["savedScans.empty"] + XCTAssertTrue(empty.waitForExistence(timeout: 10), "no empty state") + XCTAssertFalse(app.buttons["action.shareSavedScan"].exists) + attach(app, "savedScans-empty") + try audit(app) + } + + // MARK: Helpers + + @MainActor + private func launch() -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = ["-savedScansRoot", root.path, "-replay", FullFlowUITests.fixture, "-sampleResult", "-practiceMeter", "NO"] + app.launch() + XCTAssertTrue(app.descendants(matching: .any)["screen.onboarding"].waitForExistence(timeout: 30)) + return app + } + + @MainActor + private func openSavedScans(_ app: XCUIApplication) { + tapWhenReady(app.buttons["action.savedScans"]) + XCTAssertTrue(app.descendants(matching: .any)["savedScans"].waitForExistence(timeout: 10), "Saved scans did not open") + } + + @MainActor + private func row(_ app: XCUIApplication, _ id: String) -> XCUIElement { + app.descendants(matching: .any)["savedScan.\(id)"] + } + + /// Waits for the element to take a tap before tapping it, so a sheet still sliding in + /// doesn't swallow the tap. + @MainActor + private func tapWhenReady(_ element: XCUIElement, file: StaticString = #filePath, line: UInt = #line) { + XCTAssertTrue(element.waitForExistence(timeout: 10), "\(element) never appeared", file: file, line: line) + let hittable = expectation(for: NSPredicate(format: "isHittable == true"), evaluatedWith: element) + wait(for: [hittable], timeout: 10) + element.tap() + } + + /// The shared audit (`AccessibilityAudit`): an issue fails only when it shows in two passes. + @MainActor + private func audit(_ app: XCUIApplication, file: StaticString = #filePath, line: UInt = #line) throws { + let outcome = try AccessibilityAudit.run(app) { _ in Thread.sleep(forTimeInterval: 1) } + for unread in outcome.unread { add(XCTAttachment(string: unread)) } + XCTAssertTrue(outcome.persistent.isEmpty, outcome.persistent.map(\.finding.message).joined(separator: "\n"), file: file, line: line) + } + + @MainActor + private func attach(_ app: XCUIApplication, _ name: String) { + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = name + shot.lifetime = .keepAlways + add(shot) + } + + /// A scan folder with a keyframe and, unless `minutesAgo` is nil, a bundle saved that long + /// ago: a synthetic packet whose first entry is manifest.json, as the app writes it. + private func scan(_ name: String, minutesAgo: Double?, stamp: String? = nil) throws { + let folder = root.appending(path: name) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + try Data("photo".utf8).write(to: folder.appending(path: "k00001.jpg")) + if let stamp { try Data(stamp.utf8).write(to: folder.appending(path: "scan-stamp.json")) } + guard let minutesAgo else { return } + let bundle = folder.appending(path: "scan.zip") + try StoredZip.archive([ + ("manifest.json", Data(#"{"version":"1.1"}"#.utf8)), + ("photos/p00001.jpg", Data(repeating: 0xAB, count: 2048)), + ]).write(to: bundle) + try FileManager.default.setAttributes([.modificationDate: Date(timeIntervalSinceNow: -minutesAgo * 60)], ofItemAtPath: bundle.path) + } +} + +/// A stored (uncompressed) zip, enough for a synthetic packet. The UI tests don't link +/// HouseScanKit, whose `ZipWriter` the app uses. +private enum StoredZip { + static func archive(_ entries: [(name: String, data: Data)]) -> Data { + var out = Data() + var central = Data() + for entry in entries { + let name = Data(entry.name.utf8) + let crc = crc32(entry.data) + let offset = UInt32(out.count) + out.append(le: UInt32(0x0403_4B50)); out.append(le: UInt16(20)); out.append(le: UInt16(0)); out.append(le: UInt16(0)) + out.append(le: UInt16(0)); out.append(le: UInt16(0x21)) + out.append(le: crc); out.append(le: UInt32(entry.data.count)); out.append(le: UInt32(entry.data.count)) + out.append(le: UInt16(name.count)); out.append(le: UInt16(0)) + out.append(name); out.append(entry.data) + + central.append(le: UInt32(0x0201_4B50)); central.append(le: UInt16(20)); central.append(le: UInt16(20)) + central.append(le: UInt16(0)); central.append(le: UInt16(0)); central.append(le: UInt16(0)); central.append(le: UInt16(0x21)) + central.append(le: crc); central.append(le: UInt32(entry.data.count)); central.append(le: UInt32(entry.data.count)) + central.append(le: UInt16(name.count)); central.append(le: UInt16(0)); central.append(le: UInt16(0)) + central.append(le: UInt16(0)); central.append(le: UInt16(0)); central.append(le: UInt32(0)); central.append(le: offset) + central.append(name) + } + let directoryOffset = UInt32(out.count) + out.append(central) + out.append(le: UInt32(0x0605_4B50)); out.append(le: UInt16(0)); out.append(le: UInt16(0)) + out.append(le: UInt16(entries.count)); out.append(le: UInt16(entries.count)) + out.append(le: UInt32(central.count)); out.append(le: directoryOffset); out.append(le: UInt16(0)) + return out + } + + static func crc32(_ data: Data) -> UInt32 { + var crc: UInt32 = 0xFFFF_FFFF + for byte in data { + crc ^= UInt32(byte) + for _ in 0..<8 { crc = (crc & 1) != 0 ? 0xEDB8_8320 ^ (crc >> 1) : crc >> 1 } + } + return crc ^ 0xFFFF_FFFF + } +} + +private extension Data { + mutating func append(le value: T) { + Swift.withUnsafeBytes(of: value.littleEndian) { append(contentsOf: $0) } + } +} diff --git a/ios/HouseScanUITests/ScreenStatesUITests.swift b/ios/HouseScanUITests/ScreenStatesUITests.swift index 3002e501d..7bf03e93b 100644 --- a/ios/HouseScanUITests/ScreenStatesUITests.swift +++ b/ios/HouseScanUITests/ScreenStatesUITests.swift @@ -215,8 +215,9 @@ final class ScreenStatesUITests: XCTestCase { XCTAssertTrue(element(app, "screen.findMeter").waitForExistence(timeout: 5)) } - /// A failed upload says the app can't reopen the scan after it closes, and with the scan - /// packaged it points to Share scan. The note, Try again and Share scan are all reachable. + /// A failed upload says to keep the scan open to try again, and with the scan packaged that + /// Saved scans can still share its file after the app closes. The note, Try again and Share + /// scan are all reachable. @MainActor func testOfflineRecoveryActions() throws { try checkOfflineRecovery(textSize: [], name: "offline-recovery-polish") @@ -240,9 +241,8 @@ final class ScreenStatesUITests: XCTestCase { let note = element(app, "upload.recoveryLimit") for _ in 0..<8 where !note.isHittable { scroll.swipeUp(velocity: .slow) } XCTAssertTrue(note.isHittable) - // The demo scan is packaged, so the note names the export. - XCTAssertTrue(note.label.contains("use Share scan to save a copy"), note.label) - XCTAssertTrue(note.label.contains("can't reopen it after you close the app"), note.label) + // The demo scan is packaged, so the note points to Saved scans for after the app closes. + XCTAssertTrue(note.label.contains("If you close the app, you can still share its saved file from Saved scans on the first screen."), note.label) let retry = app.buttons["action.retryUpload"] for _ in 0..<8 where !retry.isHittable { scroll.swipeUp(velocity: .slow) } XCTAssertTrue(retry.isHittable) diff --git a/ios/README.md b/ios/README.md index f8323e881..9f54635ea 100644 --- a/ios/README.md +++ b/ios/README.md @@ -22,6 +22,7 @@ Native iPhone app for the AR capture walk. The homeowner marks the electric mete | `-autopilotGate ` | Used by the UI tests. Before leaving a screen, the app waits for a file named after that phase in this folder, and while it waits it leaves `.held` there, so a test can audit the screen once the autopilot has finished with it. Once the result shows, the autopilot writes the scan's scene.json there. | | `-simulateAppStore` | Runs as an App Store install would: no developer options, and practice meter stays off whatever the stored switch says. `PracticeMeterUITests` uses it. | | `-practiceMeter YES` or `NO` | Overrides the stored practice meter switch for one run (UserDefaults' argument domain). The full-flow UI tests pass `NO`, since the switch outlives a run in the Simulator. | +| `-savedScansRoot ` | Debug builds only. Saved scans lists the scan folders in this folder instead of the app's Caches/Scans. It only changes what is listed; the scan store still writes and cleans up Caches/Scans. `SavedScansUITests` passes it with synthetic scans. | | `-uiDemo` | Runs the screens on a scripted fake engine instead of the capture engine, for design work and for auditing states a replay can't reach. The arguments it takes are listed in `HouseScan/UI/Preview/UIDemo.swift`; for example `-uiDemoGroundQuestion` opens the feature review with the ground question showing, `-uiDemoPhase meterCloseUp -uiDemoMeterChoose` shows the meter number picker, and `-uiDemoPhase result -uiDemoCorner` shows the result model of a wall that turns a corner. | **Spot check.** Before the result, the app shows the kept photo that best sees the answer's spot, with the spot and its clearance area outlined, and asks whether anything stands in front of the wall or on the ground there (`STATE=spotConfirm`). Photos without depth and the walked path claim wall, ground and clear space they never saw; "It's clear" backs those claims for that spot, and "Something's there" takes them back over the area, so scene.json reports it unseen, and sends the scan again. The rules are in `Runtime/ScanEngine+Confirm.swift` and HouseScanKit's `Confirm/SpotConfirmation.swift`. @@ -95,7 +96,7 @@ Git ignores `Local.xcconfig`. Leave the team field in Xcode's Signing & Capabili **Logs.** Open Console.app on the Mac, select the iPhone, press Start and search for `subsystem:dev.housescanning.housescan`. Every screen change logs as `STATE=`; the `engine` category records the capture's decisions, the scan bundle and upload failures. The same lines show in Xcode's console while it runs the app. -**Getting a scan off the phone.** The result screen, and the screens for a failed or refused upload, have a Share scan button. It shares the scan bundle (below); AirDrop it to your Mac. It holds photos of a real home, so keep it out of git. +**Getting a scan off the phone.** The result screen, and the screens for a failed or refused upload, have a Share scan button. It shares the scan bundle (below); AirDrop it to your Mac. It holds photos of a real home, so keep it out of git. After the app is closed, **Saved scans** in the first screen's top bar lists the completed scans the phone still keeps (the two most recent whose `scan.zip` was written to the end, as `ScanFolderCleanup` keeps them) with when each bundle was saved, its size and a Practice tag when its `scan-stamp.json` says so. A bundle cut short by a quit during its write is not listed, holds no kept place and is not deleted. Share there sends the same bundle. It copies the bundle to the app's tmp folder first, so a new scan's cleanup can't delete it while the share sheet reads it, and deletes the copy when the sheet closes; a launch deletes copies earlier runs left. A saved scan can only be shared, never reopened or continued. The code is in `UI/SavedScans/` and HouseScanKit's `SavedScans/`. ## Scan bundle