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:
Ayushya Amitabh
2026-10-06 21:01:08 -04:00
co-authored by Claude Sonnet 5.5
parent 1634d34341
commit 8805bafb97
14 changed files with 2834 additions and 10 deletions
+195
View File
@@ -0,0 +1,195 @@
import Foundation
/// A local file's size and modification time (ms), the pair sync compares
/// against what it recorded.
struct LocalStat: Equatable {
let size: Int64
let mtimeMs: Int64
}
enum LocalFS {
/// nil if [url] doesn't exist.
static func stat(_ url: URL) -> LocalStat? {
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) else { return nil }
let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0
let mtime = (attrs[.modificationDate] as? Date).map { Int64($0.timeIntervalSince1970 * 1000) } ?? 0
return LocalStat(size: size, mtimeMs: mtime)
}
static func relative(_ path: String) -> String {
path.hasPrefix("/") ? String(path.dropFirst()) : path
}
static func trimTrailingSlashes(_ path: String) -> String {
var p = path
while p.hasSuffix("/") { p.removeLast() }
return p
}
/// [rel] under [root], or nil if it isn't safely inside it: empty, with a
/// `.`/`..` component, or resolving outside [root]. Every path that came
/// from the server goes through this before anything is read, written or
/// deleted, so a hostile or buggy server can't point sync at other files.
static func resolve(_ rel: String, in root: URL) -> URL? {
let parts = rel.split(separator: "/", omittingEmptySubsequences: true)
guard !parts.isEmpty, !parts.contains(where: { $0 == ".." || $0 == "." }) else { return nil }
let url = root.appendingPathComponent(rel)
let rootPath = root.standardizedFileURL.path
guard url.standardizedFileURL.path.hasPrefix(rootPath + "/") else { return nil }
return url
}
}
/// The decisions of a sync pass, kept free of networking so they can be
/// unit-tested. A direct port of Android's `SyncEngine.diffFolder` and its
/// helpers - the same rules, so a device behaves the same on either platform.
enum SyncDiff {
/// Diffs one synced path's remote manifest against the recorded state.
/// Callers own actually doing the GET/PUT/delete and updating state.
///
/// - A file with no recorded state is simply pulled.
/// - Server changed and device changed -> conflict (never silently overwrite).
/// - Only the server changed -> download. Only the device changed -> upload.
/// - Recorded but missing on the device -> pull it back (it's inside a path
/// the user asked to sync, so a vanished copy is repaired, not propagated).
/// - Recorded for this path but absent from a *successful* listing ->
/// deleted on the server, so the local copy goes too. (An unreachable
/// server never gets here - the caller skips the path instead.)
static func diffFolder(
entries: [SyncRemoteEntry],
state: [String: SyncFileState],
priorFolderFileIds: Set<String>,
syncRoot: URL
) -> [SyncAction] {
var actions: [SyncAction] = []
var seen = Set<String>()
for entry in entries where !entry.isFolder {
seen.insert(entry.fileId)
let rel = LocalFS.relative(entry.path)
let local = LocalFS.stat(syncRoot.appendingPathComponent(rel))
guard let prior = state[entry.fileId] else {
actions.append(.download(entry))
continue
}
let serverChanged = entry.etag != prior.etag
let localChanged = local.map { $0.mtimeMs != prior.localMTime || $0.size != prior.size } ?? false
if local == nil {
actions.append(.download(entry))
} else if serverChanged && localChanged {
actions.append(.conflict(entry, relPath: rel))
} else if serverChanged {
actions.append(.download(entry))
} else if localChanged {
actions.append(.upload(relPath: rel, fileId: entry.fileId))
}
}
for fileId in priorFolderFileIds where !seen.contains(fileId) {
guard let prior = state[fileId] else { continue }
// Deleted on the server. If it was also edited here since the last sync
// that edit is the only copy left - keep it (untracked) instead of
// deleting it along with the rest.
if let local = LocalFS.stat(syncRoot.appendingPathComponent(prior.relPath)),
local.mtimeMs != prior.localMTime || local.size != prior.size
{
actions.append(.orphan(relPath: prior.relPath, fileId: fileId))
} else {
actions.append(.delete(relPath: prior.relPath, fileId: fileId))
}
}
return actions
}
/// The recorded files that belong to the synced [path]: everything for the
/// root ("/"), else the file itself or anything under it - matched on a
/// path-component boundary, so "/Docs" never claims "/Docs2" or
/// "/Documents".
static func priorFileIds(state: [String: SyncFileState], path: String) -> Set<String> {
let rootRel = LocalFS.relative(LocalFS.trimTrailingSlashes(path))
return Set(
state.filter {
rootRel.isEmpty || $0.value.relPath == rootRel || $0.value.relPath.hasPrefix(rootRel + "/")
}.keys)
}
/// Local paths (as spelled by the server) that more than one remote entry
/// would land on: "a.txt" and "A.txt" on a case-insensitive volume, or two
/// canonically-equivalent Unicode spellings. Downloading either would
/// overwrite the other, so callers skip them.
static func collidingRelPaths(_ entries: [SyncRemoteEntry]) -> Set<String> {
var byKey: [String: [String]] = [:]
for entry in entries {
let rel = LocalFS.relative(entry.path)
byKey[rel.precomposedStringWithCanonicalMapping.lowercased(), default: []].append(rel)
}
// Compared as raw bytes: Swift's own `String` equality already treats
// canonically equivalent spellings as the same string, which is exactly
// the distinction the server preserves and the disk may not.
return Set(byKey.values.filter { Set($0.map { Array($0.utf8) }).count > 1 }.flatMap { $0 })
}
/// True if every file in [fileIds] is still on disk exactly as recorded
/// (same size and mtime): nothing local to upload and nothing missing to
/// re-download. Pure local stat calls, no network.
static func localMatchesState(
state: [String: SyncFileState], fileIds: Set<String>, syncRoot: URL
) -> Bool {
fileIds.allSatisfy { id in
guard let s = state[id], let local = LocalFS.stat(syncRoot.appendingPathComponent(s.relPath)) else {
return false
}
return local.size == s.size && local.mtimeMs == s.localMTime
}
}
/// Creates a local directory for every remote folder, so empty folders
/// exist on the device too.
static func mirrorFolders(
entries: [SyncRemoteEntry], syncRoot: URL, rootRel: String, collisions: Set<String> = []
) {
let fm = FileManager.default
if !rootRel.isEmpty, let dir = LocalFS.resolve(rootRel, in: syncRoot) {
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
}
for entry in entries where entry.isFolder {
let rel = entry.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
// Skip anything unsafe or that would share a local folder with another
// remote folder (a/A on a case-insensitive volume).
guard !collisions.contains(rel), let dir = LocalFS.resolve(rel, in: syncRoot) else { continue }
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
}
}
/// Removes local directories under [rootRel] that no longer exist on the
/// server ([remoteFolders], relative paths). Only *empty* directories go:
/// by the time this runs the diff has already deleted the files that were
/// inside a deleted server folder, while a directory that still holds
/// something (a file this device created that hasn't synced) is left alone.
/// Never removes the sync root itself.
static func pruneRemovedFolders(syncRoot: URL, rootRel: String, remoteFolders: Set<String>) {
let fm = FileManager.default
let top = rootRel.isEmpty ? syncRoot : syncRoot.appendingPathComponent(rootRel)
var isDir: ObjCBool = false
guard fm.fileExists(atPath: top.path, isDirectory: &isDir), isDir.boolValue else { return }
func prune(_ dir: URL, rel: String) {
let children = (try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: [.isDirectoryKey])) ?? []
for child in children {
let childIsDir = (try? child.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
if childIsDir {
prune(child, rel: rel.isEmpty ? child.lastPathComponent : rel + "/" + child.lastPathComponent)
}
}
guard !rel.isEmpty, !remoteFolders.contains(rel),
let remaining = try? fm.contentsOfDirectory(atPath: dir.path), remaining.isEmpty
else { return }
// rmdir, not removeItem: it refuses a directory that isn't empty, so a
// file that appeared since the check survives.
rmdir(dir.path)
}
prune(top, rel: rootRel)
}
}