field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit 02a8b0e42e6e3cd4d583f86b3f7c3ba4af8a2bea
parent b13a640115514ec6014d86b72c32eaa750bc3780
Author: triesap <tyson@radroots.org>
Date:   Mon, 21 Sep 2026 05:44:38 +0000

ios: expose operation-scoped recovery actions

- Expose exact saved-transfer checks without new upload effects
- Reconcile retained advisory status after native settlement
- Preserve original submission requests and honest pause labels
- Verify isolated recovery controls and standalone consumers

Diffstat:
MPackage.swift | 1+
MTera.xcodeproj/project.pbxproj | 8++++++++
MTera/App/TeraRootShell.swift | 4+++-
MTera/Runtime/TeraAddMediaCoordinator.swift | 28++++++++++++++++++++++++----
MTera/Runtime/TeraNativeRecoveryInventory.swift | 31++++++++++++++++++++++++++-----
MTera/Runtime/TeraNativeRecoveryStatus.swift | 37++++++++++++++++++++++++++++++++-----
MTera/Runtime/TeraRecoveryCompletion.swift | 2+-
MTera/Runtime/TeraSubmissionOperationModels.swift | 11+++++++++++
MTera/State/TeraNativeRepairStore.swift | 57+++++++++++++++++++++++++++++++++++++++------------------
MTera/State/TeraSubmissionStore.swift | 7+++++++
MTera/Views/TeraDraftsSheet.swift | 1+
MTera/Views/TeraNativeRepairView.swift | 60++++++++++++++++++++++++++++++++++++++++++++----------------
ATera/Views/TeraRecoveryUITestSurface.swift | 32++++++++++++++++++++++++++++++++
MTera/Views/TeraSubmissionViews.swift | 12+++++++++++-
MTeraFFI/provenance.json | 14+++++++-------
MTeraFFI/source.lock | 4++--
MTeraFFI/source/aarch64-apple-darwin.json | 8++++----
MTeraFFI/source/aarch64-apple-ios-sim.json | 8++++----
MTeraFFI/source/aarch64-apple-ios.json | 8++++----
MTeraTests/TeraNativeRepairPreviewTests.swift | 26++++++++++++++++++++++++++
MTeraTests/TeraNativeRepairTests.swift | 2+-
ATeraTests/TeraOperationRecoveryTests.swift | 128+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTeraTests/TeraRecoveryInventoryTests.swift | 21+++++++++++++++++----
MTeraTests/TeraSubmissionStoreTests.swift | 7++++++-
ATeraUITests/TeraRecoveryUITests.swift | 32++++++++++++++++++++++++++++++++
Mproject.yml | 2++
Mrelease/provenance.json | 6+++---
Mtest-fixtures/legacy-identifiers.v1.json | 4++++
28 files changed, 480 insertions(+), 81 deletions(-)

