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>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
1634d34341
commit
8805bafb97
@@ -0,0 +1,99 @@
|
||||
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" }
|
||||
}
|
||||
Reference in New Issue
Block a user