Skip to content

Commit 8b4f960

Browse files
authored
Merge pull request #75 from claucambra/bugfix/keep-offline
Add ability to mark items to always keep them downloaded
2 parents f08095f + b562ee2 commit 8b4f960

9 files changed

Lines changed: 264 additions & 16 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
//
2+
// FilesDatabaseManager+KeepDownloaded.swift
3+
// NextcloudFileProviderKit
4+
//
5+
// Created by Claudio Cambra on 13/5/25.
6+
//
7+
8+
import Foundation
9+
import RealmSwift
10+
11+
public extension FilesDatabaseManager {
12+
func set(
13+
keepDownloaded: Bool, for metadata: SendableItemMetadata
14+
) throws -> SendableItemMetadata? {
15+
guard #available(macOS 13.0, iOS 16.0, visionOS 1.0, *) else {
16+
let errorString = """
17+
Could not update keepDownloaded status for item: \(metadata.fileName)
18+
as the system does not support this state.
19+
"""
20+
Self.logger.error("\(errorString, privacy: .public)")
21+
throw NSError(
22+
domain: Self.errorDomain,
23+
code: NSFeatureUnsupportedError,
24+
userInfo: [NSLocalizedDescriptionKey: errorString]
25+
)
26+
}
27+
28+
guard let result = itemMetadatas.where({ $0.ocId == metadata.ocId }).first else {
29+
let errorString = """
30+
Did not update keepDownloaded for item metadata as it was not found.
31+
ocID: \(metadata.ocId)
32+
filename: \(metadata.fileName)
33+
"""
34+
Self.logger.error("\(errorString, privacy: .public)")
35+
throw NSError(
36+
domain: Self.errorDomain,
37+
code: ErrorCode.metadataNotFound.rawValue,
38+
userInfo: [NSLocalizedDescriptionKey: errorString]
39+
)
40+
}
41+
42+
try ncDatabase().write {
43+
result.keepDownloaded = keepDownloaded
44+
45+
Self.logger.debug(
46+
"""
47+
Updated keepDownloaded status for item metadata.
48+
ocID: \(metadata.ocId, privacy: .public)
49+
fileName: \(metadata.fileName, privacy: .public)
50+
"""
51+
)
52+
}
53+
return SendableItemMetadata(value: result)
54+
}
55+
}

Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,12 @@ public let relativeDatabaseFolderPath = "Database/"
2424
public let databaseFilename = "fileproviderextdatabase.realm"
2525

2626
public final class FilesDatabaseManager: Sendable {
27+
enum ErrorCode: Int {
28+
case metadataNotFound = -1000
29+
}
30+
2731
private static let schemaVersion = stable2_0SchemaVersion
32+
static let errorDomain = "FilesDatabaseManager"
2833
static let logger = Logger(subsystem: Logger.subsystem, category: "filesdatabase")
2934
let account: Account
3035

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
//
2+
// Item+KeepDownloaded.swift
3+
// NextcloudFileProviderKit
4+
//
5+
// Created by Claudio Cambra on 13/5/25.
6+
//
7+
8+
import FileProvider
9+
10+
public extension Item {
11+
func toggle(keepDownloadedIn domain: NSFileProviderDomain) async throws {
12+
try await set(keepDownloaded: !keepDownloaded, domain: domain)
13+
}
14+
15+
func set(keepDownloaded: Bool, domain: NSFileProviderDomain) async throws {
16+
try dbManager.set(keepDownloaded: keepDownloaded, for: metadata)
17+
18+
guard let manager = NSFileProviderManager(for: domain) else {
19+
if #available(macOS 14.1, *) {
20+
throw NSFileProviderError(.providerDomainNotFound)
21+
} else {
22+
let providerDomainNotFoundErrorCode = -2013
23+
throw NSError(
24+
domain: NSFileProviderErrorDomain,
25+
code: providerDomainNotFoundErrorCode,
26+
userInfo: [NSLocalizedDescriptionKey: "Failed to get manager for domain."]
27+
)
28+
}
29+
}
30+
31+
if #available(macOS 13.0, iOS 16.0, visionOS 1.0, *) {
32+
if keepDownloaded && !isDownloaded {
33+
try await manager.requestDownloadForItem(withIdentifier: itemIdentifier)
34+
} else if !keepDownloaded && isDownloaded {
35+
try await manager.evictItem(identifier: itemIdentifier)
36+
} else {
37+
try await manager.requestModification(
38+
of: [.lastUsedDate], forItemWithIdentifier: itemIdentifier
39+
)
40+
}
41+
} else {
42+
try await manager.signalEnumerator(for: .workingSet)
43+
}
44+
}
45+
}

