Skip to content

Commit 84e9b91

Browse files
committed
fix(#9923): Apply eager content policy also to first item in nested hierarchies.
Signed-off-by: Iva Horn <iva.horn@nextcloud.com>
1 parent 9381842 commit 84e9b91

4 files changed

Lines changed: 296 additions & 7 deletions

File tree

shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager.swift

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,52 @@ public final class FilesDatabaseManager: Sendable {
447447
}
448448
}
449449

450+
///
451+
/// Add or replace `metadata` while carrying over local-only fields the
452+
/// server payload cannot know about: ``keepDownloaded``, ``downloaded``,
453+
/// ``visitedDirectory``, and ``lockToken``.
454+
///
455+
/// Mirrors the preservation set applied by
456+
/// ``processItemMetadatasToUpdate`` for non-paginated reads. Use this from
457+
/// any code path that ingests fresh PROPFIND results (e.g. paginated
458+
/// enumeration); plain ``addItemMetadata(_:)`` would otherwise overwrite
459+
/// these fields back to their defaults via Realm's `update: .all`,
460+
/// silently undoing user-visible state such as "Always keep downloaded"
461+
/// (#9923).
462+
///
463+
/// Returns the merged metadata that was persisted. Callers that report
464+
/// items back to the file-provider framework MUST forward the returned
465+
/// value rather than the input — otherwise the framework receives the
466+
/// pre-merge defaults and renders the item as if the local-only state
467+
/// (e.g. pinned-via-keep-downloaded) had been cleared.
468+
///
469+
/// - Parameters:
470+
/// - metadata: The freshly-built metadata to persist.
471+
/// - preserveVisitedDirectory: When `false`, do not carry over
472+
/// ``visitedDirectory`` from the existing row. Callers that have just
473+
/// visited the directory in the current request should pass `false`
474+
/// and pre-set `metadata.visitedDirectory = true`, so the visit is
475+
/// recorded rather than overwritten by a stale `false` from the DB.
476+
///
477+
@discardableResult
478+
public func addItemMetadataPreservingLocalState(_ metadata: SendableItemMetadata, preserveVisitedDirectory: Bool = true) -> SendableItemMetadata {
479+
var toWrite = metadata
480+
481+
if let existing = itemMetadatas.where({ $0.ocId == metadata.ocId }).first {
482+
toWrite.downloaded = existing.downloaded
483+
toWrite.keepDownloaded = existing.keepDownloaded
484+
485+
if preserveVisitedDirectory {
486+
toWrite.visitedDirectory = existing.visitedDirectory
487+
}
488+
489+
toWrite.lockToken = existing.lockToken
490+
}
491+
492+
addItemMetadata(toWrite)
493+
return toWrite
494+
}
495+
450496
///
451497
/// Mark an item as deleted.
452498
///

shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Enumeration/Enumerator+SyncEngine.swift

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,35 @@ extension Enumerator {
1515
if pageIndex == 0 {
1616
guard let firstFile = files.first else { return (nil, .invalidResponseError) }
1717
var metadata = firstFile.toItemMetadata()
18+
1819
if metadata.directory {
20+
// Mark the directory as visited in this request, then persist
21+
// while preserving the rest of the local-only state. Skip
22+
// preservation of `visitedDirectory` so the just-set `true`
23+
// is not overwritten by the existing row's value.
1924
metadata.visitedDirectory = true
20-
if let existingMetadata = dbManager.itemMetadata(ocId: metadata.ocId) {
21-
metadata.downloaded = existingMetadata.downloaded
22-
metadata.keepDownloaded = existingMetadata.keepDownloaded
23-
}
25+
dbManager.addItemMetadataPreservingLocalState(metadata, preserveVisitedDirectory: false)
26+
} else {
27+
dbManager.addItemMetadataPreservingLocalState(metadata)
2428
}
25-
dbManager.addItemMetadata(metadata)
2629
}
27-
let metadatas = files[startIndex...].map { $0.toItemMetadata() }
28-
metadatas.forEach { dbManager.addItemMetadata($0) }
30+
31+
// Persist children — including those on follow-up pages — while
32+
// carrying over any local-only state previously set on existing rows
33+
// (e.g. `keepDownloaded` from a recursive "Always keep downloaded"
34+
// enable). A plain `addItemMetadata` would clobber those flags via
35+
// Realm's `update: .all`, which is the root cause of #9923.
36+
//
37+
// Use the merged metadata returned by the helper for both the DB
38+
// write and the value reported back to the framework. Returning the
39+
// pre-merge fresh-from-server metadata would tell the framework
40+
// `keepDownloaded == false` for items that are pinned in the
41+
// database, leaving the OS view (`isKeepDownloaded`, `contentPolicy`)
42+
// out of sync with the local truth.
43+
let metadatas = files[startIndex...].map { file -> SendableItemMetadata in
44+
dbManager.addItemMetadataPreservingLocalState(file.toItemMetadata())
45+
}
46+
2947
return (metadatas, nil)
3048
}
3149

shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/EnumeratorTests.swift

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,145 @@ final class EnumeratorTests: NextcloudFileProviderKitTestCase {
472472
)
473473
}
474474

475+
///
476+
/// Children persisted by a page-0 paginated read must keep their existing
477+
/// ``keepDownloaded`` flag. Before the fix for #9923 the bulk write at the
478+
/// end of ``Enumerator.handlePagedReadResults`` used ``addItemMetadata``
479+
/// which replaces the entire row, silently clearing flags set by a prior
480+
/// "Always keep downloaded" enable.
481+
///
482+
/// The metadatas returned to the enumeration observer must also reflect
483+
/// the preserved state: the framework builds `Item.contentPolicy` from
484+
/// `metadata.keepDownloaded` on whatever metadata we hand it, so reporting
485+
/// the pre-merge fresh-from-server metadata would leave the OS view of
486+
/// pinned descendants stuck on `.inherited` even though the database row
487+
/// is correct. (Symptom observed in #9923 for `/Documents/Documenter`.)
488+
///
489+
func testHandlePagedReadResultsPreservesKeepDownloadedOnPageZeroChildren() {
490+
let dbManager = Self.dbManager
491+
debugPrint(dbManager.ncDatabase())
492+
493+
// Seed two of the upcoming page-0 children as already pinned. The
494+
// third stays unseeded so we can also assert the flag does not leak
495+
// onto items that never had it.
496+
let pinnedChildren = ["pagedChild0", "pagedChild2"]
497+
for ocId in pinnedChildren {
498+
var seeded = SendableItemMetadata(ocId: ocId, fileName: "\(ocId).txt", account: Self.account)
499+
seeded.keepDownloaded = true
500+
dbManager.addItemMetadata(seeded)
501+
}
502+
503+
let parentNKFile = remoteFolder.toNKFile()
504+
let childrenNKFiles = (0 ..< 3).map { i in
505+
MockRemoteItem(
506+
identifier: "pagedChild\(i)",
507+
name: "pagedChild\(i).txt",
508+
remotePath: Self.account.davFilesUrl + "/folder/pagedChild\(i).txt",
509+
account: Self.account.ncKitAccount,
510+
username: Self.account.username,
511+
userId: Self.account.id,
512+
serverUrl: Self.account.serverUrl
513+
).toNKFile()
514+
}
515+
516+
let (returnedMetadatas, error) = Enumerator.handlePagedReadResults(
517+
files: [parentNKFile] + childrenNKFiles, pageIndex: 0, dbManager: dbManager
518+
)
519+
XCTAssertNil(error)
520+
521+
for ocId in pinnedChildren {
522+
let stored = dbManager.itemMetadata(ocId: ocId)
523+
XCTAssertEqual(
524+
stored?.keepDownloaded, true,
525+
"Page-0 child \(ocId) must retain keepDownloaded across the paginated write (#9923)."
526+
)
527+
let returned = returnedMetadatas?.first(where: { $0.ocId == ocId })
528+
XCTAssertEqual(
529+
returned?.keepDownloaded, true,
530+
"Page-0 child \(ocId) reported back to the enumeration observer must also reflect the preserved keepDownloaded — the framework builds contentPolicy from this value."
531+
)
532+
}
533+
XCTAssertEqual(
534+
dbManager.itemMetadata(ocId: "pagedChild1")?.keepDownloaded, false,
535+
"Children that were never pinned must not gain the flag from the paginated write."
536+
)
537+
}
538+
539+
///
540+
/// Same protection on follow-up pages: a paginated read with `pageIndex > 0`
541+
/// must not clobber ``keepDownloaded`` on any of its items. This is the
542+
/// path most likely to land on already-pinned descendants because the
543+
/// recursive enable of "Always keep downloaded" runs without pagination
544+
/// while the OS-driven enumeration that follows uses pagination.
545+
///
546+
func testHandlePagedReadResultsPreservesKeepDownloadedOnFollowUpPage() {
547+
let dbManager = Self.dbManager
548+
debugPrint(dbManager.ncDatabase())
549+
550+
var seeded = SendableItemMetadata(ocId: "pagedChild5", fileName: "pagedChild5.txt", account: Self.account)
551+
seeded.keepDownloaded = true
552+
dbManager.addItemMetadata(seeded)
553+
554+
let followUpChildrenNKFiles = (5 ..< 8).map { i in
555+
MockRemoteItem(
556+
identifier: "pagedChild\(i)",
557+
name: "pagedChild\(i).txt",
558+
remotePath: Self.account.davFilesUrl + "/folder/pagedChild\(i).txt",
559+
account: Self.account.ncKitAccount,
560+
username: Self.account.username,
561+
userId: Self.account.id,
562+
serverUrl: Self.account.serverUrl
563+
).toNKFile()
564+
}
565+
566+
let (returnedMetadatas, error) = Enumerator.handlePagedReadResults(
567+
files: followUpChildrenNKFiles, pageIndex: 1, dbManager: dbManager
568+
)
569+
XCTAssertNil(error)
570+
571+
XCTAssertEqual(
572+
dbManager.itemMetadata(ocId: "pagedChild5")?.keepDownloaded, true,
573+
"Follow-up page child must retain keepDownloaded across the paginated write (#9923)."
574+
)
575+
XCTAssertEqual(
576+
returnedMetadatas?.first(where: { $0.ocId == "pagedChild5" })?.keepDownloaded, true,
577+
"Follow-up page child reported back to the enumeration observer must also reflect the preserved keepDownloaded."
578+
)
579+
}
580+
581+
///
582+
/// The page-0 target directory must continue to be marked as visited
583+
/// after the preservation refactor — the original inline path applied
584+
/// `visitedDirectory = true` unconditionally, and that behaviour must
585+
/// survive even when a previous DB row carries `visitedDirectory = false`.
586+
/// Previously-set ``keepDownloaded`` on the directory must also survive.
587+
///
588+
func testHandlePagedReadResultsTargetDirectoryRecordsVisitAndKeepsKeepDownloaded() {
589+
let dbManager = Self.dbManager
590+
debugPrint(dbManager.ncDatabase())
591+
592+
var seeded = remoteFolder.toItemMetadata(account: Self.account)
593+
seeded.keepDownloaded = true
594+
seeded.visitedDirectory = false
595+
dbManager.addItemMetadata(seeded)
596+
597+
let parentNKFile = remoteFolder.toNKFile()
598+
let (_, error) = Enumerator.handlePagedReadResults(
599+
files: [parentNKFile], pageIndex: 0, dbManager: dbManager
600+
)
601+
XCTAssertNil(error)
602+
603+
let stored = dbManager.itemMetadata(ocId: parentNKFile.ocId)
604+
XCTAssertEqual(
605+
stored?.visitedDirectory, true,
606+
"Page-0 target directory must be flagged as visited by the current request."
607+
)
608+
XCTAssertEqual(
609+
stored?.keepDownloaded, true,
610+
"Page-0 target directory must retain its existing keepDownloaded flag."
611+
)
612+
}
613+
475614
func testWorkingSetEnumerateChanges() async throws {
476615
// This test verifies that `enumerateChanges` for the working set correctly
477616
// queries the local database for changes since the provided sync anchor date.

shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/KeepDownloadedRecursiveTests.swift

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,4 +455,90 @@ final class KeepDownloadedRecursiveTests: NextcloudFileProviderKitTestCase {
455455
"A sibling outside the pinned folder must not inherit keepDownloaded."
456456
)
457457
}
458+
459+
///
460+
/// End-to-end regression for #9923: a paginated read that lands on
461+
/// already-pinned descendants must not silently clear their flag. Before
462+
/// the fix, the bulk write at the end of
463+
/// ``Enumerator.handlePagedReadResults`` used ``addItemMetadata`` which
464+
/// replaces rows wholesale via Realm's `update: .all`, dropping every
465+
/// local-only field — including ``keepDownloaded``. The user's pin walk
466+
/// runs without pagination, but the OS-driven `enumerateItems` that
467+
/// follows uses pagination, so a recently-pinned subtree was reliably
468+
/// undone.
469+
///
470+
func testPaginatedReadDoesNotClobberRecursivelyPinnedDescendants() throws {
471+
// Pin the seeded tree the way `Item.set(keepDownloaded:domain:)` does.
472+
let folderMetadata = try seedTree()
473+
_ = try Self.dbManager.set(keepDownloaded: true, for: folderMetadata)
474+
for child in Self.dbManager.childItems(directoryMetadata: folderMetadata) {
475+
_ = try Self.dbManager.set(keepDownloaded: true, for: child)
476+
}
477+
478+
// Build NKFiles with ocIds matching the seeded rows so the
479+
// preservation lookup hits. The `MockRemoteItem` parent chain
480+
// provides the right `serverUrl` on each `NKFile`.
481+
let parent = MockRemoteItem(
482+
identifier: "files-parent",
483+
name: "files",
484+
remotePath: "https://cloud.example.com/files",
485+
directory: true,
486+
account: Self.account.ncKitAccount,
487+
username: Self.account.username,
488+
userId: Self.account.id,
489+
serverUrl: Self.account.serverUrl
490+
)
491+
let folderMock = MockRemoteItem(
492+
identifier: "folder-1",
493+
name: "documents",
494+
remotePath: "https://cloud.example.com/files/documents",
495+
directory: true,
496+
account: Self.account.ncKitAccount,
497+
username: Self.account.username,
498+
userId: Self.account.id,
499+
serverUrl: Self.account.serverUrl
500+
)
501+
folderMock.parent = parent
502+
let directChildMock = MockRemoteItem(
503+
identifier: "direct-child-file",
504+
name: "report.pdf",
505+
remotePath: "https://cloud.example.com/files/documents/report.pdf",
506+
account: Self.account.ncKitAccount,
507+
username: Self.account.username,
508+
userId: Self.account.id,
509+
serverUrl: Self.account.serverUrl
510+
)
511+
directChildMock.parent = folderMock
512+
let subfolderMock = MockRemoteItem(
513+
identifier: "subfolder-1",
514+
name: "nested",
515+
remotePath: "https://cloud.example.com/files/documents/nested",
516+
directory: true,
517+
account: Self.account.ncKitAccount,
518+
username: Self.account.username,
519+
userId: Self.account.id,
520+
serverUrl: Self.account.serverUrl
521+
)
522+
subfolderMock.parent = folderMock
523+
524+
// Simulate the page-0 paginated PROPFIND response Finder would issue
525+
// for the pinned folder.
526+
let (_, error) = Enumerator.handlePagedReadResults(
527+
files: [folderMock.toNKFile(), directChildMock.toNKFile(), subfolderMock.toNKFile()],
528+
pageIndex: 0,
529+
dbManager: Self.dbManager
530+
)
531+
XCTAssertNil(error)
532+
533+
// Both directly re-enumerated descendants and the deeper file
534+
// (untouched by this PROPFIND but pinned in step 1) must remain
535+
// pinned after the paginated overwrite.
536+
for ocId in ["folder-1", "direct-child-file", "subfolder-1", "deep-file"] {
537+
let stored = try XCTUnwrap(Self.dbManager.itemMetadata(ocId: ocId))
538+
XCTAssertTrue(
539+
stored.keepDownloaded,
540+
"\(ocId) must remain pinned after a paginated read overwrites the row (#9923)."
541+
)
542+
}
543+
}
458544
}

0 commit comments

Comments
 (0)