diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+SyncEngine.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+SyncEngine.swift index 283dd238a599d..cac351845216a 100644 --- a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+SyncEngine.swift +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+SyncEngine.swift @@ -225,6 +225,8 @@ extension Enumerator { let updatedMetadatas = isNew ? [] : [metadata] let newMetadatas = isNew ? [metadata] : [] + metadata.lockToken = existing?.lockToken + metadata.visitedDirectory = existing?.visitedDirectory == true metadata.downloaded = existing?.downloaded == true metadata.keepDownloaded = existing?.keepDownloaded == true dbManager.addItemMetadata(metadata) diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift index e4fc1c9567061..83c1cba398685 100644 --- a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/Enumerator.swift @@ -483,30 +483,25 @@ public final class Enumerator: NSObject, NSFileProviderEnumerator, Sendable { examinedChildFilesAndDeletedItems.formUnion(metadatas[1...].filter { !$0.directory }.map(\.ocId)) } - // If the target is not in the updated metadatas then neither it, nor any of its kids have changed. So skip examining all of them. - if !allUpdatedMetadatas.contains(where: { $0.ocId == target.ocId }) { - logger.debug("Target has not changed. Skipping children.", [.url: itemRemoteUrl]) - let materialisedChildren = materializedItems.filter { $0.serverUrl.hasPrefix(itemRemoteUrl) }.map(\.ocId) - examinedChildFilesAndDeletedItems.formUnion(materialisedChildren) - } - - // OPTIMIZATION: For any child directories returned in this enumeration, if they haven't changed (etag matches database), mark them as examined so we don't enumerate them separately later. + // Only skip unchanged child directories with no materialized descendants. + // Lock changes don't propagate etags, so dirs with visible children must be enumerated. if metadatas.count > 1 { let childDirectories = metadatas[1...].filter(\.directory) for childDir in childDirectories { - // Check if this directory is in our materialized items list - if let localItem = materializedItems.first(where: { $0.ocId == childDir.ocId }), localItem.etag == childDir.etag { - // Directory hasn't changed, mark as examined to skip separate enumeration. - logger.debug("Child directory etag unchanged, marking as examined.", [.name: childDir.fileName, .eTag: childDir.etag]) - examinedChildFilesAndDeletedItems.insert(childDir.ocId) + guard let localItem = materializedItems.first( + where: { $0.ocId == childDir.ocId } + ), localItem.isInSameDatabaseStoreableRemoteState(childDir) else { + continue + } - // Also mark any materialized children of this directory as examined. - let grandChildren = materializedItems.filter { - $0.serverUrl.hasPrefix(localItem.remotePath()) - } + let hasMaterializedDescendants = materializedItems.contains { + $0.ocId != localItem.ocId + && $0.serverUrl.hasPrefix(localItem.remotePath()) + } - examinedChildFilesAndDeletedItems.formUnion(grandChildren.map(\.ocId)) + if !hasMaterializedDescendants { + examinedChildFilesAndDeletedItems.insert(childDir.ocId) } } } diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/MockRemoteItem.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/MockRemoteItem.swift index a50328a949fc5..194bd951f91e3 100644 --- a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/MockRemoteItem.swift +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/Interface/MockRemoteItem.swift @@ -126,8 +126,10 @@ public class MockRemoteItem: Equatable { ? NextcloudKit.shared.nkCommonInstance.rootFileName : trashbinOriginalLocation?.split(separator: "/").last?.toString() ?? name file.size = size - file.date = creationDate + file.creationDate = creationDate + file.date = modificationDate file.directory = isRoot ? false : directory + file.permissions = "RGDNVW" file.etag = versionIdentifier file.ocId = identifier file.fileId = identifier.replacingOccurrences(of: trashedItemIdSuffix, with: "") diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift index eede92c686c5b..67de7873ab85f 100644 --- a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift @@ -2022,4 +2022,87 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase { ) } } + + func testLockChangeDetectedByRemoteStateComparison() { + var local = SendableItemMetadata( + ocId: "file1", + account: Self.account.ncKitAccount, + classFile: "", + contentType: "", + creationDate: Date(), + directory: false, + e2eEncrypted: false, + etag: "v1", + fileId: "file1", + fileName: "file.txt", + fileNameView: "file.txt", + ownerId: "", + ownerDisplayName: "", + path: "", + serverUrl: Self.account.davFilesUrl, + size: 0, + urlBase: Self.account.serverUrl, + user: Self.account.username, + userId: Self.account.id + ) + var remote = local + + XCTAssertTrue(local.isInSameDatabaseStoreableRemoteState(remote)) + + remote.lock = true + XCTAssertFalse( + local.isInSameDatabaseStoreableRemoteState(remote), + "A lock state change must be detected as a remote state difference" + ) + + local.lock = true + XCTAssertTrue(local.isInSameDatabaseStoreableRemoteState(remote)) + } + + func testLockTokenPreservedDuringTargetDepthRead() async throws { + let db = Self.dbManager.ncDatabase() + debugPrint(db) + + let remoteFile = MockRemoteItem( + identifier: "lockTokenTestFile", + versionIdentifier: "V1", + name: "lockTokenTestFile.txt", + remotePath: Self.account.davFilesUrl + "/lockTokenTestFile.txt", + locked: true, + lockOwner: Self.account.username, + lockTimeOut: Date.now.advanced(by: 1_000_000), + account: Self.account.ncKitAccount, + username: Self.account.username, + userId: Self.account.id, + serverUrl: Self.account.serverUrl + ) + + rootItem.children = [remoteFile] + remoteFile.parent = rootItem + + let remoteInterface = MockRemoteInterface(account: Self.account, rootItem: rootItem) + + var fileMetadata = remoteFile.toItemMetadata(account: Self.account) + fileMetadata.lockToken = "local-lock-token-123" + fileMetadata.downloaded = true + Self.dbManager.addItemMetadata(fileMetadata) + + let (_, _, _, _, _, readError) = await Enumerator.readServerUrl( + Self.account.davFilesUrl + "/lockTokenTestFile.txt", + account: Self.account, + remoteInterface: remoteInterface, + dbManager: Self.dbManager, + depth: .target, + log: FileProviderLogMock() + ) + + XCTAssertNil(readError) + + let postRead = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: "lockTokenTestFile")) + XCTAssertEqual( + postRead.lockToken, + "local-lock-token-123", + "lockToken must be preserved across target-depth reads" + ) + } }