Sources/NextcloudFileProviderKit/Item/Item.swift

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -197,18 +197,27 @@ public class Item: NSObject, NSFileProviderItem {
197197
// Note that only files, not folders, should be lockable/unlockable
198198
userInfoDict["locked"] = metadata.lock
199199
}
200-
// I suspect this is already exposed by Apple but the documentation is so vague I don't know
201-
userInfoDict["downloaded"] = metadata.downloaded
200+
if #available(macOS 13.0, iOS 16.0, visionOS 1.0, *) {
201+
userInfoDict["displayKeepDownloaded"] = !metadata.keepDownloaded
202+
userInfoDict["displayAllowAutoEvicting"] = metadata.keepDownloaded
203+
userInfoDict["displayEvict"] = metadata.downloaded && !metadata.keepDownloaded
204+
} else {
205+
userInfoDict["displayEvict"] = metadata.downloaded
206+
}
202207
return userInfoDict
203208
}
204209

205-
@available(macOS 13.0, *)
210+
@available(macOS 13.0, iOS 16.0, visionOS 1.0, *)
206211
public var contentPolicy: NSFileProviderContentPolicy {
207-
#if os(macOS)
208-
.downloadLazily
209-
#else
210-
.downloadLazilyAndEvictOnRemoteUpdate
211-
#endif
212+
if metadata.keepDownloaded {
213+
return .downloadEagerlyAndKeepDownloaded
214+
}
215+
return .inherited
216+
}
217+
218+
public var keepDownloaded: Bool {
219+
guard #available(macOS 13.0, iOS 16.0, visionOS 1.0, *) else { return false }
220+
return metadata.keepDownloaded
212221
}
213222

