Refine iOS navigation, media controls, and background sync
Build APK / build (push) Successful in 5m50s

This commit is contained in:
Ayushya Amitabh
2026-10-06 23:18:50 -04:00
parent c5b46fd493
commit ce2273c9a6
39 changed files with 2309 additions and 647 deletions
@@ -320,7 +320,13 @@ final class SyncCoordinator {
/// 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) }
task.expirationHandler = startBackgroundSlot { task.setTaskCompleted(success: $0) }
}
/// Runs a background slot and returns its expiration handler.
@discardableResult
func startBackgroundSlot(completion: @escaping (Bool) -> Void) -> () -> Void {
let gate = OnceGate(completion)
let accountIds = backgroundConfigs().map(\.accountId)
let work = Task { [weak self] in
guard let self else { return gate.finish(false) }
@@ -332,7 +338,7 @@ final class SyncCoordinator {
}
// 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
return { [weak self] in
work.cancel()
for id in accountIds { self?.cancelRun(accountId: id, invalidateQueued: false) }
gate.finish(false)
+1 -10
View File
@@ -89,16 +89,7 @@ enum SyncDiff {
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))
}
actions.append(.delete(relPath: prior.relPath, fileId: fileId))
}
return actions
}
@@ -38,10 +38,6 @@ enum SyncAction: Equatable {
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.
@@ -144,7 +144,7 @@ final class SyncStatusBus {
/// 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.
/// - Server deletions remove the local copy, including local edits, like Android.
/// - 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 {
@@ -301,11 +301,6 @@ struct SyncRunner {
}
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(
+34 -4
View File
@@ -583,19 +583,19 @@ final class SyncEngineTests: XCTestCase {
XCTAssertTrue(store.loadMissingRoots(accountId: "acct").isEmpty, "nor like the folder being gone")
}
func testDeletedOnTheServerButEditedHereIsKept() async throws {
func testDeletedOnTheServerButEditedHereIsDeletedLikeAndroid() 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")
XCTAssertEqual(summary.deleted, 1)
XCTAssertFalse(exists("Docs/a.txt"))
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(exists("Docs/a.txt"), "not restored on a later sync")
XCTAssertFalse(again.changedAnything)
}
@@ -740,6 +740,36 @@ final class SyncEngineTests: XCTestCase {
XCTAssertEqual(bus.snapshot().conflicts, [b], "removing A's leaves B's")
}
func testBackgroundSlotOnlyRunsAccountsWithPeriodicSyncEnabled() async {
seedServer()
let configs = SyncConfigStore(service: "test-\(UUID().uuidString)")
addTeardownBlock { configs.removeAll() }
let coordinator = SyncCoordinator(store: store, bus: bus, configs: configs, client: DavSyncClient(session: fakeSession))
var scheduled = config
scheduled.intervalMinutes = nil
scheduled.wifiOnly = false
configs.upsert(scheduled)
let skipped = expectation(description: "manual-only account skipped")
coordinator.startBackgroundSlot { success in
XCTAssertTrue(success)
skipped.fulfill()
}
await fulfillment(of: [skipped], timeout: 10)
XCTAssertFalse(exists("Docs/a.txt"))
scheduled.intervalMinutes = 30
configs.upsert(scheduled)
let completed = expectation(description: "periodic account synced")
coordinator.startBackgroundSlot { success in
XCTAssertTrue(success)
completed.fulfill()
}
await fulfillment(of: [completed], timeout: 15)
XCTAssertEqual(local("Docs/a.txt"), "alpha")
XCTAssertEqual(local("Docs/sub/b.txt"), "bravo")
}
func testSignOutForgetsCredentialsButTurningBackgroundOffKeepsThem() {
let service = "test-\(UUID().uuidString)"
let configs = SyncConfigStore(service: service)