Fix backup progress state machine: pending queue, resolvedStatus, failure resolution
- BackupEngine: pre-filter assets against ManifestIndex before job.start() so totalFiles reflects only pending items (not full gallery). Short-circuit with allAlreadySafe() when queue is empty. - BackupJob: add resolveSuccess() (upgrades stale .failed → .completed after reconciliation proves needBackup==0) and allAlreadySafe() for empty-queue case. - BackupStatusService: expose refreshAndWait(force:) async for pull-to-refresh and post-failure reconciliation. - BackupView: wire resolvedStatus throughout ringContentID, ringMainView, ringColor, and actionButton; fix progress label to show pending-queue denominator; handle .failed in onChange by reconciling and auto-resolving to .completed when counts confirm all photos are safe. Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
@@ -77,6 +77,18 @@ final class BackupJob: ObservableObject {
|
||||
: .failed("Completed with \(result.failedCount) error\(result.failedCount == 1 ? "" : "s")")
|
||||
}
|
||||
|
||||
/// Call after reconciliation proves needBackup == 0 — upgrades stale .failed to .completed.
|
||||
func resolveSuccess() {
|
||||
if case .failed = status { status = .completed }
|
||||
}
|
||||
|
||||
/// Used when the pending queue is empty at backup start — nothing to do.
|
||||
func allAlreadySafe() {
|
||||
totalFiles = 0; uploadedFiles = 0; skippedFiles = 0; failedFiles = 0
|
||||
startDate = Date(); endDate = Date()
|
||||
status = .completed
|
||||
}
|
||||
|
||||
func fail(error: BackupError) {
|
||||
endDate = Date()
|
||||
status = .failed(error.errorDescription ?? error.localizedDescription)
|
||||
|
||||
@@ -17,25 +17,28 @@ struct BackupView: View {
|
||||
|
||||
private let ringPageCount = 4
|
||||
|
||||
// Counts are the source of truth for final display state.
|
||||
// If job ended in .failed but reconciliation shows needBackup == 0, treat as .completed.
|
||||
private var resolvedStatus: BackupStatus {
|
||||
if case .failed = engine.job.status,
|
||||
!statusService.isRefreshing,
|
||||
statusService.snapshot.phoneTotal > 0,
|
||||
statusService.snapshot.needBackup == 0 {
|
||||
return .completed
|
||||
}
|
||||
return engine.job.status
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
AppTheme.background.ignoresSafeArea()
|
||||
|
||||
// ─── Menu — aligned with kisani. wordmark ──────────────────
|
||||
Button { showMenu = true } label: {
|
||||
Image(systemName: "line.3.horizontal")
|
||||
.font(.system(size: 14, weight: .regular))
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.accessibilityLabel("Activity menu")
|
||||
.padding(.top, 4)
|
||||
.padding(.trailing, AppTheme.hPad - 4)
|
||||
|
||||
// ─── Scrollable content with pull-to-refresh ──────────────
|
||||
GeometryReader { geo in
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
VStack(spacing: 0) {
|
||||
|
||||
// ─── Brand ───────────────────────────────────────────
|
||||
// ─── Brand ────────────────────────────────────
|
||||
VStack(spacing: 10) {
|
||||
Text("kisani.")
|
||||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||||
@@ -46,34 +49,31 @@ struct BackupView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 16)
|
||||
|
||||
// flex zone — brand ↔ ring (absorbs screen-size variance)
|
||||
Spacer(minLength: 24).frame(maxHeight: 48)
|
||||
|
||||
// ─── Ring ─────────────────────────────────────────────
|
||||
// ─── Ring ─────────────────────────────────────
|
||||
ringHero
|
||||
.padding(.bottom, 44)
|
||||
|
||||
// ─── Stats ────────────────────────────────────────────
|
||||
// ─── Stats ────────────────────────────────────
|
||||
statsStrip
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.97)))
|
||||
|
||||
// flex zone — stats ↔ cards
|
||||
Spacer(minLength: 20).frame(maxHeight: 48)
|
||||
|
||||
// ─── Storage card ─────────────────────────────────────
|
||||
// ─── Storage card ─────────────────────────────
|
||||
nasStatusRow
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
// ─── Source card ──────────────────────────────────────
|
||||
// ─── Destination card ──────────────────────────
|
||||
destinationRow
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
|
||||
// flex zone — cards ↔ CTA
|
||||
Spacer(minLength: 20).frame(maxHeight: 32)
|
||||
|
||||
// ─── Photos access nudge ──────────────────────────────
|
||||
// ─── Photos access nudge ──────────────────────
|
||||
if vm.photosAuthStatus != .authorized && vm.photosAuthStatus != .limited {
|
||||
photosAccessRow
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
@@ -81,14 +81,35 @@ struct BackupView: View {
|
||||
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||
}
|
||||
|
||||
// ─── CTA ──────────────────────────────────────────────
|
||||
// ─── CTA ──────────────────────────────────────
|
||||
actionButton
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.bottom, 24)
|
||||
|
||||
// bottom absorber — soaks up remaining height to anchor cards+CTA
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(minHeight: geo.size.height)
|
||||
}
|
||||
.refreshable {
|
||||
await statusService.refreshAndWait(force: true)
|
||||
// After reconciliation, clear stale failure if counts say all safe
|
||||
if statusService.snapshot.needBackup == 0 {
|
||||
engine.resolveSuccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Menu button — floats above scroll content ─────────────
|
||||
Button { showMenu = true } label: {
|
||||
Image(systemName: "line.3.horizontal")
|
||||
.font(.system(size: 14, weight: .regular))
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.accessibilityLabel("Activity menu")
|
||||
.padding(.top, 4)
|
||||
.padding(.trailing, AppTheme.hPad - 4)
|
||||
}
|
||||
.navigationTitle("")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -124,15 +145,24 @@ struct BackupView: View {
|
||||
}
|
||||
.onChange(of: engine.job.status) { status in
|
||||
if status == .completed {
|
||||
// Flash green then revert to black
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { ctaFlashGreen = true }
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 2_500_000_000)
|
||||
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { ctaFlashGreen = false }
|
||||
// Full reconcile after flash to get accurate counts
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
statusService.refresh(force: true)
|
||||
}
|
||||
} else if case .failed = status {
|
||||
// Reconcile — if all files landed on NAS despite the failure, auto-resolve to .completed
|
||||
Task {
|
||||
await statusService.refreshAndWait(force: true)
|
||||
if statusService.snapshot.needBackup == 0 {
|
||||
engine.resolveSuccess()
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { ctaFlashGreen = true }
|
||||
try? await Task.sleep(nanoseconds: 2_500_000_000)
|
||||
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { ctaFlashGreen = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,7 +211,7 @@ struct BackupView: View {
|
||||
|
||||
// ID that changes on both page swipe and status transition — drives the ring animation
|
||||
private var ringContentID: String {
|
||||
switch engine.job.status {
|
||||
switch resolvedStatus {
|
||||
case .idle, .cancelled: return "\(ringPage)_idle"
|
||||
case .preparing: return "\(ringPage)_preparing"
|
||||
case .running: return "\(ringPage)_running"
|
||||
@@ -238,7 +268,7 @@ struct BackupView: View {
|
||||
// Page 0 content — changes based on backup state
|
||||
@ViewBuilder
|
||||
private var ringMainView: some View {
|
||||
switch engine.job.status {
|
||||
switch resolvedStatus {
|
||||
case .idle, .cancelled:
|
||||
if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 {
|
||||
VStack(spacing: 8) {
|
||||
@@ -279,7 +309,7 @@ struct BackupView: View {
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
.contentTransition(.numericText())
|
||||
.monospacedDigit()
|
||||
Text("\(engine.job.uploadedFiles) of \(engine.job.totalFiles) backed up")
|
||||
Text("Backing up \(engine.job.uploadedFiles + engine.job.skippedFiles) of \(engine.job.totalFiles)")
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
if let eta = engine.job.eta {
|
||||
@@ -326,15 +356,11 @@ struct BackupView: View {
|
||||
}
|
||||
|
||||
private var ringColor: Color {
|
||||
// Active: always orange
|
||||
if engine.job.status.isActive { return Color(red: 1.0, green: 0.58, blue: 0.0) }
|
||||
// Failed: red
|
||||
if case .failed = engine.job.status { return AppTheme.destructive }
|
||||
// Green only when ALL phone photos are accounted for (needBackup == 0)
|
||||
if resolvedStatus.isActive { return Color(red: 1.0, green: 0.58, blue: 0.0) }
|
||||
if case .failed = resolvedStatus { return AppTheme.destructive }
|
||||
if statusService.snapshot.phoneTotal > 0 && statusService.snapshot.needBackup == 0 {
|
||||
return AppTheme.positive
|
||||
}
|
||||
// Default: neutral
|
||||
return AppTheme.inkQuaternary.opacity(0.55)
|
||||
}
|
||||
|
||||
@@ -608,10 +634,10 @@ struct BackupView: View {
|
||||
|
||||
private var actionButton: some View {
|
||||
Group {
|
||||
switch engine.job.status {
|
||||
switch resolvedStatus {
|
||||
case .idle, .completed, .failed, .cancelled:
|
||||
Button(action: startBackup) {
|
||||
Text(engine.job.status == .idle ? "Start backup" : "Back up again")
|
||||
Text(resolvedStatus == .idle ? "Start backup" : "Back up again")
|
||||
}
|
||||
.buttonStyle(PrimaryButtonStyle(
|
||||
color: ctaFlashGreen ? AppTheme.positive : AppTheme.ink
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
90F628AC04DC05FDCBAE52D9 /* SyncView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncView.swift; sourceTree = "<group>"; };
|
||||
966927456571CE45600BEF75 /* BackupManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupManifest.swift; sourceTree = "<group>"; };
|
||||
9EF7C3E36ACFD6096DE0677A /* ConnectViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewModel.swift; sourceTree = "<group>"; };
|
||||
A1DAA26CE0A4DFFDD90370F2 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
A1DAA26CE0A4DFFDD90370F2 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
B3306187119851CE3E989408 /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
|
||||
B70F1C01E131F5F2798200E2 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
|
||||
BBBDD83322981C31F4DE00FF /* SavedConnections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedConnections.swift; sourceTree = "<group>"; };
|
||||
@@ -124,7 +124,7 @@
|
||||
E309F9924060F6C3C2FE36D4 /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = "<group>"; };
|
||||
E956B8562EDB9259034CF8FF /* AppMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMenuView.swift; sourceTree = "<group>"; };
|
||||
E987653F4F7129568656EFDE /* BackupResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupResult.swift; sourceTree = "<group>"; };
|
||||
ED5F358675A200A5C0FF2289 /* NASBackup.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = NASBackup.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
ED5F358675A200A5C0FF2289 /* NASBackup.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = NASBackup.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EF029B56DE72F2D5D7977D95 /* SFTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFTPService.swift; sourceTree = "<group>"; };
|
||||
F052EE8B0A757532E4BCBCCD /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
|
||||
F0987D44494F1385E8D4876F /* KisaniLogoMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniLogoMark.swift; sourceTree = "<group>"; };
|
||||
@@ -439,7 +439,6 @@
|
||||
LastUpgradeCheck = 1500;
|
||||
TargetAttributes = {
|
||||
009689A0ADEB4878A288991E = {
|
||||
DevelopmentTeam = "";
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
3362521ADE965E6BA3383045 = {
|
||||
@@ -568,6 +567,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
DEVELOPMENT_TEAM = K8BLMMR883;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
@@ -747,6 +747,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
DEVELOPMENT_TEAM = K8BLMMR883;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
|
||||
@@ -73,7 +73,7 @@ final class BackupEngine: ObservableObject {
|
||||
activeTransfer = nil
|
||||
}
|
||||
|
||||
// 3. Load manifest → build index (manifest Data+struct released after this block)
|
||||
// 3. Load manifest → build index (Data+struct released immediately after)
|
||||
let manifestPath = "\(connection.remotePath)/\(BackupManifest.remoteFilename)"
|
||||
let index: ManifestIndex
|
||||
if let data = try? await transfer.downloadData(at: manifestPath),
|
||||
@@ -85,8 +85,23 @@ final class BackupEngine: ObservableObject {
|
||||
logger.info("No manifest — will check NAS file existence per asset")
|
||||
}
|
||||
|
||||
// 4. Start job tracking
|
||||
job.start(totalFiles: assets.count, totalBytes: 0)
|
||||
// 4. Build PENDING QUEUE — items not already confirmed in manifest.
|
||||
// This is the denominator for progress; NOT the full gallery count.
|
||||
let pendingAssets = assets.filter { asset in
|
||||
!index.matches(localIdentifier: asset.localIdentifier) &&
|
||||
!(index.isFilenameOnly && index.matches(filename: asset.filename))
|
||||
}
|
||||
logger.info("Pending queue: \(pendingAssets.count) of \(assets.count) total assets")
|
||||
|
||||
// If nothing is pending, finish immediately — all already safe.
|
||||
guard !pendingAssets.isEmpty else {
|
||||
job.allAlreadySafe()
|
||||
BackupStatusService.shared.refresh(force: false)
|
||||
return BackupResult.empty(date: startDate)
|
||||
}
|
||||
|
||||
// 5. Start job with PENDING count — never the full gallery total.
|
||||
job.start(totalFiles: pendingAssets.count, totalBytes: 0)
|
||||
|
||||
var uploaded = 0
|
||||
var skipped = 0
|
||||
@@ -95,40 +110,24 @@ final class BackupEngine: ObservableObject {
|
||||
var speedTracker = SpeedTracker()
|
||||
var manifestEntries: [ManifestEntry] = []
|
||||
|
||||
// 5. Transfer loop
|
||||
for asset in assets {
|
||||
// 6. Transfer loop — manifest check already done above, only NAS-exists + upload here.
|
||||
for asset in pendingAssets {
|
||||
if isCancelled { break }
|
||||
while case .paused = job.status {
|
||||
try await Task.sleep(nanoseconds: 500_000_000)
|
||||
}
|
||||
|
||||
// ── Primary dedup: manifest localIdentifier (O(1), no network call) ──
|
||||
if index.matches(localIdentifier: asset.localIdentifier) {
|
||||
job.fileCompleted(skipped: true)
|
||||
skipped += 1
|
||||
logger.debug("Skipped (manifest id): \(asset.filename, privacy: .public)")
|
||||
continue
|
||||
}
|
||||
|
||||
// ── Filename fallback: bootstrap manifest has no localIdentifiers ──
|
||||
if index.isFilenameOnly && index.matches(filename: asset.filename) {
|
||||
job.fileCompleted(skipped: true)
|
||||
skipped += 1
|
||||
logger.debug("Skipped (manifest filename): \(asset.filename, privacy: .public)")
|
||||
continue
|
||||
}
|
||||
|
||||
// ── NAS file existence check — never overwrite ──
|
||||
let baseRemotePath = "\(connection.remotePath)/\(asset.filename)"
|
||||
let fileAlreadyExists = (try? await transfer.fileExists(at: baseRemotePath)) == true
|
||||
if fileAlreadyExists {
|
||||
|
||||
// NAS file existence check — skip without overwriting legacy files
|
||||
if (try? await transfer.fileExists(at: baseRemotePath)) == true {
|
||||
job.fileCompleted(skipped: true)
|
||||
skipped += 1
|
||||
logger.debug("Skipped (NAS exists, no-overwrite): \(asset.filename, privacy: .public)")
|
||||
continue
|
||||
}
|
||||
|
||||
// ── Upload ──
|
||||
// Upload
|
||||
do {
|
||||
let localURL = try await photoService.exportAsset(asset)
|
||||
defer { try? FileManager.default.removeItem(at: localURL) }
|
||||
@@ -211,6 +210,9 @@ final class BackupEngine: ObservableObject {
|
||||
func pause() { job.pause() }
|
||||
func resume() { job.resume() }
|
||||
|
||||
/// Upgrades a stale .failed state to .completed when reconciliation proves needBackup == 0.
|
||||
func resolveSuccess() { job.resolveSuccess() }
|
||||
|
||||
private func sendCompletionNotification(result: BackupResult) async {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = result.hasErrors ? "Backup completed with errors" : "Backup complete"
|
||||
|
||||
@@ -60,6 +60,12 @@ final class BackupStatusService: NSObject, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Awaitable reconciliation — used by pull-to-refresh and post-failure resolution.
|
||||
func refreshAndWait(force: Bool = true) async {
|
||||
cancelAll()
|
||||
await performRefresh(force: force)
|
||||
}
|
||||
|
||||
/// Called by BackupEngine after a completed run with newly uploaded entries.
|
||||
/// Applies an optimistic count update, writes the manifest in background, then reconciles.
|
||||
func refreshAfterBackup(entries: [ManifestEntry], connection: NASConnection) {
|
||||
|
||||
Reference in New Issue
Block a user