From 8805bafb977ab57403eed45e0bdd1a4910c4dcef Mon Sep 17 00:00:00 2001 From: Ayushya Amitabh Date: Tue, 6 Oct 2026 21:01:08 -0400 Subject: [PATCH] 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 --- .claude/context/server.md | 78 +- ios/Runner.xcodeproj/project.pbxproj | 41 + ios/Runner/AppDelegate.swift | 14 + ios/Runner/Info.plist | 10 + ios/Runner/Native/NativeServices.swift | 109 +++ .../Native/SyncEngine/DavSyncClient.swift | 294 ++++++ .../Native/SyncEngine/SyncConfigStore.swift | 120 +++ .../Native/SyncEngine/SyncCoordinator.swift | 448 ++++++++++ ios/Runner/Native/SyncEngine/SyncDiff.swift | 195 ++++ ios/Runner/Native/SyncEngine/SyncModels.swift | 99 ++ ios/Runner/Native/SyncEngine/SyncRunner.swift | 392 ++++++++ ios/Runner/Native/SyncEngine/SyncStore.swift | 175 ++++ ios/RunnerTests/SyncEngineTests.swift | 845 ++++++++++++++++++ lib/services/sync_service.dart | 24 +- 14 files changed, 2834 insertions(+), 10 deletions(-) create mode 100644 ios/Runner/Native/SyncEngine/DavSyncClient.swift create mode 100644 ios/Runner/Native/SyncEngine/SyncConfigStore.swift create mode 100644 ios/Runner/Native/SyncEngine/SyncCoordinator.swift create mode 100644 ios/Runner/Native/SyncEngine/SyncDiff.swift create mode 100644 ios/Runner/Native/SyncEngine/SyncModels.swift create mode 100644 ios/Runner/Native/SyncEngine/SyncRunner.swift create mode 100644 ios/Runner/Native/SyncEngine/SyncStore.swift create mode 100644 ios/RunnerTests/SyncEngineTests.swift diff --git a/.claude/context/server.md b/.claude/context/server.md index a63fa9e..2b955cf 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -831,9 +831,9 @@ The five `dev.ayushya.noo/*` channels (`share_intent`, `pick_intent`, `upload_service`, `download_service`, `sync_service`, plus their status `EventChannel`s) were written for Android in Kotlin. iOS implements `upload_service` (+ its status stream), `download_service` and -`share_intent` so far (`ios/Runner/Native/` + `ios/ShareExtension/`, see -below); for `pick_intent` and `sync_service` there's no handler, so calls -throw `MissingPluginException`. `lib/services/native_channel.dart` +`share_intent` and `sync_service` so far (`ios/Runner/Native/` + +`ios/ShareExtension/`, see below); for `pick_intent` there's no handler, so +calls throw `MissingPluginException`. `lib/services/native_channel.dart` makes that safe until each one is built in Swift: - `invokeIfAvailable` - a missing implementation returns null. For cold-start @@ -987,5 +987,73 @@ mismatch); it's embedded by an "Embed Foundation Extensions" phase placed cycle). The simulator honours the App Group and keychain group without a signing team; a real device needs a team that owns both ids. -Not built on iOS yet: the picker (File Provider) and sync -(`BGTaskScheduler`) - see the iOS handoff notes. +### iOS device sync (`ios/Runner/Native/SyncEngine/`) + +A Swift port of Android's `SyncEngine.kt`/`SyncWorker.kt`/ +`ConflictResolveWorker.kt`/`SyncStatusBus.kt`, behind the same `sync_service` +channel (+ `/status` event stream) so the Dart side is unchanged apart from +`cancelAccount`'s `forget` flag (below). Same rules: only paths the user +synced are mirrored; brand-new local files are never uploaded (only edits to +already-synced files are); a file edited on both sides is a conflict (neither +copy is touched, "Keep local"/"Use server" resolves it); a local deletion is +repaired by re-downloading; the mirror lives at +`/sync//...` (what Dart's +`SyncService.baseDirectory()` reads for the Offline tab, excluded from +backup) with state in `<...>/sync-state//*.json`. + +- `SyncDiff` (pure decisions, mirrors `diffFolder`), `DavSyncClient` + (PROPFIND/GET/PUT on a cookie-less ephemeral session), `SyncRunner` (one + account's pass), `SyncStore`, `SyncConfigStore` (Keychain), `SyncCoordinator` + (runs, `BGTaskScheduler`, notifications), and the `registerSync` channel + handlers in `NativeServices.swift`. +- **Safeguards (each has a regression test in `SyncEngineTests`, which drives + the real runner against an in-memory fake Nextcloud):** an unreachable + server, a non-207, or a PROPFIND answer that is truncated, not a + `multistatus`, empty, or has an href outside the account root / with dot + components throws `SyncRemoteUnavailable` and the path is skipped - never + diffed as empty or partial (which would delete local files); unreadable + recorded state makes the run do nothing (`SyncStateUnreadable`) instead of + treating every file as untracked and overwriting local edits; every + server-supplied path is resolved through `LocalFS.resolve` (no escaping the + mirror) and entries that would land on the same local file (case or Unicode + spellings) are skipped, not downloaded over each other; downloads go to a + temp file and replace atomically, never replacing a folder; empty-folder + pruning uses `rmdir` and only on a successful empty read; a root's etag + shortcut marker is only written when the path synced cleanly *and* the state + was saved. +- **Deliberate differences from Android:** a file deleted on the server but + edited here is **kept** (untracked) rather than deleted - the edit is the + only copy; after our own upload the file's etag is re-read so it isn't + downloaded back; state is flushed every 20 transfers; conflicts are kept + per account on the status bus; downloads write to a temp file first. No + ongoing progress notification (a run posts a summary, and conflict + notifications with Keep local / Use server actions). +- **Running:** every run - "Sync now", the in-app refresh, and background - + goes through `SyncCoordinator.startRun`: one tracked run per account + (`force` supersedes it, a quiet check leaves it alone), serialised by one + cancellable `AsyncMutex`, tagged with a per-account *generation* so a run + that was only queued never starts for an account that was cancelled or had a + path removed meanwhile. `removeLocalSync` cancels the run and takes the same + mutex, so a finishing run can't write stale state back over it. +- **Background:** `BGAppRefreshTask` + `BGProcessingTask` + (`dev.ayushya.noo.sync.refresh`/`.processing`, `UIBackgroundModes` fetch + + processing, `BGTaskSchedulerPermittedIdentifiers` in Info.plist; handlers + registered before launch finishes). `intervalMinutes` (floored at 15, like + Android) is only the *earliest* start - iOS picks when - and Wi-Fi-only is + checked with `NWPathMonitor` (`isExpensive`) since a BGTask can't require + it. An expiring task cancels its runs (they save state as they go) and is + completed exactly once (`OnceGate`). **Not testable in the simulator** + (needs a real device and the Xcode `_simulateLaunchForTaskWithIdentifier` + debugger command). +- **Credentials:** `SyncConfigStore` keeps each account's server, auth header, + paths, interval and notify flag as one Keychain item + (`AfterFirstUnlock`, so a background run works with the phone locked). + Conflict notification actions look the account up there - nothing secret is + in a notification. `sync_service.cancel` takes `forget` (default true: + signed out/removed -> credentials dropped; false: only background sync was + turned off -> kept for manual runs and conflict actions). Dart sends + `forget: false` only for "paths configured but no background interval". +- **Gotcha:** `ios/.gitignore` has `**/*sync/`, which (case-insensitively on + macOS) swallows any directory named `Sync` - hence `SyncEngine/`. + +Not built on iOS yet: the picker (File Provider) - see the iOS handoff notes. diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 65e4e79..eca4693 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -7,9 +7,11 @@ objects = { /* Begin PBXBuildFile section */ + 0EA689E7D32E498936A37CDC /* DavSyncClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 308A73EC19D196226AB395DC /* DavSyncClient.swift */; }; 134157C4C8F53C9997FF265C /* TransferTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 167672C5B1FBD70656C2FEED /* TransferTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */; }; + 187CEAB4C9995B379F33409F /* SyncCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57FF20A8DFA5A1A9F75A5F5A /* SyncCoordinator.swift */; }; 223784E5DAE5A653F0F61771 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 405A5408F4071B4D12F08B21 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 33B3C3BED4C1F26439DD6825 /* SharedInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */; }; @@ -18,14 +20,19 @@ 3DEDE94F2A4C921407EF4382 /* ShareUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */; }; 43DB9AB631DD359AEC613A1D /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */; }; 44078FC9474337A0E102B524 /* TransferNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */; }; + 4618DB104BDFF73311A5DA6E /* SyncStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39E02713460B6C7875F3278 /* SyncStore.swift */; }; + 47EF34D26CA64490D2118B0D /* SyncRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = C028B2E4E2874BB74498EABD /* SyncRunner.swift */; }; 5694337ECF8D3ADD3695CBCD /* NativeServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 901C0CDB4FB819302E2A7563 /* NativeServices.swift */; }; 62028E64976F15B4FDEDA9F1 /* ShareUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */; }; 63229CACE4281880037D33E6 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */; }; 66AFC246E251554A80B2476F /* SharePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */; }; + 721B9C528F23F144966483E0 /* SyncModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF9CFAFB2387EB4E34A992BC /* SyncModels.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 804B3C9878B9A8BB30E09340 /* SyncEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2947D6E441BA30E10B1F8C17 /* SyncEngineTests.swift */; }; 852C0C8EBA96CB6389DF6917 /* SharedAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2C956C98F58A97B5595307 /* SharedAccount.swift */; }; + 87DD0AED8048F7F7FF908FDB /* SyncDiff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0933A96CE0B65A9A6AB2D098 /* SyncDiff.swift */; }; 8C73B4CB445AEF383B7640E1 /* DeviceAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97ACCF43E1EC13AA2538E42C /* DeviceAuth.swift */; }; 91C1658DC7467CACB2AAC7FF /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0AE6F681A3100A461B4A5318 /* Foundation.framework */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; @@ -33,6 +40,7 @@ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; A16680889B3A9D8672FA29C1 /* TransferNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */; }; A3B3AB501C29516E20455F23 /* DavFolders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51604DEE43E937A5FDCA7A58 /* DavFolders.swift */; }; + B749ED5B4464A1950E1A4B18 /* SyncConfigStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18691F7A45AD55A2246075DA /* SyncConfigStore.swift */; }; E295E45196E3973A5F7E1D8E /* SharedAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2C956C98F58A97B5595307 /* SharedAccount.swift */; }; F598B112F4D33953984FEFF4 /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */; }; F8D51B4045340275FBD12D16 /* SharedInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */; }; @@ -81,12 +89,16 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0933A96CE0B65A9A6AB2D098 /* SyncDiff.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncDiff.swift; sourceTree = ""; }; 0AE6F681A3100A461B4A5318 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 18691F7A45AD55A2246075DA /* SyncConfigStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncConfigStore.swift; sourceTree = ""; }; 1F2C956C98F58A97B5595307 /* SharedAccount.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedAccount.swift; sourceTree = ""; }; 20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferTypes.swift; sourceTree = ""; }; 27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; + 2947D6E441BA30E10B1F8C17 /* SyncEngineTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncEngineTests.swift; sourceTree = ""; }; + 308A73EC19D196226AB395DC /* DavSyncClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DavSyncClient.swift; sourceTree = ""; }; 3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferNotifications.swift; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -94,6 +106,7 @@ 405A5408F4071B4D12F08B21 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 49213989F0FA1B83B0B8A523 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 51604DEE43E937A5FDCA7A58 /* DavFolders.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DavFolders.swift; sourceTree = ""; }; + 57FF20A8DFA5A1A9F75A5F5A /* SyncCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncCoordinator.swift; sourceTree = ""; }; 5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharePickerView.swift; sourceTree = ""; }; 65F57A1EB0C62774E561860D /* ShareExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; @@ -114,7 +127,10 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A39E02713460B6C7875F3278 /* SyncStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncStore.swift; sourceTree = ""; }; + AF9CFAFB2387EB4E34A992BC /* SyncModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncModels.swift; sourceTree = ""; }; B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareUpload.swift; sourceTree = ""; }; + C028B2E4E2874BB74498EABD /* SyncRunner.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncRunner.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -149,6 +165,7 @@ isa = PBXGroup; children = ( 331C807B294A618700263BE5 /* RunnerTests.swift */, + 2947D6E441BA30E10B1F8C17 /* SyncEngineTests.swift */, ); path = RunnerTests; sourceTree = ""; @@ -171,6 +188,7 @@ children = ( 8064CC1652B8F0A44E371638 /* TransferManager.swift */, 901C0CDB4FB819302E2A7563 /* NativeServices.swift */, + DDC89BA8A42212F55851CBA1 /* SyncEngine */, ); name = Native; path = Native; @@ -252,6 +270,21 @@ name = Frameworks; sourceTree = ""; }; + DDC89BA8A42212F55851CBA1 /* SyncEngine */ = { + isa = PBXGroup; + children = ( + AF9CFAFB2387EB4E34A992BC /* SyncModels.swift */, + A39E02713460B6C7875F3278 /* SyncStore.swift */, + 308A73EC19D196226AB395DC /* DavSyncClient.swift */, + 0933A96CE0B65A9A6AB2D098 /* SyncDiff.swift */, + C028B2E4E2874BB74498EABD /* SyncRunner.swift */, + 18691F7A45AD55A2246075DA /* SyncConfigStore.swift */, + 57FF20A8DFA5A1A9F75A5F5A /* SyncCoordinator.swift */, + ); + name = SyncEngine; + path = SyncEngine; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -425,6 +458,7 @@ buildActionMask = 2147483647; files = ( 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + 804B3C9878B9A8BB30E09340 /* SyncEngineTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -461,6 +495,13 @@ 62028E64976F15B4FDEDA9F1 /* ShareUpload.swift in Sources */, A3B3AB501C29516E20455F23 /* DavFolders.swift in Sources */, 44078FC9474337A0E102B524 /* TransferNotifications.swift in Sources */, + 721B9C528F23F144966483E0 /* SyncModels.swift in Sources */, + 4618DB104BDFF73311A5DA6E /* SyncStore.swift in Sources */, + 0EA689E7D32E498936A37CDC /* DavSyncClient.swift in Sources */, + 87DD0AED8048F7F7FF908FDB /* SyncDiff.swift in Sources */, + 47EF34D26CA64490D2118B0D /* SyncRunner.swift in Sources */, + B749ED5B4464A1950E1A4B18 /* SyncConfigStore.swift in Sources */, + 187CEAB4C9995B379F33409F /* SyncCoordinator.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index ae86176..afc9315 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -9,11 +9,25 @@ import UserNotifications didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { TransferManager.shared.reconnect() + // BGTask handlers have to be registered before launch finishes. + SyncCoordinator.shared.registerBackgroundTasks() + SyncCoordinator.shared.registerNotificationCategories() let launched = super.application(application, didFinishLaunchingWithOptions: launchOptions) UNUserNotificationCenter.current().delegate = self + SyncCoordinator.shared.scheduleBackgroundWork() return launched } + /// Taps on a sync-conflict notification's "Keep local" / "Use server". + override func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + if SyncCoordinator.shared.handleNotificationResponse(response, completion: completionHandler) { return } + super.userNotificationCenter(center, didReceive: response, withCompletionHandler: completionHandler) + } + /// A transfer summary ("Downloaded 1 file") usually finishes while the app /// is open; without this iOS delivers it silently to Notification Center /// instead of showing the banner. diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 5d33b03..eabd0ab 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -59,6 +59,16 @@ NooKeychainAccessGroup $(AppIdentifierPrefix)dev.ayushya.noo.shared + BGTaskSchedulerPermittedIdentifiers + + dev.ayushya.noo.sync.refresh + dev.ayushya.noo.sync.processing + + UIBackgroundModes + + fetch + processing + NSFaceIDUsageDescription Noo uses Face ID to unlock the app and to protect hidden files. NSPhotoLibraryUsageDescription diff --git a/ios/Runner/Native/NativeServices.swift b/ios/Runner/Native/NativeServices.swift index 8a770cd..762d74d 100644 --- a/ios/Runner/Native/NativeServices.swift +++ b/ios/Runner/Native/NativeServices.swift @@ -76,6 +76,115 @@ enum NativeServices { TransferManager.shared.reconnect() registerShare(messenger: messenger) registerShareAccount(messenger: messenger) + registerSync(messenger: messenger) + } + + // MARK: - Device sync + + /// Sends the current sync status the moment Dart starts listening, then + /// every change - same as Android's `SyncStatusBus.subscribe`. + private final class SyncStatusStream: NSObject, FlutterStreamHandler { + var sink: FlutterEventSink? + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) + -> FlutterError? + { + sink = events + let coordinator = SyncCoordinator.shared + events(coordinator.statusMap(coordinator.bus.snapshot())) + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + sink = nil + return nil + } + } + + private static let syncStatusStream = SyncStatusStream() + /// Serial, so status events reach Dart in the order they happened even + /// though building each one reads the state from disk. + private static let syncStatusQueue = DispatchQueue(label: "dev.ayushya.noo.sync-status", qos: .utility) + + /// `sync_service`: the same methods Android's `MainActivity.kt` serves, + /// backed by `SyncCoordinator`/`SyncRunner`. + private static func registerSync(messenger: FlutterBinaryMessenger) { + let coordinator = SyncCoordinator.shared + + FlutterMethodChannel(name: "dev.ayushya.noo/sync_service", binaryMessenger: messenger) + .setMethodCallHandler { call, result in + let args = call.arguments as? [String: Any] ?? [:] + switch call.method { + case "reschedule": + handle(call, result) { _ in coordinator.reschedule(try syncConfig(args)) } + case "cancel": + // `forget` (default true) = signed out / removed; false = only the + // background job is off - credentials stay for manual runs and + // conflict actions. + handle(call, result) { _ in + coordinator.cancel( + accountId: try string(args, "accountId"), forget: args["forget"] as? Bool ?? true) + } + case "syncNow": + handle(call, result) { _ in + let force = args["force"] as? Bool ?? true + coordinator.syncNow(try syncConfig(args), force: force, wifiOnly: args["wifiOnly"] as? Bool) + } + case "getSyncStatus": + let accountId = args["accountId"] as? String + syncStatusQueue.async { + let map = coordinator.statusMap(coordinator.bus.snapshot(), accountIdOverride: accountId) + DispatchQueue.main.async { result(map) } + } + case "resolveConflict": + handle(call, result) { _ in + let conflict = SyncConflict( + accountId: try string(args, "accountId"), fileId: try string(args, "fileId"), + remotePath: try string(args, "remotePath"), relPath: try string(args, "relPath"), + name: ((args["relPath"] as? String ?? "") as NSString).lastPathComponent) + let creds = SyncCredentials( + serverUrl: try string(args, "serverUrl"), username: try string(args, "username"), + authHeader: try string(args, "authHeader")) + let resolution = try string(args, "resolution") + // Fire and forget, like Android: the status stream reports the result. + Task { _ = await coordinator.resolveConflict(conflict, resolution: resolution, creds: creds) } + } + case "removeLocalSync": + guard let accountId = args["accountId"] as? String, let path = args["path"] as? String else { + return result(FlutterError(code: "bad_args", message: "Missing required arguments", details: nil)) + } + Task { + await coordinator.removeLocalSync(accountId: accountId, path: path) + await MainActor.run { result(nil) } + } + default: + result(FlutterMethodNotImplemented) + } + } + + FlutterEventChannel(name: "dev.ayushya.noo/sync_service/status", binaryMessenger: messenger) + .setStreamHandler(syncStatusStream) + coordinator.bus.onChange = { status in + syncStatusQueue.async { + let map = coordinator.statusMap(status) + DispatchQueue.main.async { syncStatusStream.sink?(map) } + } + } + } + + /// The account credentials + settings every sync call carries, as a config. + private static func syncConfig(_ args: [String: Any]) throws -> SyncAccountConfig { + var folders: [String] = [] + if let json = args["folders"] as? String, let data = json.data(using: .utf8), + let list = try? JSONSerialization.jsonObject(with: data) as? [String] + { + folders = list + } + return SyncAccountConfig( + accountId: try string(args, "accountId"), serverUrl: try string(args, "serverUrl"), + username: try string(args, "username"), authHeader: try string(args, "authHeader"), + folders: folders, wifiOnly: args["wifiOnly"] as? Bool ?? true, + intervalMinutes: args["intervalMinutes"] as? Int, notify: args["notify"] as? Bool ?? true) } // MARK: - Account for the Share Extension diff --git a/ios/Runner/Native/SyncEngine/DavSyncClient.swift b/ios/Runner/Native/SyncEngine/DavSyncClient.swift new file mode 100644 index 0000000..5927967 --- /dev/null +++ b/ios/Runner/Native/SyncEngine/DavSyncClient.swift @@ -0,0 +1,294 @@ +import Foundation + +/// Credentials for one account's WebDAV root. +struct SyncCredentials { + let serverUrl: String + let username: String + let authHeader: String +} + +/// The WebDAV calls sync needs: PROPFIND (a folder's children, or one item), +/// a recursive walk, and GET/PUT of a single file. Foreground +/// `URLSession` work - sync runs inside the app or a `BGTask`, not on a +/// background session, so a run can walk, diff and decide in one go. +/// +/// Authenticates by each request's own `Authorization` header only (no cookies, +/// no credential cache): two accounts can share a server, and one account's +/// session must never answer for another. +final class DavSyncClient { + private let session: URLSession + + init(session: URLSession = DavSyncClient.makeSession()) { + self.session = session + } + + static func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.httpShouldSetCookies = false + config.httpCookieAcceptPolicy = .never + config.urlCredentialStorage = nil + config.requestCachePolicy = .reloadIgnoringLocalCacheData + config.timeoutIntervalForRequest = 30 + config.timeoutIntervalForResource = 15 * 60 + return URLSession(configuration: config) + } + + // MARK: - PROPFIND + + private static let propfindBody = """ + + + + + + + + + + + """ + + /// Depth-1 PROPFIND of [path]: its direct children only. A clean 404 (the + /// folder is genuinely gone) is an empty list; anything else that goes wrong + /// throws `SyncRemoteUnavailable`. + func propfindChildren(_ creds: SyncCredentials, path: String) async throws -> [SyncRemoteEntry] { + try await propfind(creds, path: path, depth: "1", skipSelf: true) + } + + /// Depth-0 PROPFIND of [path] itself - nil on a clean 404. + func propfindSelf(_ creds: SyncCredentials, path: String) async throws -> SyncRemoteEntry? { + try await propfind(creds, path: path, depth: "0", skipSelf: false).first + } + + private func propfind( + _ creds: SyncCredentials, path: String, depth: String, skipSelf: Bool + ) async throws -> [SyncRemoteEntry] { + var clean = path.trimmingCharacters(in: .whitespaces) + if !clean.hasPrefix("/") { clean = "/" + clean } + guard var url = WebDAV.fileURL(serverUrl: creds.serverUrl, username: creds.username, remotePath: clean) + else { throw SyncRemoteUnavailable(message: "Invalid server address.") } + // Collections are addressed with a trailing slash. + if depth == "1", let slashed = URL(string: url.absoluteString + "/") { url = slashed } + + var request = URLRequest(url: url) + request.httpMethod = "PROPFIND" + request.setValue(depth, forHTTPHeaderField: "Depth") + request.setValue("application/xml", forHTTPHeaderField: "Content-Type") + request.setValue(creds.authHeader, forHTTPHeaderField: "Authorization") + request.httpBody = Self.propfindBody.data(using: .utf8) + + let data: Data + let status: Int + do { + let (body, response) = try await session.data(for: request) + data = body + status = (response as? HTTPURLResponse)?.statusCode ?? 0 + } catch { + try Self.rethrowIfCancelled(error) + throw SyncRemoteUnavailable(message: "PROPFIND \(url.path) failed: \(error.localizedDescription)") + } + if status == 404 { return [] } // genuinely gone + guard status == 207 else { + throw SyncRemoteUnavailable(message: "PROPFIND \(url.path) -> \(status)") + } + return try DavSyncParser.entries(from: data, username: creds.username, requestedPath: clean, skipSelf: skipSelf) + } + + /// Recursively walks [root] (Depth-1 PROPFINDs, breadth-first) into a flat + /// manifest. Throws if *any* level fails - a partial manifest would look + /// like the missing part had been deleted on the server. + func walk(_ creds: SyncCredentials, root: String) async throws -> [SyncRemoteEntry] { + var result: [SyncRemoteEntry] = [] + var queue = [root] + var index = 0 + while index < queue.count { + try Task.checkCancellation() + let children = try await propfindChildren(creds, path: queue[index]) + index += 1 + for child in children { + result.append(child) + if child.isFolder { queue.append(child.path) } + } + } + return result + } + + // MARK: - GET / PUT + + /// Downloads [remotePath] to [destination]. Written to a temp file first and + /// moved into place, so an interrupted download never leaves a truncated + /// file where a good one used to be. False on any failure. + func download(_ creds: SyncCredentials, remotePath: String, to destination: URL) async throws -> Bool { + guard let url = WebDAV.fileURL(serverUrl: creds.serverUrl, username: creds.username, remotePath: remotePath) + else { return false } + var request = URLRequest(url: url) + request.setValue(creds.authHeader, forHTTPHeaderField: "Authorization") + do { + let (temp, response) = try await session.download(for: request) + guard let status = (response as? HTTPURLResponse)?.statusCode, (200..<300).contains(status) else { + try? FileManager.default.removeItem(at: temp) + return false + } + try Task.checkCancellation() + let fm = FileManager.default + try fm.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true) + var isDirectory: ObjCBool = false + if fm.fileExists(atPath: destination.path, isDirectory: &isDirectory) { + // Never replace a folder with a file, and replace a file atomically: + // the old copy stays until the new one is fully in place. + guard !isDirectory.boolValue else { + try? fm.removeItem(at: temp) + return false + } + _ = try fm.replaceItemAt(destination, withItemAt: temp) + } else { + try fm.moveItem(at: temp, to: destination) + } + return true + } catch { + try Self.rethrowIfCancelled(error) + return false + } + } + + /// PUTs [source] to [remotePath]. False on any failure. + func upload(_ creds: SyncCredentials, remotePath: String, from source: URL) async throws -> Bool { + guard let url = WebDAV.fileURL(serverUrl: creds.serverUrl, username: creds.username, remotePath: remotePath) + else { return false } + var request = URLRequest(url: url) + request.httpMethod = "PUT" + request.setValue(creds.authHeader, forHTTPHeaderField: "Authorization") + do { + let (_, response) = try await session.upload(for: request, fromFile: source) + guard let status = (response as? HTTPURLResponse)?.statusCode else { return false } + return (200..<300).contains(status) + } catch { + try Self.rethrowIfCancelled(error) + return false + } + } + + /// A cancelled run must stop, not be mistaken for a flaky network. + private static func rethrowIfCancelled(_ error: Error) throws { + if error is CancellationError || (error as? URLError)?.code == .cancelled || Task.isCancelled { + throw CancellationError() + } + } +} + +/// Parses a PROPFIND multistatus into [SyncRemoteEntry]s. Pure, so it's +/// unit-tested. +enum DavSyncParser { + private struct Raw { + var href = "" + var etag = "" + var modified = "" + var length = "" + var fileId = "" + var isCollection = false + } + + private final class Delegate: NSObject, XMLParserDelegate { + var responses: [Raw] = [] + var sawMultistatus = false + private var current: Raw? + private var text = "" + + func parser( + _ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, + qualifiedName qName: String?, attributes attributeDict: [String: String] = [:] + ) { + text = "" + if elementName == "multistatus" { sawMultistatus = true } + if elementName == "response" { current = Raw() } + if elementName == "collection" { current?.isCollection = true } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + text += string + } + + func parser( + _ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, + qualifiedName qName: String? + ) { + let value = text.trimmingCharacters(in: .whitespacesAndNewlines) + switch elementName { + case "href": current?.href = value + case "getetag": current?.etag = value + case "getlastmodified": current?.modified = value + case "getcontentlength": current?.length = value + case "fileid": current?.fileId = value + case "response": + if let current { responses.append(current) } + current = nil + default: break + } + text = "" + } + } + + private static let httpDate: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(secondsFromGMT: 0) + f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" + return f + }() + + /// Parses a PROPFIND multistatus. Throws `SyncRemoteUnavailable` for + /// anything that isn't a complete, plausible answer - malformed or + /// truncated XML, no `multistatus` envelope, no responses at all (a 207 + /// always has at least the requested resource), or an href that isn't + /// under this account's files root or tries to climb out of it. A partial + /// manifest would look like the missing part was deleted on the server + /// (and an empty one like the whole path was), so the caller must treat + /// those as "couldn't reach the server", never as data. + static func entries( + from xml: Data, username: String, requestedPath: String, skipSelf: Bool + ) throws -> [SyncRemoteEntry] { + let delegate = Delegate() + let parser = XMLParser(data: xml) + parser.shouldProcessNamespaces = true + parser.delegate = delegate + guard parser.parse(), delegate.sawMultistatus, !delegate.responses.isEmpty else { + throw SyncRemoteUnavailable(message: "Unusable PROPFIND answer") + } + + let marker = "/remote.php/dav/files/\(username)" + let selfPath = trimTrailingSlashes(requestedPath) + + var entries: [SyncRemoteEntry] = [] + for raw in delegate.responses { + let decoded = raw.href.removingPercentEncoding ?? raw.href + guard let range = decoded.range(of: marker) else { + throw SyncRemoteUnavailable(message: "PROPFIND href outside the account's files root") + } + let hrefPath = String(decoded[range.upperBound...]) + // A well-formed path continues the marker at a component boundary and + // never contains a dot component. + guard hrefPath.isEmpty || hrefPath.hasPrefix("/"), + !hrefPath.split(separator: "/").contains(where: { $0 == ".." || $0 == "." }) + else { + throw SyncRemoteUnavailable(message: "PROPFIND href has an unexpected path") + } + let normalized = trimTrailingSlashes(hrefPath.isEmpty ? "/" : hrefPath) + if skipSelf && normalized == selfPath { continue } // the folder itself, not a child + entries.append( + SyncRemoteEntry( + path: normalized.isEmpty ? "/" : normalized, + fileId: raw.fileId.isEmpty ? (normalized.isEmpty ? "/" : normalized) : raw.fileId, + etag: raw.etag.trimmingCharacters(in: CharacterSet(charactersIn: "\"")), + lastModified: httpDate.date(from: raw.modified).map { Int64($0.timeIntervalSince1970 * 1000) } ?? 0, + size: Int64(raw.length) ?? 0, + isFolder: raw.isCollection)) + } + return entries + } + + private static func trimTrailingSlashes(_ path: String) -> String { + var p = path + while p.hasSuffix("/") { p.removeLast() } + return p + } +} diff --git a/ios/Runner/Native/SyncEngine/SyncConfigStore.swift b/ios/Runner/Native/SyncEngine/SyncConfigStore.swift new file mode 100644 index 0000000..bb5b0ae --- /dev/null +++ b/ios/Runner/Native/SyncEngine/SyncConfigStore.swift @@ -0,0 +1,120 @@ +import Foundation +import Security + +/// Every account's sync settings and credentials, kept in the Keychain (the +/// auth header is a secret) as one JSON item. A background run - which may +/// start while the app isn't running - reads these to know what to sync and +/// as whom, and a conflict notification's action looks the account up here +/// instead of carrying credentials in the notification itself. +/// +/// `kSecAttrAccessibleAfterFirstUnlock`, not "when unlocked": a background +/// task can fire with the phone locked. +/// +/// All accounts live in one item, so a write must never be built on a +/// failed read: an unreadable item makes `upsert`/`remove` do nothing (and +/// say so) rather than save a map holding only one account, and an update +/// replaces the item in place instead of deleting it first. +final class SyncConfigStore { + private struct KeychainError: Error { let status: OSStatus } + + private let service: String + private let account = "configs" + private let lock = NSLock() + + init(service: String = "dev.ayushya.noo.sync-configs") { + self.service = service + } + + /// Empty if there are none - or if the item can't be read, in which case + /// nothing runs in the background until it can. + func all() -> [SyncAccountConfig] { + lock.lock() + defer { lock.unlock() } + return Array(((try? load()) ?? [:]).values).sorted { $0.accountId < $1.accountId } + } + + func config(for accountId: String) -> SyncAccountConfig? { + lock.lock() + defer { lock.unlock() } + return (try? load())?[accountId] + } + + /// False if it couldn't be stored (the previous contents are untouched). + @discardableResult + func upsert(_ config: SyncAccountConfig) -> Bool { + lock.lock() + defer { lock.unlock() } + do { + var configs = try load() + configs[config.accountId] = config + try save(configs) + return true + } catch { + NSLog("[Sync] couldn't store config for %@: %@", config.accountId, String(describing: error)) + return false + } + } + + @discardableResult + func remove(accountId: String) -> Bool { + lock.lock() + defer { lock.unlock() } + do { + var configs = try load() + guard configs.removeValue(forKey: accountId) != nil else { return true } + try save(configs) + return true + } catch { + NSLog("[Sync] couldn't remove config for %@: %@", accountId, String(describing: error)) + return false + } + } + + func removeAll() { + lock.lock() + defer { lock.unlock() } + SecItemDelete(baseQuery() as CFDictionary) + } + + // MARK: - Keychain + + private func baseQuery() -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + } + + /// `[:]` only when nothing is stored yet; any other failure throws. + private func load() throws -> [String: SyncAccountConfig] { + var query = baseQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return [:] } + guard status == errSecSuccess, let data = result as? Data else { throw KeychainError(status: status) } + return try JSONDecoder().decode([String: SyncAccountConfig].self, from: data) + } + + private func save(_ configs: [String: SyncAccountConfig]) throws { + guard !configs.isEmpty else { + let status = SecItemDelete(baseQuery() as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { throw KeychainError(status: status) } + return + } + let data = try JSONEncoder().encode(configs) // before touching the item + let status = SecItemUpdate( + baseQuery() as CFDictionary, [kSecValueData as String: data] as CFDictionary) + if status == errSecItemNotFound { + var query = baseQuery() + query[kSecValueData as String] = data + query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + let added = SecItemAdd(query as CFDictionary, nil) + if added != errSecSuccess { throw KeychainError(status: added) } + } else if status != errSecSuccess { + throw KeychainError(status: status) + } + } +} diff --git a/ios/Runner/Native/SyncEngine/SyncCoordinator.swift b/ios/Runner/Native/SyncEngine/SyncCoordinator.swift new file mode 100644 index 0000000..2f06664 --- /dev/null +++ b/ios/Runner/Native/SyncEngine/SyncCoordinator.swift @@ -0,0 +1,448 @@ +import BackgroundTasks +import Network +import UIKit +import UserNotifications + +/// Whether the network currently allows a sync run: any connection normally, +/// but with "Wi-Fi only" nothing metered (cellular, a phone's hotspot) - +/// iOS's equivalent of Android's `NetworkType.UNMETERED` work constraint, +/// which a `BGTask` doesn't offer. +enum NetworkGate { + static func allows(wifiOnly: Bool) async -> Bool { + let monitor = NWPathMonitor() + let queue = DispatchQueue(label: "dev.ayushya.noo.network-gate") + return await withCheckedContinuation { continuation in + var answered = false // only touched on `queue` + monitor.pathUpdateHandler = { path in + guard !answered else { return } + answered = true + monitor.cancel() + let connected = path.status == .satisfied + continuation.resume(returning: connected && (!wifiOnly || !path.isExpensive)) + } + monitor.start(queue: queue) + } + } +} + +/// Runs an action at most once - for completing a `BGTask`, which must be +/// told it's done exactly one time whether the work finished or the system's +/// time ran out first. +final class OnceGate { + private let lock = NSLock() + private var done = false + private let action: (Bool) -> Void + + init(_ action: @escaping (Bool) -> Void) { + self.action = action + } + + func finish(_ success: Bool) { + lock.lock() + if done { + lock.unlock() + return + } + done = true + lock.unlock() + action(success) + } +} + +/// Runs sync for the app: the "Sync now" / in-app refresh runs the Dart side +/// asks for, the periodic background runs iOS grants through +/// `BGTaskScheduler`, conflict resolution, and the notifications around them. +/// The iOS counterpart of Android's `SyncWorker` scheduling + `MainActivity`'s +/// `sync_service` handlers; the actual walk/diff/transfer is `SyncRunner`. +/// +/// Every run - foreground or background - goes through `startRun`, so one +/// per-account record knows what's running, "Sync now" can supersede it and a +/// quiet check can leave it alone, and signing out or removing a path can +/// cancel it. Runs are also serialised against each other (and against +/// removal) by one mutex, and carry the account's *generation*: cancelling or +/// removing bumps it, so a run that was only queued behind another never +/// starts for an account that's gone in the meantime. +/// +/// iOS decides *when* background work runs (typically while charging, on +/// Wi-Fi, learned from how you use the phone) - `intervalMinutes` is only the +/// earliest it may start, not a schedule, and a run is cut off when the +/// system's time budget ends (the runner saves its state as it goes). +final class SyncCoordinator { + static let shared = SyncCoordinator() + + static let refreshTaskId = "dev.ayushya.noo.sync.refresh" + static let processingTaskId = "dev.ayushya.noo.sync.processing" + + static let conflictCategory = "dev.ayushya.noo.sync.conflict" + static let keepLocalAction = "dev.ayushya.noo.sync.keepLocal" + static let useServerAction = "dev.ayushya.noo.sync.useServer" + + let store: SyncStore + let bus: SyncStatusBus + let configs: SyncConfigStore + private let runner: SyncRunner + + /// One run at a time, app-wide (see `AsyncMutex`). + private let mutex = AsyncMutex() + private let lock = NSLock() + private var running: [String: (token: UUID, task: Task)] = [:] + private var generations: [String: Int] = [:] + + init( + store: SyncStore = .shared, bus: SyncStatusBus = SyncStatusBus(), + configs: SyncConfigStore = SyncConfigStore(), client: DavSyncClient = DavSyncClient() + ) { + self.store = store + self.bus = bus + self.configs = configs + self.runner = SyncRunner(client: client, store: store, bus: bus) + } + + // MARK: - Settings from Dart + + /// Brings the background job in line with [config] (called whenever the + /// synced paths, account or network setting changes). A config with no + /// folders means this account has nothing to sync. + func reschedule(_ config: SyncAccountConfig) { + if config.folders.isEmpty { + cancel(accountId: config.accountId, forget: true) + return + } + configs.upsert(config) + scheduleBackgroundWork() + } + + /// [forget] true: the account was removed or signed out - stop its run and + /// drop its settings and credentials. [forget] false: only background sync + /// was turned off (manual refresh is still possible) - the credentials stay, + /// since a conflict notification's actions and the next "Sync now" need + /// them, and a run in progress is left to finish. + func cancel(accountId: String, forget: Bool = true) { + if forget { + cancelRun(accountId: accountId, invalidateQueued: true) + configs.remove(accountId: accountId) + } else if var config = configs.config(for: accountId) { + config.intervalMinutes = nil + configs.upsert(config) + } + scheduleBackgroundWork() + } + + /// A one-off run. [force] is an explicit "Sync now" / newly added path: + /// a full walk, a summary notification, and it supersedes any run already + /// going for this account; without it (pull-to-refresh, foreground timer, + /// app resume) it's a quiet check that never interrupts a run in progress. + /// [wifiOnly] non-nil means "respect the Wi-Fi-only setting". + func syncNow(_ config: SyncAccountConfig, force: Bool, wifiOnly: Bool?) { + if config.folders.isEmpty { return } + // Keep the background settings this call doesn't carry. + var stored = config + if let existing = configs.config(for: config.accountId) { + stored.intervalMinutes = existing.intervalMinutes + stored.wifiOnly = existing.wifiOnly + } + configs.upsert(stored) + startRun(stored.withNotify(config.notify), force: force, wifiOnly: wifiOnly) + } + + /// Stops [accountId]'s run, if any. [invalidateQueued] also stops runs that + /// are only waiting their turn from ever starting. + private func cancelRun(accountId: String, invalidateQueued: Bool) { + lock.lock() + if invalidateQueued { generations[accountId, default: 0] += 1 } + running[accountId]?.task.cancel() + lock.unlock() + } + + private func generation(of accountId: String) -> Int { + lock.lock() + defer { lock.unlock() } + return generations[accountId] ?? 0 + } + + /// Starts a tracked run, or - for a non-forced one when this account is + /// already running - returns nil and leaves the current run alone. + @discardableResult + private func startRun(_ config: SyncAccountConfig, force: Bool, wifiOnly: Bool?) -> Task? { + lock.lock() + if let existing = running[config.accountId] { + if force { + existing.task.cancel() + } else { + lock.unlock() + return nil + } + } + let token = UUID() + let generation = generations[config.accountId] ?? 0 + let task = Task { [weak self] in + await self?.execute(config, force: force, wifiOnly: wifiOnly, generation: generation) + self?.finished(accountId: config.accountId, token: token) + } + running[config.accountId] = (token, task) + lock.unlock() + return task + } + + private func finished(accountId: String, token: UUID) { + lock.lock() + if running[accountId]?.token == token { running[accountId] = nil } + lock.unlock() + } + + /// Turning sync off for [path]: stop this account's run first and do the + /// removal under the same mutex as runs - a run still holding the old + /// state would otherwise write it back over the removal (and a transfer + /// finishing after it would leave a file the state doesn't know about). + func removeLocalSync(accountId: String, path: String) async { + cancelRun(accountId: accountId, invalidateQueued: true) + _ = try? await mutex.withLock { + store.removeLocalSync(accountId: accountId, path: path) + } + } + + /// The map Dart's `SyncStatusSnapshot.fromMap` reads. [accountIdOverride] + /// is the account Dart is asking about: the bus only learns an account once + /// a run has happened in this process, so on a fresh start the durable + /// per-file synced state would otherwise be reported as empty. + func statusMap(_ status: SyncStatusBus.Status, accountIdOverride: String? = nil) -> [String: Any] { + let accountId = accountIdOverride ?? status.accountId + // Live syncing state belongs to whichever account last synced. + let busMatches = status.accountId == nil || status.accountId == accountId + let synced = accountId.map { Array(((try? store.loadState(accountId: $0)) ?? [:]).keys) } ?? [] + let missing = accountId.map { Array(store.loadMissingRoots(accountId: $0)) } ?? [] + return [ + "accountId": accountId as Any, + "syncing": busMatches && status.syncing, + "syncingFileIds": busMatches ? Array(status.syncingFileIds) : [], + "syncedFileIds": synced, + "missingRoots": missing, + // Conflicts are per account (a file id is only unique within one). + "conflicts": status.conflicts.filter { $0.accountId == accountId }.map { + [ + "accountId": $0.accountId, "fileId": $0.fileId, "remotePath": $0.remotePath, + "relPath": $0.relPath, "name": $0.name, + ] + }, + ] + } + + // MARK: - Running + + /// One account's pass, serialised against every other run. + private func execute( + _ config: SyncAccountConfig, force: Bool, wifiOnly: Bool?, generation: Int + ) async { + // A few extra seconds if the app is backgrounded mid-run; if even those + // run out, stop this account's run so it saves and ends cleanly. + let box = BackgroundTaskBox() + box.id = await MainActor.run { + UIApplication.shared.beginBackgroundTask(withName: "noo.sync") { [weak self] in + self?.cancelRun(accountId: config.accountId, invalidateQueued: false) + UIApplication.shared.endBackgroundTask(box.id) + } + } + defer { Task { @MainActor in UIApplication.shared.endBackgroundTask(box.id) } } + + do { + try await mutex.withLock { + try Task.checkCancellation() + // Gone (signed out / removed) while this was queued? + guard self.generation(of: config.accountId) == generation, + self.configs.config(for: config.accountId) != nil + else { return } + if let wifiOnly, await !NetworkGate.allows(wifiOnly: wifiOnly) { return } + let summary = await runner.run(config, force: force) + if summary.changedAnything && (force || config.notify) { + SyncNotifications.summary(summary, username: config.username, accountId: config.accountId) + } + SyncNotifications.conflicts(summary.conflicts, username: config.username) + } + } catch { + // Cancelled while waiting for its turn - nothing ran. + } + } + + /// Resolves a conflict from a notification action, using the stored + /// credentials for its account. + func resolveConflict(_ conflict: SyncConflict, resolution: String, creds: SyncCredentials) async -> Bool { + (try? await mutex.withLock { + await runner.resolveConflict(conflict, resolution: resolution, creds: creds) + }) ?? false + } + + // MARK: - Background tasks + + /// Registers the two `BGTask` handlers. Must run before the app finishes + /// launching (see `AppDelegate`). + func registerBackgroundTasks() { + for id in [Self.refreshTaskId, Self.processingTaskId] { + BGTaskScheduler.shared.register(forTaskWithIdentifier: id, using: nil) { [weak self] task in + self?.handleBackground(task) + } + } + } + + /// Asks iOS for the next background run: a short app-refresh slot and a + /// longer processing slot (the system picks when, e.g. overnight on power). + /// Nothing is requested when no account has background sync turned on. + func scheduleBackgroundWork() { + let scheduler = BGTaskScheduler.shared + let due = backgroundConfigs() + guard let minutes = due.compactMap(\.intervalMinutes).min() else { + scheduler.cancel(taskRequestWithIdentifier: Self.refreshTaskId) + scheduler.cancel(taskRequestWithIdentifier: Self.processingTaskId) + return + } + // Same floor as Android's periodic work. + let earliest = Date(timeIntervalSinceNow: TimeInterval(max(minutes, 15) * 60)) + + let refresh = BGAppRefreshTaskRequest(identifier: Self.refreshTaskId) + refresh.earliestBeginDate = earliest + let processing = BGProcessingTaskRequest(identifier: Self.processingTaskId) + processing.earliestBeginDate = earliest + processing.requiresNetworkConnectivity = true + processing.requiresExternalPower = false + for request in [refresh, processing] as [BGTaskRequest] { + do { try scheduler.submit(request) } catch { + NSLog("[Sync] couldn't schedule %@: %@", request.identifier, String(describing: error)) + } + } + } + + private func backgroundConfigs() -> [SyncAccountConfig] { + configs.all().filter { $0.intervalMinutes != nil && !$0.folders.isEmpty } + } + + /// A background slot: runs every account that has background sync on, each + /// through the normal tracked path (so it can be cancelled by sign-out, and + /// a run already going is left alone). The task is reported complete + /// exactly once - when the work ends or when the system's time does. + private func handleBackground(_ task: BGTask) { + scheduleBackgroundWork() // line up the next one before this one runs + let gate = OnceGate { task.setTaskCompleted(success: $0) } + let accountIds = backgroundConfigs().map(\.accountId) + let work = Task { [weak self] in + guard let self else { return gate.finish(false) } + for config in self.backgroundConfigs() { + if Task.isCancelled { break } + await self.startRun(config, force: false, wifiOnly: config.wifiOnly)?.value + } + gate.finish(!Task.isCancelled) + } + // The system's time is up: stop the work and the runs it started (each + // saves what it has) and report. + task.expirationHandler = { [weak self] in + work.cancel() + for id in accountIds { self?.cancelRun(accountId: id, invalidateQueued: false) } + gate.finish(false) + } + } + + // MARK: - Notifications + + /// Registers the conflict notification's actions. Call once at launch. + func registerNotificationCategories() { + let keep = UNNotificationAction(identifier: Self.keepLocalAction, title: "Keep local", options: []) + let server = UNNotificationAction(identifier: Self.useServerAction, title: "Use server", options: []) + let category = UNNotificationCategory( + identifier: Self.conflictCategory, actions: [keep, server], intentIdentifiers: [], options: []) + UNUserNotificationCenter.current().setNotificationCategories([category]) + } + + /// Handles a tap on a conflict notification's action. Returns whether the + /// response was ours (so `AppDelegate` knows not to pass it on). + @MainActor + func handleNotificationResponse( + _ response: UNNotificationResponse, completion: @escaping () -> Void + ) -> Bool { + let content = response.notification.request.content + guard content.categoryIdentifier == Self.conflictCategory else { return false } + let resolution: String + switch response.actionIdentifier { + case Self.keepLocalAction: resolution = "local" + case Self.useServerAction: resolution = "server" + default: + completion() // plain tap: just opens the app + return true + } + let info = content.userInfo + guard let accountId = info["accountId"] as? String, + let fileId = info["fileId"] as? String, + let remotePath = info["remotePath"] as? String, + let relPath = info["relPath"] as? String, + let config = configs.config(for: accountId) + else { + completion() + return true + } + let conflict = SyncConflict( + accountId: accountId, fileId: fileId, remotePath: remotePath, relPath: relPath, + name: (relPath as NSString).lastPathComponent) + let creds = SyncCredentials(serverUrl: config.serverUrl, username: config.username, authHeader: config.authHeader) + let box = BackgroundTaskBox() + box.id = UIApplication.shared.beginBackgroundTask(withName: "noo.sync.resolve") { + completion() + UIApplication.shared.endBackgroundTask(box.id) + } + Task { + let ok = await self.resolveConflict(conflict, resolution: resolution, creds: creds) + if ok { + UNUserNotificationCenter.current().removeDeliveredNotifications( + withIdentifiers: [response.notification.request.identifier]) + } + completion() + await MainActor.run { UIApplication.shared.endBackgroundTask(box.id) } + } + return true + } +} + +/// Holds a `UIBackgroundTaskIdentifier` so an expiration handler (created +/// before the identifier exists) can end the task it belongs to. +final class BackgroundTaskBox { + var id: UIBackgroundTaskIdentifier = .invalid +} + +extension SyncAccountConfig { + func withNotify(_ notify: Bool) -> SyncAccountConfig { + var copy = self + copy.notify = notify + return copy + } +} + +/// The local notifications sync posts: a summary of what changed, and one per +/// conflict (which always posts - it needs a decision) with Keep local / Use +/// server actions. +enum SyncNotifications { + static func summary(_ summary: SyncRunSummary, username: String, accountId: String) { + var parts: [String] = [] + if summary.downloaded > 0 { parts.append("\(summary.downloaded) updated") } + if summary.uploaded > 0 { parts.append("\(summary.uploaded) uploaded") } + if summary.deleted > 0 { parts.append("\(summary.deleted) removed") } + let content = UNMutableNotificationContent() + content.title = "Noo sync ยท \(username)" + content.body = parts.joined(separator: ", ") + // One per account, so a newer summary replaces an older one in place. + post(content, id: "sync.summary.\(accountId)") + } + + static func conflicts(_ conflicts: [SyncConflict], username: String) { + for conflict in conflicts { + let content = UNMutableNotificationContent() + content.title = "Sync conflict: \(conflict.name)" + content.body = "\(username) - changed both on this device and on the server." + content.categoryIdentifier = SyncCoordinator.conflictCategory + content.userInfo = [ + "accountId": conflict.accountId, "fileId": conflict.fileId, + "remotePath": conflict.remotePath, "relPath": conflict.relPath, + ] + post(content, id: "sync.conflict.\(conflict.accountId).\(conflict.relPath)") + } + } + + private static func post(_ content: UNMutableNotificationContent, id: String) { + UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: id, content: content, trigger: nil)) + } +} diff --git a/ios/Runner/Native/SyncEngine/SyncDiff.swift b/ios/Runner/Native/SyncEngine/SyncDiff.swift new file mode 100644 index 0000000..de4bb60 --- /dev/null +++ b/ios/Runner/Native/SyncEngine/SyncDiff.swift @@ -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, + syncRoot: URL + ) -> [SyncAction] { + var actions: [SyncAction] = [] + var seen = Set() + + 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 { + 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 { + 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, 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 = [] + ) { + 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) { + 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) + } +} diff --git a/ios/Runner/Native/SyncEngine/SyncModels.swift b/ios/Runner/Native/SyncEngine/SyncModels.swift new file mode 100644 index 0000000..19a83e6 --- /dev/null +++ b/ios/Runner/Native/SyncEngine/SyncModels.swift @@ -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" } +} diff --git a/ios/Runner/Native/SyncEngine/SyncRunner.swift b/ios/Runner/Native/SyncEngine/SyncRunner.swift new file mode 100644 index 0000000..7cbee73 --- /dev/null +++ b/ios/Runner/Native/SyncEngine/SyncRunner.swift @@ -0,0 +1,392 @@ +import Foundation + +/// A mutex for `async` code: one holder at a time, waiters resume in order. +/// (An `actor` isn't enough - it lets other calls in at every `await`.) Held +/// for the whole of any run that reads-then-rewrites a sync state map, which +/// is also what keeps `SyncStatusBus` - it tracks one account at a time - +/// coherent when several accounts are due. +/// +/// Waiting is cancellable: a task that's cancelled while queued (a background +/// run whose time budget expired, a superseded "Sync now") leaves the queue +/// at once with `CancellationError` instead of sitting behind a long run. +final class AsyncMutex { + private let lock = NSLock() + private var locked = false + private var waiters: [(id: UUID, continuation: CheckedContinuation)] = [] + + func withLock(_ body: () async throws -> T) async throws -> T { + try await acquire() + defer { release() } + return try await body() + } + + private func acquire() async throws { + let id = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + lock.lock() + if Task.isCancelled { + lock.unlock() + continuation.resume(throwing: CancellationError()) + } else if !locked { + locked = true + lock.unlock() + continuation.resume() + } else { + waiters.append((id, continuation)) + lock.unlock() + } + } + } onCancel: { + lock.lock() + if let index = waiters.firstIndex(where: { $0.id == id }) { + let waiter = waiters.remove(at: index) + lock.unlock() + waiter.continuation.resume(throwing: CancellationError()) + } else { + lock.unlock() + } + } + } + + private func release() { + lock.lock() + if waiters.isEmpty { + locked = false + lock.unlock() + } else { + let next = waiters.removeFirst() + lock.unlock() + next.continuation.resume() + } + } +} + +/// Live sync status for the app: whether a run is going, which files are +/// transferring, and unresolved conflicts. In-memory only - what actually +/// answers "is this file synced" is the on-disk state; this carries just the +/// transient parts. The iOS twin of Android's `SyncStatusBus`, except that +/// conflicts are kept per account (a file id is only unique within one). +final class SyncStatusBus { + struct Status: Equatable { + var accountId: String? + var syncing = false + var syncingFileIds: Set = [] + /// Every account's unresolved conflicts - readers filter by account. + var conflicts: [SyncConflict] = [] + } + + private let lock = NSLock() + private var current = Status() + + /// Called (on whatever thread changed it) after every update. + var onChange: ((Status) -> Void)? + + func snapshot() -> Status { + lock.lock() + defer { lock.unlock() } + return current + } + + func setSyncing(accountId: String, _ syncing: Bool) { + update { + $0.accountId = accountId + $0.syncing = syncing + if !syncing { $0.syncingFileIds = [] } + } + } + + func markFileSyncing(accountId: String, fileId: String, _ syncing: Bool) { + update { + $0.accountId = accountId + if syncing { $0.syncingFileIds.insert(fileId) } else { $0.syncingFileIds.remove(fileId) } + } + } + + func addConflicts(accountId: String, _ new: [SyncConflict]) { + guard !new.isEmpty else { return } + update { + $0.accountId = accountId + for conflict in new + where !$0.conflicts.contains(where: { $0.accountId == conflict.accountId && $0.fileId == conflict.fileId }) { + $0.conflicts.append(conflict) + } + } + } + + func removeConflict(accountId: String, fileId: String) { + update { + $0.accountId = accountId + $0.conflicts.removeAll { $0.accountId == accountId && $0.fileId == fileId } + } + } + + private func update(_ mutate: (inout Status) -> Void) { + lock.lock() + mutate(¤t) + let snapshot = current + lock.unlock() + onChange?(snapshot) + } +} + +/// One account's sync pass: walk each configured path, diff it against the +/// recorded state, transfer, and record the result. The iOS port of Android's +/// `SyncWorker.performSync` (and `ConflictResolveWorker`), with the same +/// safeguards - and a few more, found in review: +/// +/// - If the server can't be reached for a path - or answers with something +/// that isn't a complete, plausible listing - that path is skipped for this +/// run, never diffed against an empty or partial listing, which would look +/// like files were deleted and wipe the local copies. +/// - If the recorded state can't be read the run does nothing at all (it +/// would otherwise treat every local file as untracked and overwrite local +/// edits with the server's copies). +/// - Paths the server sends are checked to stay inside the mirror, and two +/// remote files that would land on the same local file are both skipped. +/// - A file deleted on the server but edited here is kept, not deleted. +/// - A root's etag is only remembered once its whole path synced cleanly, and +/// only if the state it relies on was actually saved. +struct SyncRunner { + let client: DavSyncClient + let store: SyncStore + let bus: SyncStatusBus + var now: () -> Int64 = { Int64(Date().timeIntervalSince1970 * 1000) } + + /// Even when a root's etag hasn't changed, walk it in full at least this + /// often: etag propagation isn't reliable everywhere (external storage + /// mounts in particular), so this bounds how stale a mirror can get. + static let fullWalkMaxAgeMs: Int64 = 6 * 60 * 60 * 1000 + + /// State is flushed to disk after this many transfers. + static let saveEvery = 20 + + /// [force] is an explicit "Sync now" / newly added path: always a full + /// walk, never trusting the root-etag shortcut. + func run(_ config: SyncAccountConfig, force: Bool) async -> SyncRunSummary { + var summary = SyncRunSummary() + guard !config.folders.isEmpty else { return summary } + let accountId = config.accountId + let creds = SyncCredentials(serverUrl: config.serverUrl, username: config.username, authHeader: config.authHeader) + let syncRoot = store.syncRoot(accountId: accountId) + + var state: [String: SyncFileState] + do { + state = try store.loadState(accountId: accountId) + } catch { + NSLog("[Sync] %@ - skipping this run", String(describing: error)) + return summary + } + var markers = store.loadRootMarkers(accountId: accountId) + var sinceSave = 0 + + bus.setSyncing(accountId: accountId, true) + defer { + // State first, then the markers that depend on it, then announce. + if store.saveState(accountId: accountId, state) { + markers = markers.filter { config.folders.contains($0.key) } + store.saveRootMarkers(accountId: accountId, markers) + } else { + NSLog("[Sync] couldn't save state for %@; not recording this run's markers", accountId) + } + bus.setSyncing(accountId: accountId, false) + } + + func transferred() { + sinceSave += 1 + if sinceSave >= Self.saveEvery { + if !store.saveState(accountId: accountId, state) { NSLog("[Sync] periodic state save failed") } + sinceSave = 0 + } + } + + for path in config.folders { + if Task.isCancelled { break } + do { + // A configured path can be a file or a folder - check which before + // deciding whether to walk it or just diff the single item. + let selfEntry = try await client.propfindSelf(creds, path: path) + let priorIds = SyncDiff.priorFileIds(state: state, path: path) + + // Cheap "did anything change?" check: an unchanged root etag means + // nothing beneath it changed on the server, and if every local file + // is also still as recorded there's nothing to upload or re-download + // either - skip the walk (one Depth-0 PROPFIND instead of one per + // subfolder). + if !force, let root = selfEntry, root.isFolder, let marker = markers[path], + marker.etag == root.etag, + now() - marker.fullWalkAt < Self.fullWalkMaxAgeMs, + !priorIds.isEmpty, + SyncDiff.localMatchesState(state: state, fileIds: priorIds, syncRoot: syncRoot) + { + continue + } + + // The server says this synced path no longer exists (a clean 404, not + // a network error): flag it so the app drops it from its synced list. + store.setRootMissing(accountId: accountId, path: path, missing: selfEntry == nil) + + var entries: [SyncRemoteEntry] = [] + if let root = selfEntry { + if root.isFolder { + entries = try await client.walk(creds, root: path) + } else { + entries = [root] + } + } + var pathClean = true + let collisions = SyncDiff.collidingRelPaths(entries) + + let actions = SyncDiff.diffFolder( + entries: entries, state: state, priorFolderFileIds: priorIds, syncRoot: syncRoot) + for action in actions { + try Task.checkCancellation() + switch action { + case .download(let entry): + let rel = LocalFS.relative(entry.path) + guard !collisions.contains(rel), let dest = LocalFS.resolve(rel, in: syncRoot) else { + NSLog("[Sync] not downloading %@: unsafe or colliding local path", rel) + pathClean = false + continue + } + bus.markFileSyncing(accountId: accountId, fileId: entry.fileId, true) + let ok = try await client.download(creds, remotePath: entry.path, to: dest) + bus.markFileSyncing(accountId: accountId, fileId: entry.fileId, false) + if ok { + let local = LocalFS.stat(dest) + state[entry.fileId] = SyncFileState( + relPath: rel, etag: entry.etag, lastModified: entry.lastModified, + size: local?.size ?? entry.size, localMTime: local?.mtimeMs ?? 0) + summary.downloaded += 1 + transferred() + } else { + pathClean = false + } + + case .upload(let rel, let fileId): + guard let local = LocalFS.resolve(rel, in: syncRoot) else { + pathClean = false + continue + } + bus.markFileSyncing(accountId: accountId, fileId: fileId, true) + var ok = false + if FileManager.default.fileExists(atPath: local.path) { + ok = try await client.upload(creds, remotePath: "/" + rel, from: local) + } + bus.markFileSyncing(accountId: accountId, fileId: fileId, false) + if ok, let stat = LocalFS.stat(local) { + // Our own PUT changed the file's etag; record the new one, or + // the next pass would see "server changed" and re-download it. + // If it can't be read back, keep the old one and let the root + // be walked for real next time. + let fresh = (try? await client.propfindSelf(creds, path: "/" + rel)) ?? nil + if fresh == nil { pathClean = false } + state[fileId] = SyncFileState( + relPath: rel, etag: fresh?.etag ?? state[fileId]?.etag ?? "", + lastModified: stat.mtimeMs, size: stat.size, localMTime: stat.mtimeMs) + summary.uploaded += 1 + transferred() + } else { + pathClean = false + } + + case .delete(let rel, let fileId): + // Only counts as a removal if a local copy existed and went; + // dropping stale state for a file never on disk isn't news. + if let local = LocalFS.resolve(rel, in: syncRoot), + FileManager.default.fileExists(atPath: local.path), + (try? FileManager.default.removeItem(at: local)) != nil + { + summary.deleted += 1 + } + state[fileId] = nil + + case .orphan(_, let fileId): + // Deleted on the server but edited here: the edit stays on the + // device, no longer tracked. + state[fileId] = nil + + case .conflict(let entry, let rel): + summary.conflicts.append( + SyncConflict( + accountId: accountId, fileId: entry.fileId, remotePath: entry.path, relPath: rel, + name: (rel as NSString).lastPathComponent)) + pathClean = false + } + } + + // Mirror the folder structure itself: empty folders created on the + // server appear on the device, and folders deleted on the server + // (whose files the diff has already removed) don't linger. + if selfEntry == nil || selfEntry?.isFolder == true { + let rootRel = path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + if selfEntry != nil { + SyncDiff.mirrorFolders(entries: entries, syncRoot: syncRoot, rootRel: rootRel, collisions: collisions) + } + var remoteFolders = Set( + entries.filter(\.isFolder).map { $0.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) }) + if selfEntry != nil { remoteFolders.insert(rootRel) } + SyncDiff.pruneRemovedFolders(syncRoot: syncRoot, rootRel: rootRel, remoteFolders: remoteFolders) + } + + if let root = selfEntry, root.isFolder, pathClean { + markers[path] = SyncRootMarker(etag: root.etag, fullWalkAt: now()) + } else { + markers[path] = nil + } + } catch is CancellationError { + markers[path] = nil // cut short: make the next run walk it for real + break + } catch { + // Couldn't reach the server for this path (or it answered with + // nothing usable): leave its state and marker alone and try again + // next run. + NSLog("[Sync] skipping %@ this run: %@", path, String(describing: error)) + } + } + + if !summary.conflicts.isEmpty { + bus.addConflicts(accountId: accountId, summary.conflicts) + } + return summary + } + + /// Applies the user's choice for one conflicted file - `local` uploads the + /// device's copy over the server's, `server` downloads the server's over + /// the device's - then refreshes the recorded etag so the next pass doesn't + /// immediately re-flag it. Returns whether it worked. + func resolveConflict( + _ conflict: SyncConflict, resolution: String, creds: SyncCredentials + ) async -> Bool { + // An unreadable baseline must not be overwritten with a one-file one. + guard var state = try? store.loadState(accountId: conflict.accountId), + let local = LocalFS.resolve(conflict.relPath, in: store.syncRoot(accountId: conflict.accountId)) + else { return false } + + var ok = false + do { + switch resolution { + case "local": + if FileManager.default.fileExists(atPath: local.path) { + ok = try await client.upload(creds, remotePath: conflict.remotePath, from: local) + } + case "server": + ok = try await client.download(creds, remotePath: conflict.remotePath, to: local) + default: + break + } + } catch { + return false + } + guard ok else { return false } + + let fresh = (try? await client.propfindSelf(creds, path: conflict.remotePath)) ?? nil + let stat = LocalFS.stat(local) + state[conflict.fileId] = SyncFileState( + relPath: conflict.relPath, etag: fresh?.etag ?? "", lastModified: fresh?.lastModified ?? 0, + size: stat?.size ?? 0, localMTime: stat?.mtimeMs ?? 0) + store.saveState(accountId: conflict.accountId, state) + bus.removeConflict(accountId: conflict.accountId, fileId: conflict.fileId) + return true + } +} diff --git a/ios/Runner/Native/SyncEngine/SyncStore.swift b/ios/Runner/Native/SyncEngine/SyncStore.swift new file mode 100644 index 0000000..0f5d626 --- /dev/null +++ b/ios/Runner/Native/SyncEngine/SyncStore.swift @@ -0,0 +1,175 @@ +import Foundation + +/// The on-device side of sync: the mirrored files and the per-account sync +/// state. Laid out under one base directory (Application Support, which is +/// also where Dart's `SyncService.baseDirectory()` looks for the mirror): +/// +/// /sync//... the mirrored files +/// /sync-state//*.json state, root markers, missing roots +/// +/// Plain JSON files rather than `UserDefaults`: the state map grows with the +/// number of synced files. Not thread-safe by itself - the sync runner holds +/// one lock around anything that reads-then-rewrites it (see `AsyncMutex`). +final class SyncStore { + /// Bumped whenever a walk starts doing something new (e.g. mirroring empty + /// folders) so markers recorded by an older version - which would make the + /// next run skip the walk - are ignored once. Same value as Android. + static let rootsVersion = 2 + + static let shared = SyncStore(base: defaultBase) + + static var defaultBase: URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + } + + let base: URL + private let fileManager = FileManager.default + + init(base: URL) { + self.base = base + } + + // MARK: - Locations + + /// The account's mirror folder, created on demand. Marked excluded from + /// iCloud/device backups: it's a re-downloadable copy of server data. + func syncRoot(accountId: String) -> URL { + let top = base.appendingPathComponent("sync", isDirectory: true) + let root = top.appendingPathComponent(accountId, isDirectory: true) + try? fileManager.createDirectory(at: root, withIntermediateDirectories: true) + var values = URLResourceValues() + values.isExcludedFromBackup = true + var topMutable = top + try? topMutable.setResourceValues(values) + return root + } + + private func stateDirectory(accountId: String) -> URL { + let dir = base.appendingPathComponent("sync-state", isDirectory: true) + .appendingPathComponent(accountId, isDirectory: true) + try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + private func file(_ accountId: String, _ name: String) -> URL { + stateDirectory(accountId: accountId).appendingPathComponent(name) + } + + // MARK: - Per-file state + + /// The recorded baseline - empty if nothing has been recorded yet, and + /// *throws* if there is a file that can't be read or decoded (see + /// `SyncStateUnreadable`). + func loadState(accountId: String) throws -> [String: SyncFileState] { + let url = file(accountId, "state.json") + guard fileManager.fileExists(atPath: url.path) else { return [:] } + do { + return try JSONDecoder().decode([String: SyncFileState].self, from: Data(contentsOf: url)) + } catch { + throw SyncStateUnreadable(accountId: accountId) + } + } + + /// False if the state couldn't be written - callers must not then record + /// anything that assumes it was. + @discardableResult + func saveState(accountId: String, _ state: [String: SyncFileState]) -> Bool { + write(state, to: file(accountId, "state.json")) + } + + // MARK: - Root markers + + private struct MarkersFile: Codable { + let version: Int + let markers: [String: SyncRootMarker] + } + + func loadRootMarkers(accountId: String) -> [String: SyncRootMarker] { + guard let stored = read(MarkersFile.self, from: file(accountId, "roots.json")), + stored.version == Self.rootsVersion + else { return [:] } + return stored.markers + } + + @discardableResult + func saveRootMarkers(accountId: String, _ markers: [String: SyncRootMarker]) -> Bool { + write(MarkersFile(version: Self.rootsVersion, markers: markers), to: file(accountId, "roots.json")) + } + + // MARK: - Roots the server says are gone + + func loadMissingRoots(accountId: String) -> Set { + Set(read([String].self, from: file(accountId, "missing.json")) ?? []) + } + + func setRootMissing(accountId: String, path: String, missing: Bool) { + var current = loadMissingRoots(accountId: accountId) + let changed = missing ? current.insert(path).inserted : (current.remove(path) != nil) + if changed { _ = write(Array(current).sorted(), to: file(accountId, "missing.json")) } + } + + // MARK: - Removing a path's mirror + + /// Deletes [path]'s local mirror (a file, or a whole folder's worth) and + /// forgets its entries - called when the user turns sync off for that path. + /// Without clearing the state too, a later re-add would see the (now + /// missing) local file as "deleted, server unchanged" and not pull it back. + func removeLocalSync(accountId: String, path: String) { + var cleanPath = path.trimmingCharacters(in: .whitespaces) + if cleanPath.hasPrefix("/") { cleanPath.removeFirst() } + while cleanPath.hasSuffix("/") { cleanPath.removeLast() } + let prefix = cleanPath.isEmpty ? "" : cleanPath + "/" + + // An unreadable baseline is left alone rather than overwritten with a + // partial one; the next run reports it and does nothing. + if var state = try? loadState(accountId: accountId) { + // The sync root itself ("/") covers every recorded file. + for (fileId, entry) in state + where cleanPath.isEmpty || entry.relPath == cleanPath || entry.relPath.hasPrefix(prefix) { + state[fileId] = nil + } + saveState(accountId: accountId, state) + } + + setRootMissing(accountId: accountId, path: "/" + cleanPath, missing: false) + setRootMissing(accountId: accountId, path: cleanPath, missing: false) + + // A root marker for this path (or an ancestor/descendant that also covers + // some of what was just deleted) would make the next background pass + // think nothing changed and skip re-downloading. + let removedPath = "/" + cleanPath + var markers = loadRootMarkers(accountId: accountId) + let stale = markers.keys.filter { key in + var k = key + while k.hasSuffix("/") { k.removeLast() } + return k.isEmpty || removedPath == "/" || k == removedPath || k.hasPrefix(removedPath + "/") + || removedPath.hasPrefix(k + "/") + } + if !stale.isEmpty { + stale.forEach { markers[$0] = nil } + saveRootMarkers(accountId: accountId, markers) + } + + let root = syncRoot(accountId: accountId) + let target = cleanPath.isEmpty ? root : root.appendingPathComponent(cleanPath) + if fileManager.fileExists(atPath: target.path) { try? fileManager.removeItem(at: target) } + if cleanPath.isEmpty { _ = syncRoot(accountId: accountId) } + } + + // MARK: - Helpers + + private func read(_ type: T.Type, from url: URL) -> T? { + guard let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode(T.self, from: data) + } + + private func write(_ value: T, to url: URL) -> Bool { + guard let data = try? JSONEncoder().encode(value) else { return false } + do { + try data.write(to: url, options: .atomic) + return true + } catch { + return false + } + } +} diff --git a/ios/RunnerTests/SyncEngineTests.swift b/ios/RunnerTests/SyncEngineTests.swift new file mode 100644 index 0000000..395956e --- /dev/null +++ b/ios/RunnerTests/SyncEngineTests.swift @@ -0,0 +1,845 @@ +import XCTest + +@testable import Runner + +/// An in-memory Nextcloud that answers just enough WebDAV (PROPFIND depth 0/1, +/// GET, PUT) for the sync engine to run end to end against - with ancestor +/// etags bumping on every change, like the real thing. +final class FakeDav: URLProtocol { + struct Node { + var isFolder: Bool + var data = Data() + var etag: String + var fileId: String + var mtime = Date(timeIntervalSince1970: 1_700_000_000) + } + + static var nodes: [String: Node] = [:] + static var failAll = false + /// A folder whose PROPFIND answers 500 while everything else works - for + /// proving a half-built manifest is never trusted. + static var failPropfindPath: String? + /// Answer PROPFIND with a 207 whose XML is cut off mid-way. + static var truncatePropfind = false + static var log: [String] = [] + private static var counter = 0 + static let user = "alice" + private static let base = "/remote.php/dav/files/alice" + + static func reset() { + nodes = ["/": Node(isFolder: true, etag: nextTag(), fileId: "root")] + failAll = false + failPropfindPath = nil + truncatePropfind = false + log = [] + counter = 0 + } + + private static func nextTag() -> String { + counter += 1 + return "tag\(counter)" + } + + private static func parent(of path: String) -> String { + let p = (path as NSString).deletingLastPathComponent + return p.isEmpty ? "/" : p + } + + /// Bumps [path]'s etag and every ancestor's. + private static func touch(_ path: String) { + var p = path + while true { + nodes[p]?.etag = nextTag() + if p == "/" { break } + p = parent(of: p) + } + } + + // MARK: Test-side helpers + + static func mkdir(_ path: String) { + var parts: [String] = [] + for comp in path.split(separator: "/") { + parts.append(String(comp)) + let p = "/" + parts.joined(separator: "/") + if nodes[p] == nil { + nodes[p] = Node(isFolder: true, etag: nextTag(), fileId: "id\(counter)") + touch(parent(of: p)) + } + } + } + + static func put(_ path: String, _ text: String) { + mkdir(parent(of: path)) + var node = nodes[path] ?? Node(isFolder: false, etag: "", fileId: "id\(nextTag())") + node.data = Data(text.utf8) + nodes[path] = node + touch(path) + } + + static func remove(_ path: String) { + for key in nodes.keys where key == path || key.hasPrefix(path + "/") { nodes[key] = nil } + touch(parent(of: path)) + } + + static func text(_ path: String) -> String? { + nodes[path].flatMap { String(data: $0.data, encoding: .utf8) } + } + + // MARK: URLProtocol + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func stopLoading() {} + + override func startLoading() { + let request = self.request + let method = request.httpMethod ?? "GET" + let depth = request.value(forHTTPHeaderField: "Depth") ?? "-" + var path = (request.url?.path ?? "").removingPercentEncoding ?? "" + if path.hasPrefix(Self.base) { path = String(path.dropFirst(Self.base.count)) } + while path.count > 1 && path.hasSuffix("/") { path.removeLast() } + if path.isEmpty { path = "/" } + Self.log.append("\(method) \(depth) \(path)") + + func send(_ status: Int, _ body: Data = Data()) { + let response = HTTPURLResponse( + url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: nil)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: body) + client?.urlProtocolDidFinishLoading(self) + } + + if Self.failAll { return send(503) } + + switch method { + case "PROPFIND": + if path == Self.failPropfindPath { return send(500) } + guard Self.nodes[path] != nil else { return send(404) } + var paths = [path] + if depth == "1" { + paths += Self.nodes.keys.filter { $0 != path && Self.parent(of: $0) == path }.sorted() + } + var body = Self.multistatus(paths) + // Only folder listings: a cut-off *listing* still holds a few complete + // responses, which is the dangerous case (a partial manifest). + if Self.truncatePropfind && depth == "1" { body = String(body.prefix(body.count * 2 / 3)) } + send(207, Data(body.utf8)) + case "GET": + guard let node = Self.nodes[path], !node.isFolder else { return send(404) } + send(200, node.data) + case "PUT": + var data = Data() + if let stream = request.httpBodyStream { + stream.open() + var buffer = [UInt8](repeating: 0, count: 4096) + while stream.hasBytesAvailable { + let n = stream.read(&buffer, maxLength: buffer.count) + if n <= 0 { break } + data.append(buffer, count: n) + } + stream.close() + } else if let body = request.httpBody { + data = body + } + Self.mkdir(Self.parent(of: path)) + var node = Self.nodes[path] ?? Node(isFolder: false, etag: "", fileId: "id\(Self.nextTag())") + node.data = data + Self.nodes[path] = node + Self.touch(path) + send(201) + default: + send(405) + } + } + + private static let httpDate: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(secondsFromGMT: 0) + f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss 'GMT'" + return f + }() + + private static func multistatus(_ paths: [String]) -> String { + let responses = paths.map { p -> String in + let node = nodes[p]! + let encoded = p.split(separator: "/").map { WebDAV.encodeSegment(String($0)) }.joined(separator: "/") + let href = base + (encoded.isEmpty ? "" : "/" + encoded) + (node.isFolder ? "/" : "") + return """ + \(href)\ + \(httpDate.string(from: node.mtime))\ + \(node.data.count)\ + \(node.isFolder ? "" : "")\ + "\(node.etag)"\(node.fileId)\ + HTTP/1.1 200 OK + """ + }.joined() + return """ + \(responses) + """ + } +} + +final class SyncEngineTests: XCTestCase { + private var store: SyncStore! + private var bus: SyncStatusBus! + private var runner: SyncRunner! + private var fakeSession: URLSession! + private var clock: Int64 = 1_700_000_000_000 + private let config = SyncAccountConfig( + accountId: "acct", serverUrl: "https://dav.test", username: "alice", authHeader: "Basic x", + folders: ["/Docs"], wifiOnly: false, intervalMinutes: nil, notify: false) + private var creds: SyncCredentials { + SyncCredentials(serverUrl: config.serverUrl, username: config.username, authHeader: config.authHeader) + } + + override func setUp() { + super.setUp() + FakeDav.reset() + let base = FileManager.default.temporaryDirectory + .appendingPathComponent("noo-sync-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: base) } + store = SyncStore(base: base) + bus = SyncStatusBus() + + let sessionConfig = URLSessionConfiguration.ephemeral + sessionConfig.protocolClasses = [FakeDav.self] + fakeSession = URLSession(configuration: sessionConfig) + runner = SyncRunner( + client: DavSyncClient(session: fakeSession), + store: store, bus: bus, now: { [unowned self] in self.clock }) + } + + private func state() -> [String: SyncFileState] { + (try? store.loadState(accountId: "acct")) ?? [:] + } + + private var mirror: URL { store.syncRoot(accountId: "acct") } + private func local(_ rel: String) -> String? { + try? String(contentsOf: mirror.appendingPathComponent(rel), encoding: .utf8) + } + private func exists(_ rel: String) -> Bool { + FileManager.default.fileExists(atPath: mirror.appendingPathComponent(rel).path) + } + private func editLocally(_ rel: String, _ text: String) throws { + let url = mirror.appendingPathComponent(rel) + try text.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.modificationDate: Date().addingTimeInterval(60)], ofItemAtPath: url.path) + } + private func depth1Requests() -> Int { FakeDav.log.filter { $0.hasPrefix("PROPFIND 1") }.count } + + private func seedServer() { + FakeDav.put("/Docs/a.txt", "alpha") + FakeDav.put("/Docs/sub/b.txt", "bravo") + } + + // MARK: - The happy path + + func testFirstSyncDownloadsEverythingAndMirrorsFolders() async { + seedServer() + FakeDav.mkdir("/Docs/empty") + let summary = await runner.run(config, force: false) + + XCTAssertEqual(summary.downloaded, 2) + XCTAssertEqual(local("Docs/a.txt"), "alpha") + XCTAssertEqual(local("Docs/sub/b.txt"), "bravo") + XCTAssertTrue(exists("Docs/empty"), "empty server folders are mirrored") + XCTAssertEqual(state().count, 2) + XCTAssertNotNil(store.loadRootMarkers(accountId: "acct")["/Docs"]) + XCTAssertFalse(bus.snapshot().syncing, "announced as finished") + } + + func testUnchangedServerIsOneCheapRequestNotAWalk() async { + seedServer() + _ = await runner.run(config, force: false) + let walks = depth1Requests() + + FakeDav.log = [] + let summary = await runner.run(config, force: false) + XCTAssertFalse(summary.changedAnything) + XCTAssertEqual(depth1Requests(), 0, "the root etag shortcut skipped the walk (was \(walks) before)") + XCTAssertEqual(FakeDav.log, ["PROPFIND 0 /Docs"]) + } + + func testForceAlwaysWalks() async { + seedServer() + _ = await runner.run(config, force: false) + FakeDav.log = [] + _ = await runner.run(config, force: true) + XCTAssertGreaterThan(depth1Requests(), 0) + } + + func testShortcutExpiresAfterTheMaxAge() async { + seedServer() + _ = await runner.run(config, force: false) + clock += SyncRunner.fullWalkMaxAgeMs + 1 + FakeDav.log = [] + _ = await runner.run(config, force: false) + XCTAssertGreaterThan(depth1Requests(), 0, "etags aren't trusted forever") + } + + func testServerChangeIsDownloaded() async { + seedServer() + _ = await runner.run(config, force: false) + FakeDav.put("/Docs/a.txt", "alpha v2") + FakeDav.put("/Docs/new.txt", "fresh") + + let summary = await runner.run(config, force: false) + XCTAssertEqual(summary.downloaded, 2) + XCTAssertEqual(local("Docs/a.txt"), "alpha v2") + XCTAssertEqual(local("Docs/new.txt"), "fresh") + } + + func testLocalEditIsUploadedOnceAndNotReDownloaded() async throws { + seedServer() + _ = await runner.run(config, force: false) + try editLocally("Docs/a.txt", "alpha edited on phone") + + let summary = await runner.run(config, force: false) + XCTAssertEqual(summary.uploaded, 1) + XCTAssertEqual(FakeDav.text("/Docs/a.txt"), "alpha edited on phone") + + let again = await runner.run(config, force: false) + XCTAssertFalse(again.changedAnything, "our own upload must not come back as a server change") + XCTAssertEqual(local("Docs/a.txt"), "alpha edited on phone") + } + + func testFileDeletedLocallyIsPulledBack() async throws { + seedServer() + _ = await runner.run(config, force: false) + try FileManager.default.removeItem(at: mirror.appendingPathComponent("Docs/a.txt")) + + let summary = await runner.run(config, force: false) + XCTAssertEqual(summary.downloaded, 1) + XCTAssertEqual(local("Docs/a.txt"), "alpha") + XCTAssertNotNil(FakeDav.text("/Docs/a.txt"), "a local delete never deletes on the server") + } + + // MARK: - Conflicts + + func testChangedOnBothSidesIsAConflictAndNothingIsOverwritten() async throws { + seedServer() + _ = await runner.run(config, force: false) + FakeDav.put("/Docs/a.txt", "server edit") + try editLocally("Docs/a.txt", "phone edit") + + let summary = await runner.run(config, force: false) + XCTAssertEqual(summary.conflicts.map(\.relPath), ["Docs/a.txt"]) + XCTAssertEqual(summary.conflicts.first?.name, "a.txt") + XCTAssertEqual(local("Docs/a.txt"), "phone edit", "the device copy is untouched") + XCTAssertEqual(FakeDav.text("/Docs/a.txt"), "server edit", "the server copy is untouched") + XCTAssertEqual(bus.snapshot().conflicts.count, 1) + XCTAssertNil(store.loadRootMarkers(accountId: "acct")["/Docs"], "an unresolved conflict forces a real walk next time") + } + + func testResolveWithServerCopyThenNoConflictOnTheNextPass() async throws { + seedServer() + _ = await runner.run(config, force: false) + FakeDav.put("/Docs/a.txt", "server edit") + try editLocally("Docs/a.txt", "phone edit") + let conflict = await runner.run(config, force: false).conflicts[0] + + let ok = await runner.resolveConflict(conflict, resolution: "server", creds: creds) + XCTAssertTrue(ok) + XCTAssertEqual(local("Docs/a.txt"), "server edit") + XCTAssertTrue(bus.snapshot().conflicts.isEmpty) + + let next = await runner.run(config, force: false) + XCTAssertTrue(next.conflicts.isEmpty) + XCTAssertFalse(next.changedAnything) + } + + func testResolveWithLocalCopyUploadsIt() async throws { + seedServer() + _ = await runner.run(config, force: false) + FakeDav.put("/Docs/a.txt", "server edit") + try editLocally("Docs/a.txt", "phone edit") + let conflict = await runner.run(config, force: false).conflicts[0] + + let ok = await runner.resolveConflict(conflict, resolution: "local", creds: creds) + XCTAssertTrue(ok) + XCTAssertEqual(FakeDav.text("/Docs/a.txt"), "phone edit") + let next = await runner.run(config, force: false) + XCTAssertTrue(next.conflicts.isEmpty && !next.changedAnything) + } + + func testAnUnknownResolutionDoesNothing() async throws { + seedServer() + _ = await runner.run(config, force: false) + FakeDav.put("/Docs/a.txt", "server edit") + try editLocally("Docs/a.txt", "phone edit") + let conflict = await runner.run(config, force: false).conflicts[0] + + let ok = await runner.resolveConflict(conflict, resolution: "bogus", creds: creds) + XCTAssertFalse(ok) + XCTAssertEqual(local("Docs/a.txt"), "phone edit") + XCTAssertEqual(FakeDav.text("/Docs/a.txt"), "server edit") + } + + // MARK: - Deletes (the dangerous part) + + func testFileDeletedOnTheServerIsRemovedLocally() async { + seedServer() + _ = await runner.run(config, force: false) + FakeDav.remove("/Docs/a.txt") + + let summary = await runner.run(config, force: false) + XCTAssertEqual(summary.deleted, 1) + XCTAssertFalse(exists("Docs/a.txt")) + XCTAssertTrue(exists("Docs/sub/b.txt"), "only the deleted file goes") + } + + func testFolderDeletedOnTheServerRemovesItsFilesAndTheEmptyDirectory() async { + seedServer() + _ = await runner.run(config, force: false) + FakeDav.remove("/Docs/sub") + + let summary = await runner.run(config, force: false) + XCTAssertEqual(summary.deleted, 1) + XCTAssertFalse(exists("Docs/sub/b.txt")) + XCTAssertFalse(exists("Docs/sub"), "the now-empty directory is pruned") + XCTAssertTrue(exists("Docs/a.txt")) + } + + func testAnUnreachableServerNeverDeletesAnything() async { + seedServer() + _ = await runner.run(config, force: false) + let before = state() + + FakeDav.failAll = true + let summary = await runner.run(config, force: true) + XCTAssertFalse(summary.changedAnything, "a 503 is not an empty folder") + XCTAssertEqual(local("Docs/a.txt"), "alpha") + XCTAssertEqual(local("Docs/sub/b.txt"), "bravo") + XCTAssertEqual(state(), before) + XCTAssertTrue(store.loadMissingRoots(accountId: "acct").isEmpty, "unreachable is not 'gone'") + + FakeDav.failAll = false + let recovered = await runner.run(config, force: true) + XCTAssertFalse(recovered.changedAnything) + XCTAssertEqual(local("Docs/a.txt"), "alpha") + } + + func testAPartiallyFailingWalkDoesNotLookLikeAMassDeletion() async { + seedServer() + _ = await runner.run(config, force: false) + // Only the subfolder listing breaks: a half-built manifest must not make + // sub/b.txt look deleted. + FakeDav.nodes["/Docs/sub"]?.etag = "changed" + FakeDav.nodes["/Docs"]?.etag = "changed" + FakeDav.failPropfindPath = "/Docs/sub" + + let summary = await runner.run(config, force: true) + XCTAssertEqual(summary.deleted, 0) + XCTAssertEqual(local("Docs/sub/b.txt"), "bravo") + XCTAssertEqual(local("Docs/a.txt"), "alpha") + } + + func testAPathDeletedOnTheServerIsFlaggedAndItsMirrorRemoved() async { + seedServer() + _ = await runner.run(config, force: false) + FakeDav.remove("/Docs") + + let summary = await runner.run(config, force: false) + XCTAssertEqual(summary.deleted, 2) + XCTAssertEqual(store.loadMissingRoots(accountId: "acct"), ["/Docs"]) + XCTAssertFalse(exists("Docs/a.txt")) + } + + // MARK: - Single files, scoping, housekeeping + + func testASyncedSingleFilePath() async { + FakeDav.put("/Notes/todo.txt", "buy milk") + var single = config + single.folders = ["/Notes/todo.txt"] + + let summary = await runner.run(single, force: false) + XCTAssertEqual(summary.downloaded, 1) + XCTAssertEqual(local("Notes/todo.txt"), "buy milk") + FakeDav.put("/Notes/todo.txt", "buy milk and eggs") + _ = await runner.run(single, force: false) + XCTAssertEqual(local("Notes/todo.txt"), "buy milk and eggs") + } + + func testSyncOnlyTouchesTheConfiguredPaths() async { + seedServer() + FakeDav.put("/Other/x.txt", "not synced") + _ = await runner.run(config, force: false) + XCTAssertFalse(exists("Other/x.txt")) + } + + func testNothingConfiguredDoesNothing() async { + seedServer() + var empty = config + empty.folders = [] + let summary = await runner.run(empty, force: true) + XCTAssertEqual(summary, SyncRunSummary()) + XCTAssertTrue(FakeDav.log.isEmpty) + } + + func testRemoveLocalSyncForgetsThePathSoItComesBackOnReAdd() async { + seedServer() + _ = await runner.run(config, force: false) + store.removeLocalSync(accountId: "acct", path: "/Docs/sub") + + XCTAssertFalse(exists("Docs/sub/b.txt")) + XCTAssertTrue(exists("Docs/a.txt")) + XCTAssertEqual(state().count, 1) + XCTAssertNil(store.loadRootMarkers(accountId: "acct")["/Docs"], "an ancestor's marker would skip the re-download") + + let summary = await runner.run(config, force: false) + XCTAssertEqual(summary.downloaded, 1) + XCTAssertEqual(local("Docs/sub/b.txt"), "bravo") + } + + func testStatusMarksFilesAsSyncingDuringTheRun() async { + seedServer() + var seen = Set() + bus.onChange = { seen.formUnion($0.syncingFileIds) } + _ = await runner.run(config, force: false) + XCTAssertEqual(seen.count, 2, "each transferring file was announced") + XCTAssertTrue(bus.snapshot().syncingFileIds.isEmpty) + } + + func testStaleMarkersForRemovedPathsAreDropped() async { + seedServer() + _ = await runner.run(config, force: false) + var other = config + other.folders = ["/Other"] + FakeDav.mkdir("/Other") + _ = await runner.run(other, force: false) + XCTAssertNil(store.loadRootMarkers(accountId: "acct")["/Docs"]) + } + + // MARK: - Parsing + + func testParserReadsEtagFileIdSizeAndDates() { + let xml = Data( + """ + + /nextcloud/remote.php/dav/files/alice/Docs/ + "abc123" + 7 + /nextcloud/remote.php/dav/files/alice/Docs/Tax%20Forms.pdf + Tue, 14 Nov 2023 22:13:20 GMT + 2048 + "e1"9 + + """.utf8) + let entries = try! DavSyncParser.entries(from: xml, username: "alice", requestedPath: "/Docs", skipSelf: true) + XCTAssertEqual(entries.count, 1, "the folder itself is skipped") + XCTAssertEqual(entries[0].path, "/Docs/Tax Forms.pdf") + XCTAssertEqual(entries[0].etag, "e1", "quotes are stripped") + XCTAssertEqual(entries[0].fileId, "9") + XCTAssertEqual(entries[0].size, 2048) + XCTAssertEqual(entries[0].lastModified, 1_700_000_000_000) + XCTAssertFalse(entries[0].isFolder) + + let all = try! DavSyncParser.entries(from: xml, username: "alice", requestedPath: "/Docs", skipSelf: false) + XCTAssertEqual(all.map(\.path), ["/Docs", "/Docs/Tax Forms.pdf"]) + XCTAssertTrue(all[0].isFolder) + } + + func testParserRefusesAnythingThatIsNotACompleteAnswer() { + func entries(_ xml: String) throws -> [SyncRemoteEntry] { + try DavSyncParser.entries(from: Data(xml.utf8), username: "alice", requestedPath: "/Docs", skipSelf: false) + } + XCTAssertThrowsError(try entries("nope"), "not XML") + XCTAssertThrowsError(try entries("/remote.php"), "truncated") + XCTAssertThrowsError( + try entries( + "/remote.php/dav/files/alice/Docs/a.txt" + + "/remote.php/dav/files/alice/Docs/b"), + "one complete response, then cut off: a partial manifest") + XCTAssertThrowsError(try entries("Maintenance"), "well-formed but not a multistatus") + XCTAssertThrowsError(try entries(""), "a 207 always has the resource itself") + } + + func testParserRefusesHrefsOutsideTheAccountRootOrClimbingOut() { + func parse(_ href: String) throws -> [SyncRemoteEntry] { + let xml = "\(href)" + return try DavSyncParser.entries(from: Data(xml.utf8), username: "alice", requestedPath: "/", skipSelf: false) + } + XCTAssertNoThrow(try parse("/remote.php/dav/files/alice/Docs/a.txt")) + XCTAssertThrowsError(try parse("/remote.php/dav/files/alice/../bob/secret.txt"), "dot-dot component") + XCTAssertThrowsError(try parse("/remote.php/dav/files/alice/Docs/%2E%2E/%2E%2E/x"), "encoded dot-dot") + XCTAssertThrowsError(try parse("/somewhere/else/a.txt"), "not under the files root") + XCTAssertThrowsError(try parse("/remote.php/dav/files/alicia/a.txt"), "another user's root that merely starts the same") + } + + // MARK: - Review findings: each of these used to lose or misplace data + + func testATruncatedAnswerIsNotAManifest() async { + seedServer() + _ = await runner.run(config, force: false) + FakeDav.truncatePropfind = true + + let summary = await runner.run(config, force: true) + XCTAssertFalse(summary.changedAnything, "half a listing must not look like deletions") + XCTAssertEqual(local("Docs/a.txt"), "alpha") + XCTAssertEqual(local("Docs/sub/b.txt"), "bravo") + XCTAssertTrue(store.loadMissingRoots(accountId: "acct").isEmpty, "nor like the folder being gone") + } + + func testDeletedOnTheServerButEditedHereIsKept() async throws { + seedServer() + _ = await runner.run(config, force: false) + try editLocally("Docs/a.txt", "my only copy of this edit") + FakeDav.remove("/Docs/a.txt") + + let summary = await runner.run(config, force: false) + XCTAssertEqual(summary.deleted, 0) + XCTAssertEqual(local("Docs/a.txt"), "my only copy of this edit") + XCTAssertNil(state().first { $0.value.relPath == "Docs/a.txt" }, "no longer tracked") + + let again = await runner.run(config, force: false) + XCTAssertEqual(local("Docs/a.txt"), "my only copy of this edit", "and not touched later either") + XCTAssertFalse(again.changedAnything) + } + + func testSyncingTheWholeAccountNoticesServerDeletions() async { + FakeDav.put("/a.txt", "top") + FakeDav.put("/sub/b.txt", "nested") + var everything = config + everything.folders = ["/"] + + _ = await runner.run(everything, force: false) + XCTAssertEqual(local("a.txt"), "top") + FakeDav.log = [] + _ = await runner.run(everything, force: false) + XCTAssertEqual(depth1Requests(), 0, "the root shortcut works for '/' too") + + FakeDav.remove("/a.txt") + let summary = await runner.run(everything, force: false) + XCTAssertEqual(summary.deleted, 1) + XCTAssertFalse(exists("a.txt")) + XCTAssertTrue(exists("sub/b.txt")) + } + + func testAPathNeverClaimsASiblingWithTheSamePrefix() { + let entry = { (rel: String) in SyncFileState(relPath: rel, etag: "e", lastModified: 0, size: 1, localMTime: 1) } + let state = ["1": entry("Docs/a.txt"), "2": entry("Docs2/b.txt"), "3": entry("Documents/c.txt"), "4": entry("Docs")] + XCTAssertEqual(SyncDiff.priorFileIds(state: state, path: "/Docs"), ["1", "4"]) + XCTAssertEqual(SyncDiff.priorFileIds(state: state, path: "/Docs/"), ["1", "4"]) + XCTAssertEqual(SyncDiff.priorFileIds(state: state, path: "/"), ["1", "2", "3", "4"], "the root covers everything") + } + + func testTwoRemoteNamesForOneLocalFileAreNeitherDownloaded() async { + FakeDav.put("/Docs/a.txt", "lower") + FakeDav.put("/Docs/A.txt", "upper") + FakeDav.put("/Docs/fine.txt", "ok") + + let summary = await runner.run(config, force: false) + XCTAssertEqual(summary.downloaded, 1, "only the file with no collision") + XCTAssertEqual(local("Docs/fine.txt"), "ok") + XCTAssertNil(local("Docs/a.txt"), "neither of the colliding pair overwrote the other") + XCTAssertNil(store.loadRootMarkers(accountId: "acct")["/Docs"], "and the path isn't called clean") + let unicode = SyncDiff.collidingRelPaths([ + SyncRemoteEntry(path: "/x/\u{C4}.txt", fileId: "1", etag: "", lastModified: 0, size: 0, isFolder: false), + SyncRemoteEntry(path: "/x/A\u{308}.txt", fileId: "2", etag: "", lastModified: 0, size: 0, isFolder: false), + ]) + XCTAssertTrue(unicode.contains("x/\u{C4}.txt") && unicode.contains("x/A\u{308}.txt"), + "canonically equivalent Unicode spellings collide too") + XCTAssertTrue( + SyncDiff.collidingRelPaths([ + SyncRemoteEntry(path: "/x/same.txt", fileId: "1", etag: "", lastModified: 0, size: 0, isFolder: false) + ]).isEmpty, "one spelling is never a collision") + } + + func testServerPathsCanNeverLeaveTheMirror() { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("noo-root-\(UUID().uuidString)") + XCTAssertNotNil(LocalFS.resolve("Docs/a.txt", in: root)) + XCTAssertNil(LocalFS.resolve("../outside.txt", in: root)) + XCTAssertNil(LocalFS.resolve("Docs/../../outside.txt", in: root)) + XCTAssertNil(LocalFS.resolve("./x", in: root)) + XCTAssertNil(LocalFS.resolve("", in: root)) + XCTAssertNil(LocalFS.resolve("/", in: root)) + } + + func testAnUnreadableStateMakesTheRunDoNothing() async throws { + seedServer() + _ = await runner.run(config, force: false) + try editLocally("Docs/a.txt", "unsynced local edit") + let stateFile = store.base.appendingPathComponent("sync-state/acct/state.json") + try Data("{ this is not json".utf8).write(to: stateFile) + FakeDav.log = [] + + let summary = await runner.run(config, force: true) + XCTAssertEqual(summary, SyncRunSummary()) + XCTAssertEqual(local("Docs/a.txt"), "unsynced local edit", "an untracked-looking file must not be overwritten") + XCTAssertTrue(FakeDav.log.isEmpty, "it didn't even talk to the server") + XCTAssertThrowsError(try store.loadState(accountId: "acct")) + XCTAssertEqual(try Data(contentsOf: stateFile), Data("{ this is not json".utf8), "the corrupt file isn't overwritten") + } + + func testNoStateYetIsJustAFirstSyncNotAnError() throws { + XCTAssertEqual(try store.loadState(accountId: "brand-new"), [:]) + } + + func testADownloadNeverReplacesAFolderOrDestroysTheOldCopyOnFailure() async throws { + FakeDav.put("/Docs/a.txt", "server") + let client = DavSyncClient(session: fakeSession) + let folder = mirror.appendingPathComponent("Docs/a.txt") + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + try "inside".write(to: folder.appendingPathComponent("keep.txt"), atomically: true, encoding: .utf8) + + let ok = try await client.download(creds, remotePath: "/Docs/a.txt", to: folder) + XCTAssertFalse(ok, "a folder is not replaced by a file") + XCTAssertEqual(local("Docs/a.txt/keep.txt"), "inside") + + let file = mirror.appendingPathComponent("Docs/b.txt") + try "old".write(to: file, atomically: true, encoding: .utf8) + FakeDav.put("/Docs/b.txt", "new") + let replaced = try await client.download(creds, remotePath: "/Docs/b.txt", to: file) + XCTAssertTrue(replaced) + XCTAssertEqual(local("Docs/b.txt"), "new") + FakeDav.failAll = true + let failed = try await client.download(creds, remotePath: "/Docs/b.txt", to: file) + XCTAssertFalse(failed) + XCTAssertEqual(local("Docs/b.txt"), "new", "a failed download leaves what was there") + } + + func testRemovingTheWholeRootForgetsEveryFile() async { + seedServer() + _ = await runner.run(config, force: false) + store.removeLocalSync(accountId: "acct", path: "/") + XCTAssertTrue(state().isEmpty, "'/' covers everything recorded") + XCTAssertFalse(exists("Docs/a.txt")) + } + + func testPruningNeverRemovesAFolderThatHoldsSomething() throws { + let root = mirror + try FileManager.default.createDirectory( + at: root.appendingPathComponent("Docs/stale"), withIntermediateDirectories: true) + try "mine".write(to: root.appendingPathComponent("Docs/stale/note.txt"), atomically: true, encoding: .utf8) + try FileManager.default.createDirectory( + at: root.appendingPathComponent("Docs/empty-stale"), withIntermediateDirectories: true) + + SyncDiff.pruneRemovedFolders(syncRoot: root, rootRel: "Docs", remoteFolders: ["Docs"]) + XCTAssertEqual(local("Docs/stale/note.txt"), "mine") + XCTAssertFalse(exists("Docs/empty-stale"), "an empty folder the server no longer has goes") + } + + // MARK: - Status, coordinator, config store, mutex + + func testConflictsAreKeptPerAccount() { + let a = SyncConflict(accountId: "A", fileId: "7", remotePath: "/x", relPath: "x", name: "x") + let b = SyncConflict(accountId: "B", fileId: "7", remotePath: "/y", relPath: "y", name: "y") + bus.addConflicts(accountId: "A", [a]) + bus.addConflicts(accountId: "B", [b]) + XCTAssertEqual(bus.snapshot().conflicts.count, 2, "same file id, different accounts: both kept") + + let coordinator = SyncCoordinator( + store: store, bus: bus, configs: SyncConfigStore(service: "test-\(UUID().uuidString)"), client: DavSyncClient()) + let forB = coordinator.statusMap(bus.snapshot(), accountIdOverride: "B")["conflicts"] as? [[String: String]] + XCTAssertEqual(forB?.map { $0["relPath"] }, ["y"], "B never sees A's conflicts") + + bus.removeConflict(accountId: "A", fileId: "7") + XCTAssertEqual(bus.snapshot().conflicts, [b], "removing A's leaves B's") + } + + func testSignOutForgetsCredentialsButTurningBackgroundOffKeepsThem() { + let service = "test-\(UUID().uuidString)" + let configs = SyncConfigStore(service: service) + addTeardownBlock { configs.removeAll() } + let coordinator = SyncCoordinator(store: store, bus: bus, configs: configs, client: DavSyncClient()) + var withBackground = config + withBackground.intervalMinutes = 30 + + coordinator.reschedule(withBackground) + XCTAssertEqual(configs.config(for: "acct")?.intervalMinutes, 30) + + coordinator.cancel(accountId: "acct", forget: false) + XCTAssertNotNil(configs.config(for: "acct"), "conflict actions and Sync now still need the credentials") + XCTAssertNil(configs.config(for: "acct")?.intervalMinutes, "but nothing is scheduled") + + coordinator.cancel(accountId: "acct", forget: true) + XCTAssertNil(configs.config(for: "acct"), "signing out forgets them") + } + + func testConfigStoreHoldsSeveralAccountsAndUpdatesInPlace() { + let configs = SyncConfigStore(service: "test-\(UUID().uuidString)") + addTeardownBlock { configs.removeAll() } + let second = SyncAccountConfig( + accountId: "other", serverUrl: "https://o", username: "bob", authHeader: "Basic y", + folders: ["/x"], wifiOnly: true, intervalMinutes: 60, notify: true) + + XCTAssertTrue(configs.upsert(config)) + XCTAssertTrue(configs.upsert(second)) + XCTAssertEqual(configs.all().map(\.accountId), ["acct", "other"]) + + var changed = config + changed.folders = ["/Docs", "/More"] + XCTAssertTrue(configs.upsert(changed)) + XCTAssertEqual(configs.config(for: "acct")?.folders, ["/Docs", "/More"]) + XCTAssertEqual(configs.config(for: "other"), second, "updating one leaves the others") + + XCTAssertTrue(configs.remove(accountId: "acct")) + XCTAssertEqual(configs.all().map(\.accountId), ["other"]) + XCTAssertTrue(configs.remove(accountId: "never-existed")) + } + + func testAQueuedWaiterThatIsCancelledLeavesTheQueueAtOnce() async throws { + let mutex = AsyncMutex() + let holderStarted = expectation(description: "holder has the lock") + let holder = Task { + try await mutex.withLock { + holderStarted.fulfill() + try await Task.sleep(nanoseconds: 1_500_000_000) + } + } + await fulfillment(of: [holderStarted], timeout: 5) + + let waiter = Task { try await mutex.withLock { "ran" } } + try await Task.sleep(nanoseconds: 100_000_000) + let cancelledAt = Date() + waiter.cancel() + do { + _ = try await waiter.value + XCTFail("a cancelled waiter must not run") + } catch { + XCTAssertTrue(error is CancellationError) + XCTAssertLessThan(Date().timeIntervalSince(cancelledAt), 0.8, "it didn't wait for the 1.5s holder") + } + + try await holder.value + let next = try await mutex.withLock { "next" } + XCTAssertEqual(next, "next", "the lock is free for the next one") + } + + private final class Counter: @unchecked Sendable { + var running = 0 + var maxRunning = 0 + var finished = 0 + } + + func testAMutexNeverRunsTwoBodiesAtOnce() async throws { + let mutex = AsyncMutex() + let counter = Counter() + await withTaskGroup(of: Void.self) { group in + for _ in 0..<6 { + group.addTask { + _ = try? await mutex.withLock { + counter.running += 1 + counter.maxRunning = max(counter.maxRunning, counter.running) + try? await Task.sleep(nanoseconds: 10_000_000) + counter.running -= 1 + counter.finished += 1 + } + } + } + } + XCTAssertEqual(counter.finished, 6) + XCTAssertEqual(counter.maxRunning, 1) + } + + func testOnceGateCompletesOnlyOnce() { + var calls: [Bool] = [] + let gate = OnceGate { calls.append($0) } + gate.finish(false) + gate.finish(true) + XCTAssertEqual(calls, [false]) + } +} diff --git a/lib/services/sync_service.dart b/lib/services/sync_service.dart index 7c15213..8c334b6 100644 --- a/lib/services/sync_service.dart +++ b/lib/services/sync_service.dart @@ -105,12 +105,17 @@ class SyncService { ) : _SyncConfig.fromPrefs(prefs, store.accountPrefKey, account.id); final password = await store.readPassword(account.id); - if (password == null || - config.paths.isEmpty || - config.intervalMinutes == null) { + if (password == null || config.paths.isEmpty) { await cancelAccount(account.id); continue; } + if (config.intervalMinutes == null) { + // Background sync is off, but this account still has paths to sync + // by hand: keep its credentials (native uses them for "Sync now" + // and conflict notification actions). + await cancelAccount(account.id, forget: false); + continue; + } await invokeIfAvailable(_channel, 'reschedule', { 'accountId': account.id, 'serverUrl': account.serverUrl, @@ -132,8 +137,17 @@ class SyncService { /// Stops [accountId]'s periodic job (the account was removed, or has /// nothing left to sync). - static Future cancelAccount(String accountId) async { - await invokeIfAvailable(_channel, 'cancel', {'accountId': accountId}); + /// + /// [forget] (the default) is for an account that was signed out or removed: + /// native also drops its stored credentials. Pass false when only the + /// background job is being turned off - the account's paths are still + /// synced by hand ("Sync now"), and a conflict notification's actions + /// still need its credentials. + static Future cancelAccount(String accountId, {bool forget = true}) async { + await invokeIfAvailable(_channel, 'cancel', { + 'accountId': accountId, + 'forget': forget, + }); } /// Runs a one-off sync pass immediately, independent of the periodic