Files
noo/ios/Runner/Native/SyncEngine/SyncModels.swift
T
Ayushya AmitabhandClaude Sonnet 5.5 8805bafb97 iOS: device sync (Swift port of the Android engine) behind the sync_service channel
PROPFIND walk / diff / GET / PUT mirror with conflicts, per-account state,
BGTaskScheduler background runs, and conflict notifications with Keep local /
Use server. Reviewed by Codex against the Kotlin original; its data-loss
findings are fixed and each has a regression test (fake-server end-to-end
tests, 68 Swift tests total): truncated/unusable PROPFIND answers are never a
manifest, unreadable state aborts the run, server paths can't leave the mirror,
case/Unicode collisions are skipped, atomic downloads, a locally edited file
deleted on the server is kept, '/' scope matching, serialised removal,
tracked/cancellable background runs, per-account conflicts, and sign-out vs
background-off credentials (cancel gains a 'forget' flag).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 21:01:08 -04:00

100 lines
3.5 KiB
Swift

import Foundation
// The iOS port of Android's `SyncEngine.kt`/`SyncWorker.kt` data model. Times
// are milliseconds since the epoch, like the Kotlin side, so the persisted
// state means the same thing on both platforms.
/// One file or folder the server reported in a PROPFIND.
struct SyncRemoteEntry: Equatable {
/// Path relative to the user's files root, no trailing slash ("/Docs/a.txt").
let path: String
let fileId: String
let etag: String
let lastModified: Int64
let size: Int64
let isFolder: Bool
}
/// What the last successful sync recorded for a file - the baseline a later
/// pass diffs the server and the device against.
struct SyncFileState: Codable, Equatable {
let relPath: String
let etag: String
let lastModified: Int64
let size: Int64
let localMTime: Int64
}
/// A configured sync root's etag at the end of its last clean, complete walk.
/// Nextcloud propagates a change to any descendant up through every ancestor's
/// etag, so an unchanged root etag means nothing beneath it changed.
struct SyncRootMarker: Codable, Equatable {
let etag: String
let fullWalkAt: Int64
}
enum SyncAction: Equatable {
case download(SyncRemoteEntry)
case upload(relPath: String, fileId: String)
case delete(relPath: String, fileId: String)
case conflict(SyncRemoteEntry, relPath: String)
/// Deleted on the server, but edited on this device since the last sync:
/// the edited copy is the only one left, so it's kept (and simply no longer
/// tracked) instead of being deleted with the rest. Android deletes it.
case orphan(relPath: String, fileId: String)
}
/// A file changed both on the device and on the server since the last sync.
struct SyncConflict: Codable, Equatable {
let accountId: String
let fileId: String
let remotePath: String
let relPath: String
let name: String
}
/// Everything a sync run (foreground or background) needs for one account.
/// Persisted in the Keychain - it carries the auth header.
struct SyncAccountConfig: Codable, Equatable {
let accountId: String
let serverUrl: String
let username: String
let authHeader: String
/// Remote paths (files or folders) to mirror.
var folders: [String]
var wifiOnly: Bool
/// Minutes between background syncs; nil = no background sync (manual and
/// in-app refresh only).
var intervalMinutes: Int?
/// Whether automatic runs may post a summary notification.
var notify: Bool
}
struct SyncRunSummary: Equatable {
var downloaded = 0
var uploaded = 0
var deleted = 0
var conflicts: [SyncConflict] = []
var changedAnything: Bool { downloaded > 0 || uploaded > 0 || deleted > 0 }
}
/// The server couldn't be reached or answered with an error (anything but a
/// clean 404). Distinct from "the path is genuinely gone": treating a network
/// blip as an empty listing made every previously synced file look deleted
/// server-side, and the diff then deleted the local copies. Callers skip the
/// affected path for this run instead.
struct SyncRemoteUnavailable: Error, CustomStringConvertible {
let message: String
var description: String { message }
}
/// The recorded sync state exists but couldn't be read (corrupt, or an I/O
/// error). Treating that as "nothing recorded yet" would make every local
/// file look untracked and overwrite local edits with the server's copy, so
/// a run that hits it does nothing at all.
struct SyncStateUnreadable: Error, CustomStringConvertible {
let accountId: String
var description: String { "Sync state for \(accountId) is unreadable" }
}