diff --git a/Package.swift b/Package.swift @@ -168,6 +168,7 @@ let package = Package( "Views/TeraDraftsSheet.swift", "Views/TeraEditingProtectionActions.swift", "Views/TeraEditingProtectionUITestSurface.swift", + "Views/TeraRecoveryUITestSurface.swift", "Views/TeraSupportingViews.swift", "Views/TeraContextPicker.swift", "Views/TeraTodayDiscoveryView.swift", diff --git a/Tera.xcodeproj/project.pbxproj b/Tera.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 026981088C27CB79A49D9833 /* TeraOperationRecoveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8B650ED2D867EF94248F6F /* TeraOperationRecoveryTests.swift */; }; 08A44543B171791AF858C517 /* TeraAddSignerFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1CC48A95774B931BE8D1E4A /* TeraAddSignerFixture.swift */; }; 093A6AC6C9B724E465507004 /* TeraPublicationEvidenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B954B7006CC5904C98138C6 /* TeraPublicationEvidenceTests.swift */; }; 095FBECA7268AA594F614EFA /* TeraScopeBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ED6D3E35C3235B6068E1902 /* TeraScopeBackend.swift */; }; @@ -119,6 +120,7 @@ F13090A3350CAE9CA3FFF3F3 /* TeraLateSigningTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 217E44A3221756EC904702D6 /* TeraLateSigningTests.swift */; }; F14217EA66C2A9397E36E3FA /* TeraEditingOperationProtectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC9B19425D3374B899485C17 /* TeraEditingOperationProtectionTests.swift */; }; F4AF91E71B691FE30C191852 /* TeraAddObservationStartupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44A4460928B20A21A94BECCE /* TeraAddObservationStartupTests.swift */; }; + F8A87EA68BEF06EA7F0838D0 /* TeraRecoveryUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5928D5CAC9F8EF904547DF8 /* TeraRecoveryUITests.swift */; }; F8C87782857DC930D47E6A90 /* TeraRuntimeResourceTaskTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4D01C023E258AC3B3B1C27D /* TeraRuntimeResourceTaskTests.swift */; }; F8DB757CAC7AB2136483EC2B /* TeraComposerSaveSchedulingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C51E210CC4A6734CD302452 /* TeraComposerSaveSchedulingTests.swift */; }; FB585785DC906CB2AE4AE535 /* TeraBindingPortabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E711687008866790042792 /* TeraBindingPortabilityTests.swift */; }; @@ -222,7 +224,9 @@ 9ED6D3E35C3235B6068E1902 /* TeraScopeBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraScopeBackend.swift; sourceTree = "<group>"; }; 9F22CFABCF27A28408F68D25 /* TeraAddStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraAddStoreTests.swift; sourceTree = "<group>"; }; 9F54FC4930051FC4611B37D3 /* tera */ = {isa = PBXFileReference; lastKnownFileType = folder; name = tera; path = .; sourceTree = SOURCE_ROOT; }; + 9F8B650ED2D867EF94248F6F /* TeraOperationRecoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraOperationRecoveryTests.swift; sourceTree = "<group>"; }; A1CC48A95774B931BE8D1E4A /* TeraAddSignerFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraAddSignerFixture.swift; sourceTree = "<group>"; }; + A5928D5CAC9F8EF904547DF8 /* TeraRecoveryUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraRecoveryUITests.swift; sourceTree = "<group>"; }; A5938176A92F96A30CC39DFB /* TeraComposerPersistenceFFITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraComposerPersistenceFFITests.swift; sourceTree = "<group>"; }; AB8A30A75EFD65EFD938D248 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; AC9B19425D3374B899485C17 /* TeraEditingOperationProtectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeraEditingOperationProtectionTests.swift; sourceTree = "<group>"; }; @@ -299,6 +303,7 @@ children = ( 0D243145C276EBF17915AB4E /* TeraEditingProtectionUITests.swift */, FD65DE234C7DD80CE3CB91EB /* TeraPhotoQualificationInput.swift */, + A5928D5CAC9F8EF904547DF8 /* TeraRecoveryUITests.swift */, 32CBDAE974FB8D7408108E69 /* TeraRemoteQualificationUITests.swift */, 5238B5F236C392C82CEF9838 /* TeraRootShellUITests.swift */, 019FAA997BEDE9CC84785DAE /* TeraSubmissionQualificationSupport.swift */, @@ -374,6 +379,7 @@ 649F88AE533C3BEEBC466894 /* TeraOfflineMediaTests.swift */, 74FCF1C4A8A7137623BBE223 /* TeraOpenedMediaCloseFixture.swift */, 9B10B1F3C7FCD9A72F04FC00 /* TeraOpenedMediaTests.swift */, + 9F8B650ED2D867EF94248F6F /* TeraOperationRecoveryTests.swift */, 7AF60EF9DCFDC4ECE77AED67 /* TeraProductStartupTests.swift */, 3B954B7006CC5904C98138C6 /* TeraPublicationEvidenceTests.swift */, 156F07F1F2961FDF6539099A /* TeraRecoveryCompletionTests.swift */, @@ -684,6 +690,7 @@ 46ECA0EA5CE33D30BB5E08AF /* TeraOfflineMediaTests.swift in Sources */, 2A68D8FEEC3B88D47D696589 /* TeraOpenedMediaCloseFixture.swift in Sources */, 6FED2D2255CC702B33CDC760 /* TeraOpenedMediaTests.swift in Sources */, + 026981088C27CB79A49D9833 /* TeraOperationRecoveryTests.swift in Sources */, C46F8EB5B196FFDD37E0279A /* TeraProductStartupTests.swift in Sources */, 093A6AC6C9B724E465507004 /* TeraPublicationEvidenceTests.swift in Sources */, 36AB69B9F8DB9AADB8124795 /* TeraRecoveryCompletionTests.swift in Sources */, @@ -749,6 +756,7 @@ files = ( 6F09A1AF6E764AC0136E8F74 /* TeraEditingProtectionUITests.swift in Sources */, 25CCFD77EFA43657C473ADDC /* TeraPhotoQualificationInput.swift in Sources */, + F8A87EA68BEF06EA7F0838D0 /* TeraRecoveryUITests.swift in Sources */, C3177E827FE62639579C3EF5 /* TeraRemoteQualificationUITests.swift in Sources */, DF97F408D7D32F72C5AF5939 /* TeraRootShellUITests.swift in Sources */, 41ED082A17594FE682A381DD /* TeraSubmissionQualificationSupport.swift in Sources */, diff --git a/Tera/App/TeraRootShell.swift b/Tera/App/TeraRootShell.swift @@ -61,7 +61,9 @@ struct TeraRootShell: View { TeraAddView(store: stores.add) } else { #if DEBUG - if ProcessInfo.processInfo.environment["TERA_IOS_UI_TEST_EDITING_PROTECTION"] == "1" { + if ProcessInfo.processInfo.environment["TERA_IOS_UI_TEST_RECOVERY"] == "1" { + TeraRecoveryUITestSurface() + } else if ProcessInfo.processInfo.environment["TERA_IOS_UI_TEST_EDITING_PROTECTION"] == "1" { TeraEditingProtectionUITestSurface() } else { TeraAddUnavailable() diff --git a/Tera/Runtime/TeraAddMediaCoordinator.swift b/Tera/Runtime/TeraAddMediaCoordinator.swift @@ -21,6 +21,7 @@ struct TeraAddBackgroundUploadReceipt: Sendable, Equatable { protocol TeraAddMediaHandling: Sendable { func renewSubmissionUpload(_ submission: TeraSubmissionStatus, media: TeraPreparedMedia, client: TeraRuntimeClient) async throws -> TeraSubmissionStatus func recoverNativeUploads(client: TeraRuntimeClient) async throws -> TeraNativeRecoveryProgress + func recoverNativeUpload(key: String, client: TeraRuntimeClient) async throws -> TeraNativeRecoveryProgress func confirmDurableComposerMedia(_ media: [TeraComposerMedia]) async throws func prefersSharedForegroundUpload(ownerID: String) async throws -> Bool func support() async throws -> TeraAddMediaSupport @@ -39,6 +40,10 @@ protocol TeraAddMediaHandling: Sendable { } extension TeraAddMediaHandling { + func recoverNativeUpload(key _: String, client _: TeraRuntimeClient) async throws -> TeraNativeRecoveryProgress { + throw TeraComposerAcknowledgment.unconfirmed + } + func renewSubmissionUpload(_: TeraSubmissionStatus, media _: TeraPreparedMedia, client _: TeraRuntimeClient) async throws -> TeraSubmissionStatus { throw TeraComposerAcknowledgment.unconfirmed } @@ -341,14 +346,29 @@ actor TeraAddMediaCoordinator: TeraAddMediaHandling { extension TeraAddMediaCoordinator { func recoverNativeUploads(client: TeraRuntimeClient) async throws -> TeraNativeRecoveryProgress { + try await recoverNativeUploads(selectedKey: nil, client: client) + } + + func recoverNativeUpload(key: String, client: TeraRuntimeClient) async throws -> TeraNativeRecoveryProgress { + guard key.utf8.count == 64, key.utf8.allSatisfy({ (48 ... 57).contains($0) || (97 ... 102).contains($0) }) else { + throw TeraComposerAcknowledgment.unconfirmed + } + return try await recoverNativeUploads(selectedKey: key, client: client) + } + + private func recoverNativeUploads(selectedKey: String?, client: TeraRuntimeClient) async throws -> TeraNativeRecoveryProgress { guard !recoveryActive else { throw TeraBackgroundUploadRequest.operationInProgress } let mediaUse = try TeraMediaProcessUse.admit(root: roots.dataRoot) recoveryActive = true defer { recoveryActive = false; withExtendedLifetime(mediaUse) {} } - let schedule = try await client.nativeRecoverySchedule() - let continuation = TeraNativeRecoveryContinuation(schedule, client: client) - let result = try await TeraNativeRecoveryInventory.run(transfer: transfer, cursor: schedule.after, checkpoint: { key in - try await continuation.advance(key) + let schedule = selectedKey == nil ? try await client.nativeRecoverySchedule() : nil + let continuation = schedule.map { TeraNativeRecoveryContinuation($0, client: client) } + let inspection = TeraNativeRecoveryInspection(selectedKey: selectedKey, completedNeedsRepair: { key in + guard let status = try await client.nativeRecoveryStatus(key: key) else { return false } + return status.reason != .resolved + }) + let result = try await TeraNativeRecoveryInventory.run(transfer: transfer, cursor: schedule?.after, inspection: inspection, checkpoint: { key in + try await continuation?.advance(key) }, complete: { snapshot, owner in let input = try TeraRecoveryUploadReceipt(snapshot: snapshot, owner: owner) let opened = try await self.open([input.media]) diff --git a/Tera/Runtime/TeraNativeRecoveryInventory.swift b/Tera/Runtime/TeraNativeRecoveryInventory.swift @@ -1,6 +1,11 @@ import Foundation import RadrootsKit +struct TeraNativeRecoveryInspection: Sendable { + var selectedKey: String? + var completedNeedsRepair: @Sendable (String) async throws -> Bool = { _ in false } +} + /// One explicit bounded pass over authoritative native receipts. The owner /// persists a redacted ordering key after each visited position. Interrupted /// work is replayed idempotently; a new sweep revisits earlier quarantined IDs. @@ -9,6 +14,7 @@ enum TeraNativeRecoveryInventory { static func run( transfer: any RadrootsBackgroundTransfer, cursor: String?, + inspection: TeraNativeRecoveryInspection = .init(), checkpoint: @Sendable (String?) async throws -> Void = { _ in }, complete: @Sendable (RadrootsBackgroundTransferSnapshot, TeraNativeUploadRecoveryOwner) async throws -> Void = { _, _ in throw TeraComposerAcknowledgment.unconfirmed }, report: @Sendable (RadrootsBackgroundTransferSnapshot, TeraNativeRecoveryReason) async -> TeraNativeRecoveryIssue? = { snapshot, reason in @@ -17,10 +23,16 @@ enum TeraNativeRecoveryInventory { lookup: @Sendable (String) async throws -> TeraNativeUploadRecoveryOwner? ) async throws -> (progress: TeraNativeRecoveryProgress, cursor: String?) { let snapshots = try await transfer.snapshots() - .filter { $0.state == .awaitingVerification } + .filter { $0.state == .awaitingVerification || $0.state == .completed } .map { (key: TeraNativeRecoveryIssue.key($0.identifier.rawValue), snapshot: $0) } .sorted { $0.key < $1.key } - let pending = snapshots.filter { cursor == nil || $0.key > (cursor ?? "") } + let pending = snapshots.filter { + if let selected = inspection.selectedKey { + return $0.key == selected + } + return cursor == nil || $0.key > (cursor ?? "") + } + guard inspection.selectedKey == nil || pending.count == 1 else { throw TeraComposerAcknowledgment.unconfirmed } var attention = false var issues: [TeraNativeRecoveryIssue] = [] var pause: TeraNativeRecoveryPause? @@ -29,9 +41,13 @@ enum TeraNativeRecoveryInventory { recovery: for position in pending.prefix(passLimit) { let snapshot = position.snapshot try Task.checkCancellation() - switch try await recover(snapshot, complete: complete, lookup: lookup) { + switch try await recover(snapshot, inspection: inspection, complete: complete, lookup: lookup) { case .complete: - _ = await report(snapshot, .resolved) + if let resolved = await report(snapshot, .resolved) { + issues.append(resolved) + attention = attention || resolved.reason != .resolved + } + case .ignored: break case let .issue(reason): attention = true if let issue = await report(snapshot, reason) { @@ -53,13 +69,18 @@ enum TeraNativeRecoveryInventory { return (.init(visited: visited, remaining: remaining, needsAttention: attention, issues: issues, pause: pause), remaining > 0 ? last : nil) } - private enum Outcome { case complete, issue(TeraNativeRecoveryReason), pause(TeraNativeRecoveryPause) } + private enum Outcome { case complete, ignored, issue(TeraNativeRecoveryReason), pause(TeraNativeRecoveryPause) } private static func recover(_ snapshot: RadrootsBackgroundTransferSnapshot, + inspection: TeraNativeRecoveryInspection, complete: @Sendable (RadrootsBackgroundTransferSnapshot, TeraNativeUploadRecoveryOwner) async throws -> Void, lookup: @Sendable (String) async throws -> TeraNativeUploadRecoveryOwner?) async throws -> Outcome { do { + if snapshot.state == .completed, inspection.selectedKey == nil, + try await !inspection.completedNeedsRepair(TeraNativeRecoveryIssue.key(snapshot.identifier.rawValue)) { + return .ignored + } guard let identity = TeraBackgroundUploadRequest.transferIdentity(snapshot.identifier) else { throw TeraNativeRecoveryFault.associationMismatch } guard let owner = try await lookup(identity.draftID) else { throw TeraNativeRecoveryFault.missingParent } guard owner.id == identity.draftID else { throw TeraNativeRecoveryFault.associationMismatch } diff --git a/Tera/Runtime/TeraNativeRecoveryStatus.swift b/Tera/Runtime/TeraNativeRecoveryStatus.swift @@ -7,7 +7,18 @@ enum TeraNativeRecoveryReason: Sendable, Equatable { } enum TeraNativeRecoveryPause: Sendable, Equatable { - case protectedData, storageUnavailable + case protectedData, storageUnavailable, quota, credentials, runtimeUnavailable + + var message: String { + let reason = switch self { + case .protectedData: "Photo recovery is paused until this device is unlocked." + case .storageUnavailable: "Photo recovery is paused until local storage is available." + case .quota: "Photo recovery is paused because local storage is full. Free space, then check again." + case .credentials: "Photo recovery is paused until this account’s credentials are available." + case .runtimeUnavailable: "Photo recovery is paused until this account’s runtime is available." + } + return "\(reason) Saved editing is still available." + } } enum TeraNativeRecoveryFault: Error { @@ -37,10 +48,16 @@ struct TeraNativeRecoveryIssue: Sendable, Equatable, Identifiable { enum TeraNativeRecoveryClassification { static func pause(_ error: Error) -> TeraNativeRecoveryPause? { + if let client = error as? TeraRuntimeClientError, case .notRunning = client { + return .runtimeUnavailable + } if let failure = TeraRuntimeFailure.from(error) { switch failure.recovery.disposition { case .protectedDataUnavailable: return .protectedData - case .storageFailure, .quotaExhausted, .runtimeUnavailable, .identityUnavailable: return .storageUnavailable + case .storageFailure: return .storageUnavailable + case .quotaExhausted: return .quota + case .identityUnavailable: return .credentials + case .runtimeUnavailable: return .runtimeUnavailable default: break } } @@ -63,9 +80,19 @@ enum TeraNativeRecoveryClassification { client: TeraRuntimeClient) async -> TeraNativeRecoveryIssue? { let key = TeraNativeRecoveryIssue.key(identifier) - let status = try? await client.reportNativeRecoveryStatus(key: key, reason: reason) - guard reason != .resolved else { return nil } - return .init(key: key, reason: reason, status: status) + do { + let status = try await client.reportNativeRecoveryStatus(key: key, reason: reason) + return .init(key: key, reason: reason, status: status) + } catch { + guard reason == .resolved else { return .init(key: key, reason: reason, status: nil) } + // A new store may have no transient notice. Read back a retained advisory + // after a failed resolution write; only durable resolved state clears it. + do { + return try await client.nativeRecoveryStatus(key: key).map { .init(key: key, reason: $0.reason, status: $0) } + } catch { + return .init(key: key, reason: .outcomeUnconfirmed, status: nil) + } + } } } diff --git a/Tera/Runtime/TeraRecoveryCompletion.swift b/Tera/Runtime/TeraRecoveryCompletion.swift @@ -10,7 +10,7 @@ struct TeraRecoveryUploadReceipt: Sendable { let response: TeraAddBackgroundUploadReceipt init(snapshot: RadrootsBackgroundTransferSnapshot, owner: TeraNativeUploadRecoveryOwner) throws { - guard snapshot.state == .awaitingVerification, + guard snapshot.state == .awaitingVerification || snapshot.state == .completed, let identity = TeraBackgroundUploadRequest.transferIdentity(snapshot.identifier), identity.draftID == owner.id, identity.revision <= owner.revision, let hash = snapshot.request.expectedSourceSHA256, diff --git a/Tera/Runtime/TeraSubmissionOperationModels.swift b/Tera/Runtime/TeraSubmissionOperationModels.swift @@ -50,6 +50,12 @@ struct TeraSubmissionStatus: Sendable, Equatable, Identifiable, CustomStringConv if media.contains(where: \.progress.possibleOrphan) { return "Photo delivery needs attention. A remote copy may exist." } + if delivery.state == .unknown { + return "Relay delivery outcome is unknown. Check the original request before continuing." + } + if delivery.state == .partiallyAccepted { + return "Some saved relays accepted this publication. Remaining delivery is not confirmed." + } switch state { case .draft: return "Saved on this device." case .mediaPreparing: return "Preparing photo." @@ -66,6 +72,11 @@ struct TeraSubmissionStatus: Sendable, Equatable, Identifiable, CustomStringConv } } + /// Presentation of an explicit action, never authority to bypass Rust policy. + var canOfferContinuation: Bool { + !delivery.isStopped && state != .complete && state != .cancelled && state != .terminal + } + var mediaSummary: String { let verified = media.filter { $0.progress.stage == .verified }.count let orphans = media.filter(\.progress.possibleOrphan).count diff --git a/Tera/State/TeraNativeRepairStore.swift b/Tera/State/TeraNativeRepairStore.swift @@ -14,7 +14,8 @@ final class TeraNativeRepairStore: ObservableObject { private var author: String? private var generation = TeraSessionGeneration.initial private var task: Task<Void, Never>? - private var pending = false + private enum Request { case sweep, transfer(String) } + private var pending: Request? init(client: TeraRuntimeClient, media: (any TeraAddMediaHandling)?) { self.client = client @@ -34,27 +35,36 @@ final class TeraNativeRepairStore: ObservableObject { func stop() { generation = generation.invalidated() - pending = false + pending = nil task?.cancel() } func retry() { + enqueue(.sweep) + } + + func check(_ issue: TeraNativeRecoveryIssue) { + guard issues.contains(where: { $0.key == issue.key }) else { return } + enqueue(.transfer(issue.key)) + } + + private func enqueue(_ request: Request) { guard !Task.isCancelled, generation.isActive else { return } // Coalesce live requests. A request after stop is retained until the old // worker actually returns, so cancellation cannot release its ownership. guard task == nil || task?.isCancelled == true else { return } - pending = true + pending = request startWorker() } private func startWorker() { - guard task == nil, pending, generation.isActive else { return } - pending = false + guard task == nil, let request = pending, generation.isActive else { return } + pending = nil let requested = generation isRunning = true task = Task { [weak self] in guard let self else { return } - await run(requested) + await run(requested, request: request) isRunning = false task = nil startWorker() @@ -78,10 +88,13 @@ final class TeraNativeRepairStore: ObservableObject { return true } - private func run(_ requested: TeraSessionGeneration) async { + private func run(_ requested: TeraSessionGeneration, request: Request) async { for _ in 0 ..< Self.batchLimit { guard requested == generation, !Task.isCancelled else { return } - await batch(requested) + await batch(requested, request: request) + if case .transfer = request { + return + } guard requested == generation, !Task.isCancelled, let progress, progress.pause == nil, progress.remaining > 0, progress.visited > 0 else { return } @@ -89,9 +102,12 @@ final class TeraNativeRepairStore: ObservableObject { } } - private func batch(_ requested: TeraSessionGeneration) async { + private func batch(_ requested: TeraSessionGeneration, request: Request) async { do { - let result = try await media?.recoverNativeUploads(client: client) + let result: TeraNativeRecoveryProgress? = switch request { + case .sweep: try await media?.recoverNativeUploads(client: client) + case let .transfer(key): try await media?.recoverNativeUpload(key: key, client: client) + } guard requested == generation, !Task.isCancelled else { return } if let result { guard (0 ... TeraNativeRecoveryInventory.passLimit).contains(result.visited), result.remaining >= 0, @@ -100,12 +116,18 @@ final class TeraNativeRepairStore: ObservableObject { let refreshed = await Self.refresh(issues, incoming: result?.issues ?? [], client: client) guard requested == generation, !Task.isCancelled else { return } issues = refreshed - progress = result - message = Self.message(result, retained: !issues.isEmpty) + if case .transfer = request, let result { + // A selected check does not advance the persistent sweep cursor. + progress = .init(visited: result.visited, remaining: progress?.remaining ?? 0, + needsAttention: result.needsAttention, issues: result.issues, pause: result.pause) + } else { + progress = result + } + message = Self.message(progress, retained: !issues.isEmpty) } catch { guard requested == generation, !Task.isCancelled else { return } let pause = TeraNativeRecoveryClassification.pause(error) - progress = .init(visited: 0, remaining: 0, needsAttention: pause == nil, pause: pause) + progress = .init(visited: 0, remaining: progress?.remaining ?? 0, needsAttention: pause == nil, pause: pause) message = Self.message(progress, retained: !issues.isEmpty) } } @@ -114,7 +136,8 @@ final class TeraNativeRepairStore: ObservableObject { client: TeraRuntimeClient) async -> [TeraNativeRecoveryIssue] { var values: [String: TeraNativeRecoveryIssue] = [:] - for issue in previous + incoming where issue.reason != .resolved { + let resolved = Set(incoming.filter { $0.reason == .resolved }.map(\.key)) + for issue in previous + incoming where issue.reason != .resolved && !resolved.contains(issue.key) { if values.count < previewLimit || values[issue.key] != nil { values[issue.key] = issue } @@ -135,10 +158,8 @@ final class TeraNativeRepairStore: ObservableObject { private static func message(_ progress: TeraNativeRecoveryProgress?, retained: Bool) -> String? { guard let progress else { return nil } - switch progress.pause { - case .protectedData: return "Photo recovery is paused until this device is unlocked. Saved editing is still available." - case .storageUnavailable: return "Photo recovery is paused until local storage is available. Saved editing is still available." - case nil: break + if let pause = progress.pause { + return pause.message } if progress.needsAttention || retained { return "Photo recovery needs attention. Saved editing is still available." diff --git a/Tera/State/TeraSubmissionStore.swift b/Tera/State/TeraSubmissionStore.swift @@ -62,6 +62,13 @@ final class TeraSubmissionStore: ObservableObject { } } + /// Explicit continuation uses only the retained original request/capture. + /// The runtime remains authoritative for every effect and retry admission. + func continueSelected() async { + guard hasAction, status?.canOfferContinuation != false else { return } + await run(advancing: true) + } + func stop() { generation = generation.invalidated() paused = true diff --git a/Tera/Views/TeraDraftsSheet.swift b/Tera/Views/TeraDraftsSheet.swift @@ -24,6 +24,7 @@ struct TeraDraftsSheet: View { TeraEditingProtectionActions(protection: protection) composerSection TeraSubmissionInventoryView(store: store.submissions) + TeraNativeRepairView(store: recovery.transfers) legacySection Section { Button("Reload from first page") { recovery.start() } diff --git a/Tera/Views/TeraNativeRepairView.swift b/Tera/Views/TeraNativeRepairView.swift @@ -6,25 +6,12 @@ struct TeraNativeRepairView: View { var body: some View { if store.message != nil || store.isRunning || !store.issues.isEmpty { Section("Saved photo recovery") { - if let message = store.message { - Text(message) - } - if let remaining = store.progress?.remaining, remaining > 0 { - Text("\(remaining) more saved photos remain to be checked.") - } + TeraNativeRepairProgressView(message: store.message, remaining: store.progress?.remaining) if store.issues.count == TeraNativeRepairStore.previewLimit { Text("Showing up to \(TeraNativeRepairStore.previewLimit) repair notices.") } ForEach(Array(store.issues.enumerated()), id: \.element.id) { index, issue in - VStack(alignment: .leading) { - Text("Saved photo \(index + 1)").font(.headline) - Text(issue.reason.message) - if issue.status == nil { - Text("This recovery status could not be saved. Check again when local storage is available.") - .foregroundStyle(.secondary) - } - } - .accessibilityElement(children: .combine) + TeraNativeRepairActions(issue: issue, index: index, isRunning: store.isRunning) { store.check(issue) } } if store.isRunning { ProgressView("Checking saved photos") @@ -38,7 +25,48 @@ struct TeraNativeRepairView: View { } } -private extension TeraNativeRecoveryReason { +struct TeraNativeRepairProgressView: View { + let message: String? + let remaining: Int? + + var body: some View { + if let message { + Text(message).accessibilityIdentifier("tera.add.recovery.message") + } + if let remaining, remaining > 0 { + Text("\(remaining) more saved transfer records remain to be checked in this pass.") + .accessibilityIdentifier("tera.add.recovery.remaining") + } + } +} + +struct TeraNativeRepairActions: View { + let issue: TeraNativeRecoveryIssue + let index: Int + let isRunning: Bool + let check: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Saved photo \(index + 1)").font(.headline) + Text(issue.reason.message) + .accessibilityIdentifier("tera.add.recovery.reason.\(issue.key)") + if issue.status == nil { + Text("This recovery status could not be saved. Check again when local storage is available.") + .foregroundStyle(.secondary) + } + Button("Check this photo’s saved evidence", action: check) + .disabled(isRunning) + .buttonStyle(.borderless) + .accessibilityIdentifier("tera.add.recovery.check.\(issue.key)") + Text("Checking keeps the evidence and does not start another upload. A missing or mismatched request must be restored before recovery can succeed.") + .font(.footnote) + } + .accessibilityElement(children: .contain) + } +} + +extension TeraNativeRecoveryReason { var message: String { switch self { case .missingParent: "The saved request for this photo could not be found. Its upload evidence has been kept." diff --git a/Tera/Views/TeraRecoveryUITestSurface.swift b/Tera/Views/TeraRecoveryUITestSurface.swift @@ -0,0 +1,32 @@ +#if DEBUG + import SwiftUI + + /// Runtime-free shell fixture using the same fact presentation and controls + /// as Add and Drafts. It has no transport, credentials or durable authority. + struct TeraRecoveryUITestSurface: View { + @State private var editing = "Keep editing during recovery" + @State private var scenario = 0 + @State private var checked = "none" + private let key = String(repeating: "a", count: 64) + + var body: some View { + Form { + TextField("Editing", text: $editing).accessibilityIdentifier("tera.test.recovery.editing") + Button("Next recovery state") { scenario = (scenario + 1) % 4 } + .accessibilityIdentifier("tera.test.recovery.next") + Text(checked).accessibilityIdentifier("tera.test.recovery.checked") + TeraNativeRepairProgressView(message: pause?.message, remaining: 12) + TeraNativeRepairActions(issue: .init(key: key, reason: scenario == 3 ? .associationMismatch : .outcomeUnconfirmed, status: nil), + index: 0, isRunning: false) { checked = key } + } + } + + private var pause: TeraNativeRecoveryPause? { + switch scenario { + case 1: .protectedData + case 2: .quota + default: nil + } + } + } +#endif diff --git a/Tera/Views/TeraSubmissionViews.swift b/Tera/Views/TeraSubmissionViews.swift @@ -11,11 +11,21 @@ struct TeraSubmissionStatusView: View { .accessibilityIdentifier("tera.add.submission.stop") } if store.isWorking { - ProgressView("Working on the captured submission…") + ProgressView(store.status?.summary ?? "Checking the original submission request…") .accessibilityIdentifier("tera.add.submission.progress") Button("Stop waiting") { store.stopWaiting() } .accessibilityIdentifier("tera.add.submission.stop_waiting") } + if !store.isWorking { + if store.request != nil { + Button("Check saved submission status") { Task { await store.refreshSelected() } } + .accessibilityIdentifier("tera.add.submission.check") + } + if store.status?.canOfferContinuation != false { + Button("Continue original submission") { Task { await store.continueSelected() } } + .accessibilityIdentifier("tera.add.submission.continue") + } + } if let status = store.status { Text(status.summary) .accessibilityIdentifier("tera.add.submission.status") diff --git a/TeraFFI/provenance.json b/TeraFFI/provenance.json @@ -94,17 +94,17 @@ { "bytes": 112575, "path": "source/aarch64-apple-darwin.json", - "sha256": "d4d5ec3a000020de768f7b2035262a7056dcb2822a4c9e2b7f44b50c6bfef481" + "sha256": "8c569072db26f806f5dcb1f5bdd90fd14437cc8af98557e48d95fef811d2a277" }, { "bytes": 112419, "path": "source/aarch64-apple-ios-sim.json", - "sha256": "3de510eb1397bf2c1a29f257e31ca590f751261af1ef2602c8c1be1645e38dc8" + "sha256": "db74d1007a6b21383f32065c820107acad264d3b25c3b710d8863aae478701bb" }, { "bytes": 112415, "path": "source/aarch64-apple-ios.json", - "sha256": "377df80376c57924661bbacdd25f210f49d035a3dc12976338034c5a651acb81" + "sha256": "083c2c3a9ff7e25126ce085d531d87e193ded11c763d8f92eb2d1322ea8eca9a" } ], "language": "swift", @@ -112,7 +112,7 @@ "schema": "radroots.artifact-manifest.v2", "source": { "repository": "https://github.com/radrootslabs/tera", - "tree": "ce639f8191f2b9bf2ac577da0e185cdd18413c5d" + "tree": "c3a6e381147c2d7e7f4ef11c74aad825fbceb30f" }, "source_records": { "aarch64-apple-darwin": "source/aarch64-apple-darwin.json", @@ -176,17 +176,17 @@ { "bytes": 112575, "path": "TeraFFI/source/aarch64-apple-darwin.json", - "sha256": "d4d5ec3a000020de768f7b2035262a7056dcb2822a4c9e2b7f44b50c6bfef481" + "sha256": "8c569072db26f806f5dcb1f5bdd90fd14437cc8af98557e48d95fef811d2a277" }, { "bytes": 112419, "path": "TeraFFI/source/aarch64-apple-ios-sim.json", - "sha256": "3de510eb1397bf2c1a29f257e31ca590f751261af1ef2602c8c1be1645e38dc8" + "sha256": "db74d1007a6b21383f32065c820107acad264d3b25c3b710d8863aae478701bb" }, { "bytes": 112415, "path": "TeraFFI/source/aarch64-apple-ios.json", - "sha256": "377df80376c57924661bbacdd25f210f49d035a3dc12976338034c5a651acb81" + "sha256": "083c2c3a9ff7e25126ce085d531d87e193ded11c763d8f92eb2d1322ea8eca9a" } ], "schema": "tera.installed-native-artifacts.v1" diff --git a/TeraFFI/source.lock b/TeraFFI/source.lock @@ -1,7 +1,7 @@ schema = "tera.installed-source.v1" repository = "https://github.com/radrootslabs/tera" -source_tree = "ce639f8191f2b9bf2ac577da0e185cdd18413c5d" -manifest_sha256 = "b3e54ff6ead86c8bcfcf2ff5564d01fdd7c01f415c8a517679894107e0f52bda" +source_tree = "c3a6e381147c2d7e7f4ef11c74aad825fbceb30f" +manifest_sha256 = "37568fa6d07527c70af4036c3782eee20371aaa634919132a5fda8b2c38e9e6e" source_date_epoch = 1787871027 [foundation] diff --git a/TeraFFI/source/aarch64-apple-darwin.json b/TeraFFI/source/aarch64-apple-darwin.json @@ -2391,13 +2391,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 161704, - "git_blob": "32c6c8f0f55d9fb463540c5791791b1235d8a5f2", + "bytes": 161810, + "git_blob": "0ebd8ff76767a4e1fbd137c15439c4084f88efc1", "mode": "100644", - "sha256": "b3c115274b049c56a74f4598abde14c8f3569b06abb0e06202888866f5140be2" + "sha256": "4f7f1f2b033105e73bcaf426642042a19ca756ffc36d2bf357acdb32f68fd509" } }, "policy": "staged_inputs", - "tree": "ce639f8191f2b9bf2ac577da0e185cdd18413c5d" + "tree": "c3a6e381147c2d7e7f4ef11c74aad825fbceb30f" } } diff --git a/TeraFFI/source/aarch64-apple-ios-sim.json b/TeraFFI/source/aarch64-apple-ios-sim.json @@ -2387,13 +2387,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 161704, - "git_blob": "32c6c8f0f55d9fb463540c5791791b1235d8a5f2", + "bytes": 161810, + "git_blob": "0ebd8ff76767a4e1fbd137c15439c4084f88efc1", "mode": "100644", - "sha256": "b3c115274b049c56a74f4598abde14c8f3569b06abb0e06202888866f5140be2" + "sha256": "4f7f1f2b033105e73bcaf426642042a19ca756ffc36d2bf357acdb32f68fd509" } }, "policy": "staged_inputs", - "tree": "ce639f8191f2b9bf2ac577da0e185cdd18413c5d" + "tree": "c3a6e381147c2d7e7f4ef11c74aad825fbceb30f" } } diff --git a/TeraFFI/source/aarch64-apple-ios.json b/TeraFFI/source/aarch64-apple-ios.json @@ -2387,13 +2387,13 @@ "sha256": "b052a73a824e8f8b26af2646a4758f13655e04de9551a8271890d3cf2b63209d" }, "test-fixtures/legacy-identifiers.v1.json": { - "bytes": 161704, - "git_blob": "32c6c8f0f55d9fb463540c5791791b1235d8a5f2", + "bytes": 161810, + "git_blob": "0ebd8ff76767a4e1fbd137c15439c4084f88efc1", "mode": "100644", - "sha256": "b3c115274b049c56a74f4598abde14c8f3569b06abb0e06202888866f5140be2" + "sha256": "4f7f1f2b033105e73bcaf426642042a19ca756ffc36d2bf357acdb32f68fd509" } }, "policy": "staged_inputs", - "tree": "ce639f8191f2b9bf2ac577da0e185cdd18413c5d" + "tree": "c3a6e381147c2d7e7f4ef11c74aad825fbceb30f" } } diff --git a/TeraTests/TeraNativeRepairPreviewTests.swift b/TeraTests/TeraNativeRepairPreviewTests.swift @@ -44,6 +44,26 @@ final class TeraNativeRepairPreviewTests: XCTestCase { _ = try await client.stop() } + func testSelectedCheckClearsOnlyAcknowledgedKeyAndRetainsOtherNotice() async throws { + let backend = try TeraScopeBackend() + let client = try await TeraScopeFixtures.client(backend) + let first = Self.issue(1), second = Self.issue(2) + let pages = NativeRepairPages([.init(visited: 0, remaining: 12, needsAttention: true, issues: [first, second], pause: .protectedData)]) + let store = TeraNativeRepairStore(client: client, media: pages) + await store.reconcile() + store.check(Self.issue(3)) + XCTAssertFalse(store.isRunning, "Unretained keys cannot be admitted by a stale UI action") + store.check(first) + await TeraScopeFixtures.eventually { !store.isRunning } + let keys = await pages.selectedKeys + XCTAssertEqual(keys, [first.key]) + XCTAssertEqual(store.issues, [second]) + XCTAssertEqual(store.progress?.remaining, 12, "Selected check preserves the unvisited sweep positions") + XCTAssertNotNil(store.message) + store.stop() + _ = try await client.stop() + } + private static func issue(_ index: Int) -> TeraNativeRecoveryIssue { .init(key: String(format: "%064x", index), reason: .missingParent, status: nil) } @@ -61,6 +81,7 @@ extension TeraScopeBackend { private actor NativeRepairPages: TeraAddMediaHandling { var pages: [TeraNativeRecoveryProgress] + private(set) var selectedKeys: [String] = [] init(_ pages: [TeraNativeRecoveryProgress]) { self.pages = pages } @@ -73,6 +94,11 @@ private actor NativeRepairPages: TeraAddMediaHandling { pages.isEmpty ? .init(visited: 0, remaining: 0, needsAttention: false) : pages.removeFirst() } + func recoverNativeUpload(key: String, client _: TeraRuntimeClient) -> TeraNativeRecoveryProgress { + selectedKeys.append(key) + return .init(visited: 1, remaining: 0, needsAttention: false, issues: [.init(key: key, reason: .resolved, status: nil)]) + } + func importImages(limit _: Int) throws -> [TeraPreparedMedia] { throw TeraComposerAcknowledgment.unconfirmed } diff --git a/TeraTests/TeraNativeRepairTests.swift b/TeraTests/TeraNativeRepairTests.swift @@ -36,7 +36,7 @@ final class TeraNativeRepairTests: XCTestCase { XCTAssertTrue(first.progress.issues.contains { $0.reason == .associationMismatch }) XCTAssertTrue(first.progress.issues.allSatisfy { $0.status?.revision == 1 }) let replay = try await run() - XCTAssertEqual(replay.progress.visited, 2) + XCTAssertEqual(replay.progress.visited, 3, "Completed positions are visited without repeating settlement") XCTAssertEqual(replay.progress.issues, first.progress.issues) let counts = await transfer.counts XCTAssertEqual(counts.acceptedSettlement, 1) diff --git a/TeraTests/TeraOperationRecoveryTests.swift b/TeraTests/TeraOperationRecoveryTests.swift @@ -0,0 +1,128 @@ +import Foundation +import RadrootsKit +@testable import TeraApp +import XCTest + +final class TeraOperationRecoveryTests: XCTestCase { + @MainActor + func testSelectedTransferUsesExactKeyWithoutMovingSweepOrAdmittingSecondWorker() async throws { + let fixture = try BackgroundUploadFixture() + defer { fixture.remove() } + let transfer = BackgroundTransferHarness() + let selected = try fixture.request(job: fixture.job(revision: 2, operation: String(repeating: "a", count: 32))) + let other = try fixture.request(job: fixture.job(revision: 2, operation: String(repeating: "b", count: 32))) + try await transfer.seed(request: selected, state: .awaitingVerification) + try await transfer.seed(request: other, state: .awaitingVerification) + let backend = try TeraScopeBackend() + let client = try await TeraScopeFixtures.client(backend) + let coordinator = fixture.coordinator(transfer: transfer) + let prior = try await client.nativeRecoverySchedule() + let key = TeraNativeRecoveryIssue.key(selected.identifier.rawValue) + let pause = ResourceTestPause() + await transfer.pauseDiscovery(pause) + let task = Task { try await coordinator.recoverNativeUpload(key: key, client: client) } + await pause.entered.wait() + do { + _ = try await coordinator.recoverNativeUploads(client: client) + XCTFail("Selected check must retain the same recovery admission") + } catch {} + await pause.resume.open() + let result = try await task.value + XCTAssertEqual(result.visited, 1) + XCTAssertEqual(result.remaining, 0) + XCTAssertEqual(result.issues.map(\.key), [key]) + let after = try await client.nativeRecoverySchedule() + XCTAssertEqual(after, prior) + for absent in [String(repeating: "0", count: 64), "invalid"] { + do { + _ = try await coordinator.recoverNativeUpload(key: absent, client: client) + XCTFail("An absent or invalid selected record is not success") + } catch {} + } + let counts = await transfer.counts + XCTAssertEqual(counts.enqueue, 0) + XCTAssertEqual(counts.acceptedSettlement, 0) + _ = try await client.stop() + } + + func testCompletedTransferRepairsLostAdvisoryWriteAfterRuntimeReopen() async throws { + let fixture = try BackgroundUploadFixture() + let storage = try MediaOwnershipFixture() + defer { fixture.remove(); storage.remove() } + let transfer = BackgroundTransferHarness() + let request = try fixture.request(job: fixture.job(revision: 2, operation: String(repeating: "a", count: 32))) + try await transfer.seed(request: request, state: .awaitingVerification) + let key = TeraNativeRecoveryIssue.key(request.identifier.rawValue) + let first = try await storage.runtime() + let backend = TeraGeneratedRuntimeBackend(runtime: first) + _ = try await backend.reportNativeRecoveryStatus(key: key, reason: .outcomeUnconfirmed) + let owner = TeraNativeUploadRecoveryOwner(draft: fixture.draft(revision: 3, stage: .verified)) + // Settle native evidence, then model the lost advisory write by retaining it. + _ = try await RecoverySettlementFixture.run(transfer: transfer, cursor: nil) { _ in owner } + _ = try await first.shutdown() + let reopened = try await storage.runtime() + let next = TeraGeneratedRuntimeBackend(runtime: reopened) + let inspection = TeraNativeRecoveryInspection(completedNeedsRepair: { key in + let status = try await next.nativeRecoveryStatus(key: key) + return status != nil && status?.reason != .resolved + }) + let result = try await TeraNativeRecoveryInventory.run(transfer: transfer, cursor: nil, inspection: inspection, complete: { snapshot, exactOwner in + let input = try TeraRecoveryUploadReceipt(snapshot: snapshot, owner: exactOwner) + try await TeraNativeUploadReconciliation.settle(snapshot, input: input, receipt: RecoverySettlementFixture.receipt(input), transfer: transfer) + }, report: { snapshot, reason in + let key = TeraNativeRecoveryIssue.key(snapshot.identifier.rawValue) + let status = try? await next.reportNativeRecoveryStatus(key: key, reason: reason) + return .init(key: key, reason: reason, status: status) + }, lookup: { _ in owner }) + XCTAssertEqual(result.progress.issues.map(\.reason), [.resolved]) + let saved = try await next.nativeRecoveryStatus(key: key) + XCTAssertEqual(saved?.reason, .resolved) + XCTAssertEqual(saved?.revision, 2) + let counts = await transfer.counts + XCTAssertEqual(counts.acceptedSettlement, 1) + XCTAssertEqual(counts.enqueue, 0) + XCTAssertEqual(counts.retry, 0) + _ = try await reopened.shutdown() + } + + func testCompletedEvidenceStillRequiresExactParentAndAssociation() async throws { + let fixture = try BackgroundUploadFixture() + defer { fixture.remove() } + let transfer = BackgroundTransferHarness() + let request = try fixture.request(job: fixture.job(revision: 2, operation: String(repeating: "a", count: 32))) + try await transfer.seed(request: request, state: .completed) + let inspection = TeraNativeRecoveryInspection(completedNeedsRepair: { _ in true }) + let missing = try await TeraNativeRecoveryInventory.run(transfer: transfer, cursor: nil, inspection: inspection) { _ in nil } + XCTAssertEqual(missing.progress.issues.map(\.reason), [.missingParent]) + let mismatch = try await TeraNativeRecoveryInventory.run(transfer: transfer, cursor: nil, inspection: inspection, complete: { _, _ in + throw TeraNativeRecoveryFault.associationMismatch + }) { _ in .init(draft: fixture.draft(revision: 3, stage: .verified)) } + XCTAssertEqual(mismatch.progress.issues.map(\.reason), [.associationMismatch]) + let counts = await transfer.counts + XCTAssertEqual(counts.acceptedSettlement, 0) + XCTAssertEqual(counts.cancel, 0) + } + + @MainActor + func testFailedResolvedReportCannotClearNoticeAndPauseReasonsRemainDistinct() async throws { + let backend = try TeraScopeBackend() + let client = try await TeraScopeFixtures.client(backend) + let result = await TeraNativeRecoveryClassification.report("retained", reason: .resolved, client: client) + XCTAssertNil(result) + let saved = TeraNativeRecoveryStatus(key: TeraNativeRecoveryIssue.key("retained"), reason: .outcomeUnconfirmed, + revision: 1, firstObservedUnixMS: 1, updatedAtUnixMS: 1) + await backend.setNativeRepairValue(saved) + let unresolved = await TeraNativeRecoveryClassification.report("retained", reason: .resolved, client: client) + XCTAssertEqual(unresolved, .init(key: saved.key, reason: saved.reason, status: saved), "A fresh store must see a retained advisory when resolution fails") + let reasons: [(String, TeraNativeRecoveryPause)] = [ + ("protected_data_unavailable", .protectedData), ("today_media_quota_exceeded", .quota), + ("identity_unavailable", .credentials), ("today_runtime_unavailable", .runtimeUnavailable), + ("store_path_unavailable", .storageUnavailable), + ] + for (code, pause) in reasons { + let failure = TeraRuntimeFailure.local(operation: "test.recovery", code: code, safeMessage: "Paused.") + XCTAssertEqual(TeraNativeRecoveryClassification.pause(failure), pause) + } + _ = try await client.stop() + } +} diff --git a/TeraTests/TeraRecoveryInventoryTests.swift b/TeraTests/TeraRecoveryInventoryTests.swift @@ -83,14 +83,27 @@ final class TeraRecoveryInventoryTests: XCTestCase { XCTAssertEqual(counts.acceptedSettlement, 999) XCTAssertEqual(counts.enqueue, 0) XCTAssertEqual(counts.retry, 0) - let fresh = try await RecoverySettlementFixture.run(transfer: transfer, cursor: nil) { key in - Self.owner(key: key, template: verified) - } - XCTAssertEqual(fresh.progress, .init(visited: 1, remaining: 0, needsAttention: false)) + let revisited = try await Self.revisit(transfer: transfer, verified: verified) + XCTAssertEqual(revisited, 1000, "Completed positions remain part of the bounded live sweep") let finalCounts = await transfer.counts XCTAssertEqual(finalCounts.acceptedSettlement, 1000) } + private static func revisit(transfer: BackgroundTransferHarness, verified: TeraNativeUploadRecoveryOwner) async throws -> Int { + var cursor: String? + var revisited = 0 + repeat { + let fresh = try await RecoverySettlementFixture.run(transfer: transfer, cursor: cursor) { key in + Self.owner(key: key, template: verified) + } + XCTAssertLessThanOrEqual(fresh.progress.visited, 64) + XCTAssertFalse(fresh.progress.needsAttention) + revisited += fresh.progress.visited + cursor = fresh.cursor + } while cursor != nil + return revisited + } + func testCancelledExactLookupRetainsReceiptAndFreshPassRecovers() async throws { let fixture = try BackgroundUploadFixture() defer { fixture.remove() } diff --git a/TeraTests/TeraSubmissionStoreTests.swift b/TeraTests/TeraSubmissionStoreTests.swift @@ -194,8 +194,13 @@ final class TeraSubmissionStoreTests: XCTestCase { XCTAssertEqual(after, before) XCTAssertEqual(replacement.submissions.status, saved) XCTAssertEqual(replacement.form.content, "reopened editing") - await replacement.submit() + await replacement.submissions.refreshSelected() + let checked = await backend.submissionBackend.advanceCount + XCTAssertEqual(checked, before, "Checking status must not begin a publication effect") + await replacement.submissions.continueSelected() XCTAssertEqual(replacement.submissions.status?.operationID, saved.operationID) + XCTAssertEqual(replacement.submissions.request, saved.request) + XCTAssertEqual(replacement.form.content, "reopened editing") _ = try await client.stop() } diff --git a/TeraUITests/TeraRecoveryUITests.swift b/TeraUITests/TeraRecoveryUITests.swift @@ -0,0 +1,32 @@ +import XCTest + +final class TeraRecoveryUITests: XCTestCase { + @MainActor + func testUnknownLockedQuotaAndMismatchKeepEditingAndExposeSelectedCheck() { + let app = XCUIApplication() + app.launchEnvironment["TERA_IOS_UI_TEST_SHELL"] = "1" + app.launchEnvironment["TERA_IOS_UI_TEST_RECOVERY"] = "1" + app.launch() + XCTAssertTrue(app.tabBars.firstMatch.waitForExistence(timeout: 5)) + app.tabBars.firstMatch.buttons["Add"].tap() + let key = String(repeating: "a", count: 64) + let editing = app.textFields["tera.test.recovery.editing"] + XCTAssertTrue(editing.waitForExistence(timeout: 5)) + let reason = app.staticTexts["tera.add.recovery.reason.\(key)"] + XCTAssertTrue(reason.label.contains("not yet confirmed")) + XCTAssertTrue(app.staticTexts["tera.add.recovery.remaining"].label.contains("12 more saved transfer records")) + let check = app.buttons["tera.add.recovery.check.\(key)"] + XCTAssertTrue(check.isEnabled) + check.tap() + XCTAssertEqual(app.staticTexts["tera.test.recovery.checked"].label, key) + let next = app.buttons["tera.test.recovery.next"] + next.tap() + XCTAssertTrue(app.staticTexts["tera.add.recovery.message"].label.contains("device is unlocked")) + next.tap() + XCTAssertTrue(app.staticTexts["tera.add.recovery.message"].label.contains("storage is full")) + next.tap() + XCTAssertTrue(reason.label.contains("does not match")) + XCTAssertEqual(editing.value as? String, "Keep editing during recovery") + XCTAssertTrue(check.isEnabled) + } +} diff --git a/project.yml b/project.yml @@ -56,6 +56,7 @@ targets: - path: TeraTests/TeraRecoveryInventoryTests.swift - path: TeraTests/TeraNativeRecoveryScheduleTestStorage.swift - path: TeraTests/TeraRecoveryContinuationTests.swift + - path: TeraTests/TeraOperationRecoveryTests.swift - path: TeraTests/TeraRecoverySchedulingTests.swift - path: TeraTests/TeraRecoveryCompletionTests.swift - path: TeraTests/TeraRecoverySettlementTests.swift @@ -165,6 +166,7 @@ targets: sources: - path: TeraUITests/TeraRootShellUITests.swift - path: TeraUITests/TeraEditingProtectionUITests.swift + - path: TeraUITests/TeraRecoveryUITests.swift - path: TeraUITests/TeraPhotoQualificationInput.swift - path: TeraUITests/TeraRemoteQualificationUITests.swift - path: TeraUITests/TeraSubmissionQualificationSupport.swift diff --git a/release/provenance.json b/release/provenance.json @@ -2,11 +2,11 @@ "artifacts": { "app_api_sha256": "020924097c0d7efc33128cb8fd3d3b2026d95f57c44da71880e585aff80f070b", "ffi_api_sha256": "7074bfaa883baa053f6112396210515438249f13f7852a29cb9501aba080d539", - "ffi_provenance_sha256": "b3e54ff6ead86c8bcfcf2ff5564d01fdd7c01f415c8a517679894107e0f52bda", + "ffi_provenance_sha256": "37568fa6d07527c70af4036c3782eee20371aaa634919132a5fda8b2c38e9e6e", "info_plist_sha256": "15ef08b1cdd1096cfb9eeaf5be5bf8f814807a7ca9350bbbb47860fa72ec13ef", "privacy_manifest_sha256": "a331d51864743ebe4e00dd22360b4a538b6b3ac26a6b3eb54094e60a36959a12", "sbom_sha256": "dfcf223cee3f889130fc1826ad5e134c6603d15fbfcdcb4f7eefc73b4ee8b471", - "xcode_project_sha256": "47f5ab09039aa738a77324fc2cbcff807832501f047ee2368a87cc050fffa144" + "xcode_project_sha256": "809ca1906c92398842e911e6a94580b665799520eb4bfaffcd286aecb6f849d9" }, "disposition": "unsigned", "platforms": [ @@ -22,7 +22,7 @@ "lib_revision": "70abf6eb6bcbf07f4074b6fba237add4681c00eb", "source_date_epoch": 1787871027, "swift_package_lock_sha256": "a7e31d77d31ecbc1e0e5a78f2681e3fb0c6f367af0edf8b4ceef0af973a78681", - "tera_ffi_source_tree": "ce639f8191f2b9bf2ac577da0e185cdd18413c5d", + "tera_ffi_source_tree": "c3a6e381147c2d7e7f4ef11c74aad825fbceb30f", "xcode_package_lock_sha256": "d98a614a38dce7b55c2925dee51c2d4bf8dcfc4e31779d0c5fe8561c45d6e3ba" }, "version": "0.1.0-alpha" diff --git a/test-fixtures/legacy-identifiers.v1.json b/test-fixtures/legacy-identifiers.v1.json @@ -1631,6 +1631,10 @@ "count": 1 }, { + "path": "TeraTests/TeraOperationRecoveryTests.swift", + "count": 1 + }, + { "path": "TeraTests/TeraRecoveryCompletionTests.swift", "count": 1 },