Compare commits

...

12 Commits
v3.0.2 ... main

Author SHA1 Message Date
Elaine Lyons 0be9d19396
[#43] Clarify that only Nextcloud has been tested 2022-03-02 14:26:51 -07:00
Isaac Lyons ef89fdcbf6
Merge pull request #39 from Isvvc/cleanup-fixes
Cleanup fixes
2021-05-11 11:13:37 -06:00
Isvvc 838d43e66d Fix issue where cached thumbnails would be deleted on cleanup 2021-05-10 20:10:58 -06:00
Isvvc 4f31371f20 Improve testCleanupDiskCacheFolder to include a real folder 2021-05-10 19:59:31 -06:00
Isvvc 1c604afe9c Fix issue where all files would be deleted from root on cleanup
Improve disk cache cleanup file test
2021-05-10 19:46:26 -06:00
Isvvc bd8231252f Fix issue where directories would always be removed from filesCache
Improve files cache cleanup test
2021-05-10 19:26:42 -06:00
Isvvc d79ed6d6bc Make WebDAVFile piecewise initializer public 2021-05-03 21:53:37 -06:00
Isvvc 375187f613 Run downloadImage completion closure with .placeholder error if thumbnail preview 2021-04-28 18:01:16 -06:00
Isaac Lyons 38b15c9b8b
Merge pull request #35 from Isvvc/thumbnail-preview
Thumbnail Preview
2021-04-28 17:17:03 -06:00
Isaac Lyons f68cc90187
Document thumbnail previews 2021-04-28 17:03:31 -06:00
Isvvc a42084f924 Run thumbnail preview fetching on utility thread
Closes #36
2021-04-28 16:53:23 -06:00
Isvvc 2368bb8d56 Add option to return thumbnail first when fetching image
Closes #31
2021-04-28 15:10:35 -06:00
7 changed files with 246 additions and 38 deletions

View File

@ -2,6 +2,13 @@
WebDAV communication library for Swift
**Note**: This has only been tested on **Nextcloud** servers.
Submit an [issue](https://github.com/Isvvc/WebDAV-Swift/issues) if you have compatibility issues with other WebDAV servers.
Help in adding support for additional services would be greatly appreciated!
See [Contribution](#contribution).
* [\[#43\]](https://github.com/Isvvc/WebDAV-Swift/issues/43) Apache WebDAV has been reported to not work
## Table of contents
+ [Install](#install)
@ -182,6 +189,31 @@ Image functions include:
_Why is there no `deleteCachedImage` or `cachedImageURL` function when there is `getCachedThumbnail` and `cachedThumbnailURL`?_
Images are stored in the disk cache the same way as data. The image-specific functions exist as a convenience for converting the data to UIImages and caching them in memory that way. Since the cached data URL does not change whether the data is an image or not, `deleteCachedData` and `cachedDataURL` can be used for images.
#### Thumbnail preview
If there are already cached thumbnails for the image you are trying to fetch, you can use the `preview` parameter to specify that you would like to get that thumbnail first while the full-size image is downloading. When you do this, the completion closure will run with a `.placeholder` error on the call with the thumbnail.
```swift
webDAV.downloadImage(path: imagePath, account: account, password: password, preview: .memoryOnly) { image, error in
switch error {
case .none, .placeholder:
// .none is the full-size image.
// .placeholder is the thumbnail.
// The completion closure will not be run with the thumbnail after
// it is run with the full-size image, so assuming you don't have
// a use for the thumbnail after the full-size image loads, you
// shouldn't need to check which it is before displaying.
break
case .some(let unexpectedError):
// Log the error
}
// Display the image
}
```
See [Thumbnails](#thumbnails) for more details on thumbnails.
### Thumbnails
Along with downloading full-sized images, you can download **thumbnails** from Nextcloud servers.

View File

@ -23,9 +23,16 @@ public extension WebDAV {
func cachedDataURL<A: WebDAVAccount>(forItemAtPath path: String, account: A) -> URL? {
guard let encodedDescription = UnwrappedAccount(account: account)?.encodedDescription,
let caches = cacheFolder else { return nil }
return caches
.appendingPathComponent(encodedDescription)
.appendingPathComponent(path.trimmingCharacters(in: AccountPath.slash))
let trimmedPath = path.trimmingCharacters(in: AccountPath.slash)
if trimmedPath.isEmpty {
return caches
.appendingPathComponent(encodedDescription)
} else {
return caches
.appendingPathComponent(encodedDescription)
.appendingPathComponent(trimmedPath)
}
}
/// Get the local cached data URL for the item at the specified path if there is cached data there.
@ -178,13 +185,14 @@ extension WebDAV {
func cleanupDiskCache<A: WebDAVAccount>(at path: String, account: A, files: [WebDAVFile]) throws {
let fm = FileManager.default
guard let url = cachedDataURL(forItemAtPath: path, account: account),fm.fileExists(atPath: url.path) else { return }
guard let url = cachedDataURL(forItemAtPath: path, account: account),
fm.fileExists(atPath: url.path) else { return }
let goodFilePaths = Set(files.compactMap { cachedDataURL(forItemAtPath: $0.path, account: account)?.path })
let goodFilePaths = files.compactMap { cachedDataURL(forItemAtPath: $0.path, account: account)?.path }
let infoPlist = filesCacheURL?.path
for path in try fm.contentsOfDirectory(atPath: url.path).map({ url.appendingPathComponent($0).path })
where !goodFilePaths.contains(path)
where !goodFilePaths.contains(where: { path.starts(with: $0) })
&& path != infoPlist {
try fm.removeItem(atPath: path)
}

View File

@ -67,6 +67,16 @@ public struct ThumbnailProperties: Hashable {
public extension WebDAV {
enum ThumbnailPreviewMode {
/// Only show a preview thumbnail if there is already one loaded into memory.
case memoryOnly
/// Allow the disk cache to be loaded if there are no thumbnails in memory.
/// Note that this can be an expensive process and is run on the main thread.
case diskAllowed
/// Load the specific thumbnail if available, from disk if not in memory.
case specific(ThumbnailProperties)
}
//MARK: Images
/// Download and cache an image from the specified file path.
@ -75,16 +85,40 @@ public extension WebDAV {
/// - account: The WebDAV account.
/// - password: The WebDAV account's password.
/// - options: Options for caching the results. Empty set uses default caching behavior.
/// - completion: If account properties are invalid, this will run immediately on the same thread.
/// Otherwise, it runs when the network call finishes on a background thread.
/// - preview: Behavior for running the completion closure with cached thumbnails before the full-sized image is fetched.
/// Note that `.diskAllowed` will load all thumbnails for the given image which can be an expensive process.
/// `.memoryOnly` and `.specific()` are recommended unless you do not know what thumbnails exist.
/// - completion: If account properties are invalid, this will run immediately on the same thread with an error.
/// Otherwise, it will run on a utility thread with a preview (if available and a `preview` mode is provided) with a `.placeholder` error,
/// then run on a background thread when the network call finishes.
/// This will not be called with the thumbnail after being called with the full-size image.
/// - image: The image downloaded, if successful.
/// The cached image if it has already been downloaded.
/// - cachedImageURL: The URL of the cached image.
/// - error: A WebDAVError if the call was unsuccessful. `nil` if it was.
/// - Returns: The request identifier.
@discardableResult
func downloadImage<A: WebDAVAccount>(path: String, account: A, password: String, caching options: WebDAVCachingOptions = [], completion: @escaping (_ image: UIImage?, _ error: WebDAVError?) -> Void) -> URLSessionDataTask? {
cachingDataTask(cache: imageCache, path: path, account: account, password: password, caching: options, valueFromData: { UIImage(data: $0) }, completion: completion)
func downloadImage<A: WebDAVAccount>(path: String, account: A, password: String, caching options: WebDAVCachingOptions = [], preview: ThumbnailPreviewMode? = .none, completion: @escaping (_ image: UIImage?, _ error: WebDAVError?) -> Void) -> URLSessionDataTask? {
cachingDataTask(cache: imageCache, path: path, account: account, password: password, caching: options, valueFromData: { UIImage(data: $0) }, placeholder: {
// Load placeholder thumbnail
var thumbnails = self.getAllMemoryCachedThumbnails(forItemAtPath: path, account: account)?.values
switch preview {
case .none:
return nil
case .specific(let properties):
return self.getCachedThumbnail(forItemAtPath: path, account: account, with: properties)
case .memoryOnly:
break
case .diskAllowed:
// Only load from disk if there aren't any in memory
if thumbnails?.isEmpty ?? true {
thumbnails = self.getAllMemoryCachedThumbnails(forItemAtPath: path, account: account)?.values
}
}
let largestThumbnail = thumbnails?.max(by: { $0.size.width * $0.size.height < $1.size.width * $1.size.height })
return largestThumbnail
}, completion: completion)
}
//MARK: Thumbnails

View File

@ -384,7 +384,10 @@ extension WebDAV {
//MARK: Standard Requests
func cachingDataTask<A: WebDAVAccount, Value: Equatable>(cache: Cache<AccountPath, Value>, path: String, account: A, password: String, caching options: WebDAVCachingOptions, valueFromData: @escaping (_ data: Data) -> Value?, completion: @escaping (_ value: Value?, _ error: WebDAVError?) -> Void) -> URLSessionDataTask? {
func cachingDataTask<A: WebDAVAccount, Value: Equatable>(
cache: Cache<AccountPath, Value>, path: String, account: A, password: String,
caching options: WebDAVCachingOptions, valueFromData: @escaping (_ data: Data) -> Value?, placeholder: (() -> Value?)? = nil,
completion: @escaping (_ value: Value?, _ error: WebDAVError?) -> Void) -> URLSessionDataTask? {
// Check cache
@ -407,10 +410,19 @@ extension WebDAV {
}
}
// Cached data was not returned. Continue with network fetch.
if options.contains(.removeExistingCache) {
try? deleteCachedData(forItemAtPath: path, account: account)
}
let placeholderTask = DispatchWorkItem {
if let placeholderValue = placeholder?() {
completion(placeholderValue, .placeholder)
}
}
DispatchQueue.global(qos: .utility).async(execute: placeholderTask)
// Create network request
guard let request = authorizedRequest(path: path, account: account, password: password, method: .get) else {
@ -427,6 +439,7 @@ extension WebDAV {
return completion(nil, error)
} else if let data = data,
let value = valueFromData(data) {
placeholderTask.cancel()
// Cache result
if !options.contains(.removeExistingCache),
!options.contains(.doNotCacheResult) {
@ -528,7 +541,7 @@ extension WebDAV {
for (key, _) in filesCache
where key.path != directory
&& key.path.starts(with: directory)
&& !files.contains(where: { key.path.starts(with: $0.path) }) {
&& !files.contains(where: { key.path.starts(with: $0.path.trimmingCharacters(in: AccountPath.slash)) }) {
filesCache.removeValue(forKey: key)
changed = true
}

View File

@ -19,6 +19,8 @@ public enum WebDAVError: Error {
case unsupported
/// Another unspecified Error occurred.
case nsError(Error)
/// The returned value is simply a placeholder.
case placeholder
static func getError(statusCode: Int?, error: Error?) -> WebDAVError? {
if let statusCode = statusCode {

View File

@ -20,7 +20,7 @@ public struct WebDAVFile: Identifiable, Codable, Equatable, Hashable {
public private(set) var size: Int
public private(set) var etag: String
init(path: String, id: String, isDirectory: Bool, lastModified: Date, size: Int, etag: String) {
public init(path: String, id: String, isDirectory: Bool, lastModified: Date, size: Int, etag: String) {
self.path = path
self.id = id
self.isDirectory = isDirectory

View File

@ -356,29 +356,46 @@ final class WebDAVTests: XCTestCase {
func testCleanupFilesCacheRoot() {
guard let (account, password) = getAccount() else { return XCTFail() }
let listRealDirExpectation = XCTestExpectation(description: "List files from real directory.")
let expectation = XCTestExpectation(description: "List files from WebDAV")
let path = UUID().uuidString
let accountPath = AccountPath(account: account, path: path)
let realDir = createFolder(account: account, password: password)
let realDirAccountPath = AccountPath(account: account, path: realDir)
let fakeDir = UUID().uuidString
let fakeDirAccountPath = AccountPath(account: account, path: fakeDir)
// Load real file into cache
uploadData(to: realDir, account: account, password: password)
webDAV.listFiles(atPath: realDir, account: account, password: password, caching: .doNotReturnCachedResult) { _, _ in
listRealDirExpectation.fulfill()
}
wait(for: [listRealDirExpectation], timeout: 10000.0)
XCTAssertNotNil(webDAV.filesCache[realDirAccountPath])
// Place fake cache
webDAV.filesCache[accountPath] = [WebDAVFile(path: path + "/fake.txt", id: "0", isDirectory: true, lastModified: Date(), size: 0, etag: "0")]
XCTAssertNotNil(webDAV.filesCache[accountPath])
webDAV.filesCache[fakeDirAccountPath] = [WebDAVFile(path: fakeDir + "/fake.txt", id: "0", isDirectory: true, lastModified: Date(), size: 0, etag: "0")]
XCTAssertNotNil(webDAV.filesCache[fakeDirAccountPath])
// List files
webDAV.listFiles(atPath: "/", account: account, password: password, foldersFirst: false, caching: .ignoreCache) { files, error in
webDAV.listFiles(atPath: "/", account: account, password: password, caching: .doNotReturnCachedResult) { files, error in
XCTAssertNotNil(files)
XCTAssertNil(error)
expectation.fulfill()
}
wait(for: [expectation], timeout: 10.0)
wait(for: [expectation], timeout: 10000.0)
// Check that the fake cached file was cleaned up
XCTAssertNil(webDAV.filesCache[fakeDirAccountPath])
// Check that the real file still exists
XCTAssertNotNil(webDAV.filesCache[realDirAccountPath])
XCTAssertNil(webDAV.filesCache[accountPath])
deleteFile(path: realDir, account: account, password: password)
}
func testCleanupFilesCacheSubdirectory() {
@ -397,7 +414,7 @@ final class WebDAVTests: XCTestCase {
// List files
webDAV.listFiles(atPath: folder, account: account, password: password, foldersFirst: false, caching: .ignoreCache) { files, error in
webDAV.listFiles(atPath: folder, account: account, password: password, foldersFirst: false, caching: .doNotReturnCachedResult) { files, error in
XCTAssertNotNil(files)
XCTAssertNil(error)
expectation.fulfill()
@ -413,23 +430,31 @@ final class WebDAVTests: XCTestCase {
//MARK: Disk Cache Cleanup
func testCleanupDiskCacheFile() {
func testCleanupDiskCacheFileRoot() {
guard let (account, password) = getAccount() else { return XCTFail() }
let expectation = XCTestExpectation(description: "List files from WebDAV")
// Add dummy file to disk cache
let path = UUID().uuidString + ".txt"
let dummyPath = UUID().uuidString + ".txt"
let data = UUID().uuidString.data(using: .utf8)!
let tempFileURL = webDAV.cachedDataURL(forItemAtPath: path, account: account)!
XCTAssertNoThrow(try webDAV.saveDataToDiskCache(data, url: tempFileURL))
let dummyFileURL = webDAV.cachedDataURL(forItemAtPath: dummyPath, account: account)!
XCTAssertNoThrow(try webDAV.saveDataToDiskCache(data, url: dummyFileURL))
XCTAssert(FileManager.default.fileExists(atPath: tempFileURL.path))
XCTAssert(FileManager.default.fileExists(atPath: dummyFileURL.path))
// Create real file
let realFile = uploadData(account: account, password: password)
let realFileURL = webDAV.cachedDataURL(forItemAtPath: realFile.fileName, account: account)!
downloadData(path: realFile.fileName, account: account, password: password)
XCTAssert(FileManager.default.fileExists(atPath: realFileURL.path))
// List files
webDAV.listFiles(atPath: "/", account: account, password: password, foldersFirst: false, caching: .ignoreCache) { files, error in
webDAV.listFiles(atPath: "/", account: account, password: password, foldersFirst: false, caching: .doNotReturnCachedResult) { files, error in
XCTAssertNotNil(files)
XCTAssertNil(error)
expectation.fulfill()
@ -438,8 +463,11 @@ final class WebDAVTests: XCTestCase {
wait(for: [expectation], timeout: 10.0)
// Check that the fake cached file was cleaned up
XCTAssertFalse(FileManager.default.fileExists(atPath: dummyFileURL.path))
// Check that the real file still exists
XCTAssert(FileManager.default.fileExists(atPath: realFileURL.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: tempFileURL.path))
deleteFile(path: realFile.fileName, account: account, password: password)
}
func testCleanupDiskCacheFolder() {
@ -450,13 +478,23 @@ final class WebDAVTests: XCTestCase {
// Add dummy folder to disk cache
let path = UUID().uuidString
let tempFileURL = webDAV.cachedDataURL(forItemAtPath: path, account: account)!
XCTAssertNoThrow(try FileManager.default.createDirectory(at: tempFileURL, withIntermediateDirectories: true))
XCTAssert(FileManager.default.fileExists(atPath: tempFileURL.path))
let dummyFileURL = webDAV.cachedDataURL(forItemAtPath: path, account: account)!
XCTAssertNoThrow(try FileManager.default.createDirectory(at: dummyFileURL, withIntermediateDirectories: true))
XCTAssert(FileManager.default.fileExists(atPath: dummyFileURL.path))
// Create real folder
let folder = createFolder(account: account, password: password)
let realFile = uploadData(to: folder, account: account, password: password)
downloadData(path: realFile.fileName, account: account, password: password)
let realFileURL = webDAV.cachedDataURL(forItemAtPath: realFile.fileName, account: account)!
XCTAssert(FileManager.default.fileExists(atPath: realFileURL.path))
// List files
webDAV.listFiles(atPath: "/", account: account, password: password, foldersFirst: false, caching: .ignoreCache) { files, error in
webDAV.listFiles(atPath: "/", account: account, password: password, foldersFirst: false, caching: .doNotReturnCachedResult) { files, error in
XCTAssertNotNil(files)
XCTAssertNil(error)
expectation.fulfill()
@ -465,8 +503,11 @@ final class WebDAVTests: XCTestCase {
wait(for: [expectation], timeout: 10.0)
// Check that the fake cached folder was cleaned up
XCTAssertFalse(FileManager.default.fileExists(atPath: dummyFileURL.path))
// Check that the real file still exists
XCTAssert(FileManager.default.fileExists(atPath: realFileURL.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: tempFileURL.path))
deleteFile(path: folder, account: account, password: password)
}
func testCleanupDiskCacheWithGoodFile() {
@ -496,7 +537,7 @@ final class WebDAVTests: XCTestCase {
// List files
webDAV.listFiles(atPath: directory.path, account: account, password: password, foldersFirst: false, caching: .ignoreCache) { files, error in
webDAV.listFiles(atPath: directory.path, account: account, password: password, caching: .doNotReturnCachedResult) { files, error in
XCTAssertNotNil(files)
XCTAssertNil(error)
expectation.fulfill()
@ -511,7 +552,35 @@ final class WebDAVTests: XCTestCase {
XCTAssertNoThrow(try webDAV.deleteCachedData(forItemAtPath: imagePath, account: account))
}
//MARK: Image Cache
func testCleanupDiskCacheKeepingThumbnail() {
guard let (account, password) = getAccount() else { return XCTFail() }
guard let imagePath = ProcessInfo.processInfo.environment["image_path"] else {
return XCTFail("You need to set the image_path in the environment.")
}
let expectation = XCTestExpectation(description: "List files from WebDAV")
// Download Thumbnail
downloadThumbnail(imagePath: imagePath, account: account, password: password)
let cachedThumbnailURL = webDAV.cachedThumbnailURL(forItemAtPath: imagePath, account: account, with: .default)!
XCTAssert(FileManager.default.fileExists(atPath: cachedThumbnailURL.path))
// List files
let imageURL = URL(fileURLWithPath: imagePath, isDirectory: false)
let directory = imageURL.deletingLastPathComponent()
webDAV.listFiles(atPath: directory.path, account: account, password: password, caching: .doNotReturnCachedResult) { _, _ in
expectation.fulfill()
}
wait(for: [expectation], timeout: 10.0)
// Check that the cached thumbnail still exists
XCTAssert(FileManager.default.fileExists(atPath: cachedThumbnailURL.path))
}
//MARK: Images
func testDownloadImage() {
guard let (account, password) = getAccount() else { return XCTFail() }
@ -628,6 +697,34 @@ final class WebDAVTests: XCTestCase {
XCTAssertFalse(FileManager.default.fileExists(atPath: cachedThumbnailFitURL.path))
}
func testThumbnailPlaceholder() {
guard let (account, password) = getAccount() else { return XCTFail() }
guard let imagePath = ProcessInfo.processInfo.environment["image_path"] else {
return XCTFail("You need to set the image_path in the environment.")
}
let thumbnailExpectation = XCTestExpectation(description: "Get the cached thumbnail")
let imageExpectation = XCTestExpectation(description: "Fetch image")
downloadThumbnail(imagePath: imagePath, account: account, password: password)
try? webDAV.deleteCachedData(forItemAtPath: imagePath, account: account)
webDAV.downloadImage(path: imagePath, account: account, password: password, preview: .memoryOnly) { image, error in
switch error {
case .placeholder:
thumbnailExpectation.fulfill()
case .none:
imageExpectation.fulfill()
case .some(let unexpectedError):
XCTFail("\(unexpectedError)")
}
XCTAssertNotNil(image)
}
try? webDAV.deleteCachedData(forItemAtPath: imagePath, account: account)
wait(for: [thumbnailExpectation, imageExpectation], timeout: 10.0)
}
//MARK: OCS
func testColorHex() {
@ -691,11 +788,17 @@ final class WebDAVTests: XCTestCase {
wait(for: [expectation], timeout: 10.0)
}
private func uploadData(account: SimpleAccount, password: String) -> (name: String, fileName: String, content: String) {
@discardableResult
private func uploadData(to folder: String = "", account: SimpleAccount, password: String) -> (name: String, fileName: String, content: String) {
let expectation = XCTestExpectation(description: "Upload data")
let name = UUID().uuidString
let fileName = name + ".txt"
let fileName: String
if folder.isEmpty {
fileName = name + ".txt"
} else {
fileName = "\(folder)/\(name).txt"
}
let content = UUID().uuidString
let data = content.data(using: .utf8)!
@ -735,6 +838,21 @@ final class WebDAVTests: XCTestCase {
return folder
}
private func downloadData(path file: String, account: SimpleAccount, password: String) {
let expectation = XCTestExpectation(description: "Download file from WebDAV")
try? webDAV.deleteCachedData(forItemAtPath: file, account: account)
webDAV.download(fileAtPath: file, account: account, password: password) { image, error in
XCTAssertNil(error)
XCTAssertNotNil(image)
expectation.fulfill()
}
wait(for: [expectation], timeout: 10.0)
}
private func downloadImage(imagePath: String, account: SimpleAccount, password: String) {
let expectation = XCTestExpectation(description: "Download image from WebDAV")
@ -788,7 +906,7 @@ final class WebDAVTests: XCTestCase {
("testCleanupFilesCacheRoot", testCleanupFilesCacheRoot),
("testCleanupFilesCacheSubdirectory", testCleanupFilesCacheSubdirectory),
// Disk Cache Cleanup
("testCleanupDiskCacheFile", testCleanupDiskCacheFile),
("testCleanupDiskCacheFileRoot", testCleanupDiskCacheFileRoot),
("testCleanupDiskCacheFolder", testCleanupDiskCacheFolder),
("testCleanupDiskCacheWithGoodFile", testCleanupDiskCacheWithGoodFile),
// Image Cache
@ -800,6 +918,7 @@ final class WebDAVTests: XCTestCase {
("testThumbnailCacheURL", testThumbnailCacheURL),
("testSpecificThumbnailCache", testSpecificThumbnailCache),
("testGeneralThumbnailCache", testGeneralThumbnailCache),
("testThumbnailPlaceholder", testThumbnailPlaceholder),
// OCS
("testTheme", testTheme),
("testColorHex", testColorHex)