214223
public static func rootContainer(

Sources/NextcloudFileProviderKit/Metadata/ItemMetadata.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ public protocol ItemMetadata: Equatable {
9292
var tags: [String] { get set }
9393
var downloaded: Bool { get set }
9494
var uploaded: Bool { get set }
95+
var keepDownloaded: Bool { get set }
9596
var trashbinFileName: String { get set }
9697
var trashbinOriginalLocation: String { get set }
9798
var trashbinDeletionTime: Date { get set }

Sources/NextcloudFileProviderKit/Metadata/RealmItemMetadata.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ internal class RealmItemMetadata: Object, ItemMetadata {
8484
}
8585
@Persisted public var downloaded = false
8686
@Persisted public var uploaded = false
87+
@Persisted public var keepDownloaded = false
8788
@Persisted public var trashbinFileName = ""
8889
@Persisted public var trashbinOriginalLocation = ""
8990
@Persisted public var trashbinDeletionTime = Date()
@@ -157,6 +158,7 @@ internal class RealmItemMetadata: Object, ItemMetadata {
157158
self.tags = value.tags
158159
self.downloaded = value.downloaded
159160
self.uploaded = value.uploaded
161+
self.keepDownloaded = value.keepDownloaded
160162
self.trashbinFileName = value.trashbinFileName
161163
self.trashbinOriginalLocation = value.trashbinOriginalLocation
162164
self.trashbinDeletionTime = value.trashbinDeletionTime

Sources/NextcloudFileProviderKit/Metadata/SendableItemMetadata.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ public struct SendableItemMetadata: ItemMetadata, Sendable {
6363
public var tags: [String]
6464
public var downloaded: Bool
6565
public var uploaded: Bool
66+
public var keepDownloaded: Bool
6667
public var trashbinFileName: String
6768
public var trashbinOriginalLocation: String
6869
public var trashbinDeletionTime: Date
@@ -125,6 +126,7 @@ public struct SendableItemMetadata: ItemMetadata, Sendable {
125126
tags: [String] = [],
126127
downloaded: Bool = false,
127128
uploaded: Bool = false,
129+
keepDownloaded: Bool = false,
128130
trashbinFileName: String = "",
129131
trashbinOriginalLocation: String = "",
130132
trashbinDeletionTime: Date = Date(),
@@ -186,6 +188,7 @@ public struct SendableItemMetadata: ItemMetadata, Sendable {
186188
self.tags = tags
187189
self.downloaded = downloaded
188190
self.uploaded = uploaded
191+
self.keepDownloaded = keepDownloaded
189192
self.trashbinFileName = trashbinFileName
190193
self.trashbinOriginalLocation = trashbinOriginalLocation
191194
self.trashbinDeletionTime = trashbinDeletionTime
@@ -248,6 +251,7 @@ public struct SendableItemMetadata: ItemMetadata, Sendable {
248251
self.status = value.status
249252
self.downloaded = value.downloaded
250253
self.uploaded = value.uploaded
254+
self.keepDownloaded = value.keepDownloaded
251255
self.tags = value.tags
252256
self.trashbinFileName = value.trashbinFileName
253257
self.trashbinOriginalLocation = value.trashbinOriginalLocation

Tests/NextcloudFileProviderKitTests/FilesDatabaseManagerTests.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,4 +1031,28 @@ final class FilesDatabaseManagerTests: XCTestCase {
10311031

10321032
try FileManager.default.removeItem(at: tempDir)
10331033
}
1034+
1035+
func testKeepDownloadedSetting() throws {
1036+
let existingMetadata = RealmItemMetadata()
1037+
existingMetadata.ocId = "id-1"
1038+
existingMetadata.fileName = "File.pdf"
1039+
existingMetadata.account = "TestAccount"
1040+
existingMetadata.serverUrl = "https://example.com"
1041+
XCTAssertFalse(existingMetadata.keepDownloaded)
1042+
1043+
let realm = Self.dbManager.ncDatabase()
1044+
try realm.write {
1045+
realm.add(existingMetadata)
1046+
}
1047+
1048+
let sendable = SendableItemMetadata(value: existingMetadata)
1049+
var updatedMetadata =
1050+
try XCTUnwrap(try Self.dbManager.set(keepDownloaded: true, for: sendable))
1051+
XCTAssertTrue(updatedMetadata.keepDownloaded)
1052+
1053+
updatedMetadata.keepDownloaded = false
1054+
let finalMetadata =
1055+
try XCTUnwrap(try Self.dbManager.set(keepDownloaded: false, for: updatedMetadata))
1056+
XCTAssertFalse(finalMetadata.keepDownloaded)
1057+
}
10341058
}

Tests/NextcloudFileProviderKitTests/ItemPropertyTests.swift

Lines changed: 111 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ final class ItemPropertyTests: XCTestCase {
196196
XCTAssertTrue(lockPredicate.evaluate(with: fileproviderItems))
197197
}
198198

199-
func testItemUserInfoDownloadedState() {
199+
func testItemUserInfoDisplayEvictState() {
200200
var metadata =
201201
SendableItemMetadata(ocId: "test-id", fileName: "test.txt", account: Self.account)
202202
metadata.downloaded = true
@@ -209,16 +209,32 @@ final class ItemPropertyTests: XCTestCase {
209209
dbManager: Self.dbManager
210210
)
211211

212-
XCTAssertNotNil(item.userInfo?["downloaded"])
212+
XCTAssertNotNil(item.userInfo?["displayEvict"])
213213

214214
let fileproviderItems = ["fileproviderItems": [item]]
215-
let downloadedPredicate = NSPredicate(
216-
format: "SUBQUERY ( fileproviderItems, $fileproviderItem, $fileproviderItem.userInfo.downloaded == true ).@count > 0"
215+
let canEvictPredicate = NSPredicate(
216+
format: "SUBQUERY ( fileproviderItems, $fileproviderItem, $fileproviderItem.userInfo.displayEvict == true ).@count > 0"
217217
)
218-
XCTAssertTrue(downloadedPredicate.evaluate(with: fileproviderItems))
218+
XCTAssertTrue(canEvictPredicate.evaluate(with: fileproviderItems))
219+
220+
metadata.keepDownloaded = true
221+
let keepDownloadedItem = Item(
222+
metadata: metadata,
223+
parentItemIdentifier: .rootContainer,
224+
account: Self.account,
225+
remoteInterface: MockRemoteInterface(),
226+
dbManager: Self.dbManager
227+
)
228+
XCTAssertNotNil(keepDownloadedItem.userInfo?["displayEvict"])
229+
230+
let fileproviderKeepDownloadedItems = ["fileproviderItems": [keepDownloadedItem]]
231+
let cannotEvictPredicate = NSPredicate(
232+
format: "SUBQUERY ( fileproviderItems, $fileproviderItem, $fileproviderItem.userInfo.displayEvict == true ).@count > 0"
233+
)
234+
XCTAssertFalse(cannotEvictPredicate.evaluate(with: fileproviderKeepDownloadedItems))
219235
}
220236

221-
func testItemUserInfoUndownloadedState() {
237+
func testItemUserInfoNoDisplayEvictState() {
222238
var metadata =
223239
SendableItemMetadata(ocId: "test-id", fileName: "test.txt", account: Self.account)
224240
metadata.downloaded = false
@@ -231,15 +247,76 @@ final class ItemPropertyTests: XCTestCase {
231247
dbManager: Self.dbManager
232248
)
233249

234-
XCTAssertNotNil(item.userInfo?["downloaded"])
250+
XCTAssertNotNil(item.userInfo?["displayEvict"])
235251

236252
let fileproviderItems = ["fileproviderItems": [item]]
237253
let undownloadedPredicate = NSPredicate(
238-
format: "SUBQUERY ( fileproviderItems, $fileproviderItem, $fileproviderItem.userInfo.downloaded == false ).@count > 0"
254+
format: "SUBQUERY ( fileproviderItems, $fileproviderItem, $fileproviderItem.userInfo.displayEvict == false ).@count > 0"
239255
)
240256
XCTAssertTrue(undownloadedPredicate.evaluate(with: fileproviderItems))
241257
}
242258

259+
func testItemUserInfoKeepDownloadedProperties() {
260+
var metadataA =
261+
SendableItemMetadata(ocId: "test-id", fileName: "test.txt", account: Self.account)
262+
metadataA.keepDownloaded = true
263+
264+
let itemA = Item(
265+
metadata: metadataA,
266+
parentItemIdentifier: .rootContainer,
267+
account: Self.account,
268+
remoteInterface: MockRemoteInterface(),
269+
dbManager: Self.dbManager
270+
)
271+
XCTAssertEqual(itemA.userInfo?["displayKeepDownloaded"] as? Bool, false)
272+
XCTAssertEqual(itemA.userInfo?["displayAllowAutoEvicting"] as? Bool, true)
273+
XCTAssertEqual(itemA.userInfo?["displayEvict"] as? Bool, false)
274+
275+
let metadataB =
276+
SendableItemMetadata(ocId: "test-id", fileName: "test.txt", account: Self.account)
277+
let itemB = Item(
278+
metadata: metadataB,
279+
parentItemIdentifier: .rootContainer,
280+
account: Self.account,
281+
remoteInterface: MockRemoteInterface(),
282+
dbManager: Self.dbManager
283+
)
284+
XCTAssertTrue(itemB.userInfo?["displayKeepDownloaded"] as? Bool == true)
285+
XCTAssertTrue(itemB.userInfo?["displayAllowAutoEvicting"] as? Bool == false)
286+
XCTAssertEqual(itemB.userInfo?["displayEvict"] as? Bool, false)
287+
288+
var metadataC =
289+
SendableItemMetadata(ocId: "test-id", fileName: "test.txt", account: Self.account)
290+
metadataC.keepDownloaded = true
291+
metadataC.downloaded = true
292+
293+
let itemC = Item(
294+
metadata: metadataC,
295+
parentItemIdentifier: .rootContainer,
296+
account: Self.account,
297+
remoteInterface: MockRemoteInterface(),
298+
dbManager: Self.dbManager
299+
)
300+
XCTAssertEqual(itemC.userInfo?["displayKeepDownloaded"] as? Bool, false)
301+
XCTAssertEqual(itemC.userInfo?["displayAllowAutoEvicting"] as? Bool, true)
302+
XCTAssertEqual(itemC.userInfo?["displayEvict"] as? Bool, false)
303+
304+
var metadataD =
305+
SendableItemMetadata(ocId: "test-id", fileName: "test.txt", account: Self.account)
306+
metadataD.downloaded = true
307+
308+
let itemD = Item(
309+
metadata: metadataD,
310+
parentItemIdentifier: .rootContainer,
311+
account: Self.account,
312+
remoteInterface: MockRemoteInterface(),
313+
dbManager: Self.dbManager
314+
)
315+
XCTAssertEqual(itemD.userInfo?["displayKeepDownloaded"] as? Bool, true)
316+
XCTAssertEqual(itemD.userInfo?["displayAllowAutoEvicting"] as? Bool, false)
317+
XCTAssertEqual(itemD.userInfo?["displayEvict"] as? Bool, true)
318+
}
319+
243320
func testItemLockFileUntrashable() {
244321
let metadata = SendableItemMetadata(
245322
ocId: "test-id", fileName: ".~lock.test.doc#", account: Self.account
@@ -320,4 +397,30 @@ final class ItemPropertyTests: XCTestCase {
320397
XCTAssertFalse(notSharedItem.isSharedByCurrentUser)
321398
XCTAssertNil(notSharedItem.ownerNameComponents)
322399
}
400+
401+
func testContentPolicy() {
402+
var metadataA =
403+
SendableItemMetadata(ocId: "test-id", fileName: "test.txt", account: Self.account)
404+
metadataA.keepDownloaded = true
405+
406+
let itemA = Item(
407+
metadata: metadataA,
408+
parentItemIdentifier: .rootContainer,
409+
account: Self.account,
410+
remoteInterface: MockRemoteInterface(),
411+
dbManager: Self.dbManager
412+
)
413+
XCTAssertEqual(itemA.contentPolicy, .downloadEagerlyAndKeepDownloaded)
414+
415+
let metadataB =
416+
SendableItemMetadata(ocId: "test-id", fileName: "test.txt", account: Self.account)
417+
let itemB = Item(
418+
metadata: metadataB,
419+
parentItemIdentifier: .rootContainer,
420+
account: Self.account,
421+
remoteInterface: MockRemoteInterface(),
422+
dbManager: Self.dbManager
423+
)
424+
XCTAssertEqual(itemB.contentPolicy, .inherited)
425+
}
323426
}

0 commit comments

Comments
 (0)