// // DropboxService.swift // Harmony-Dropbox // // Created by Hthik on 3/4/19. // Copyright © 2019 Hthik. All rights reserved. // import Foundation import CoreData import Harmony import SwiftyDropbox extension DropboxService { enum DropboxError: LocalizedError { case nilDirectoryName var errorDescription: String? { switch self { case .nilDirectoryName: return NSLocalizedString("There is no provided Dropbox directory name.", comment: "") } } } private struct OAuthError: LocalizedError { var oAuthError: OAuth2Error var errorDescription: String? init(error: OAuth2Error, description: String) { self.oAuthError = error self.errorDescription = description } } internal enum CallError: Error { case error(SwiftyDropbox.CallError) init(_ callError: SwiftyDropbox.CallError) { self = .error(callError) } init?(_ callError: SwiftyDropbox.CallError?) { guard let callError = callError else { return nil } self = .error(callError) } } } public class DropboxService: NSObject, Service { public static let shared = DropboxService() public let localizedName = NSLocalizedString("Dropbox", comment: "") public let identifier = "com.rileytestut.Harmony.Dropbox" public var clientID: String? { didSet { guard let clientID = self.clientID else { return } DropboxClientsManager.setupWithAppKey(clientID) } } public var preferredDirectoryName: String? internal private(set) var dropboxClient: DropboxClient? internal let responseQueue = DispatchQueue(label: "com.rileytestut.Harmony.Dropbox.responseQueue") private var authorizationCompletionHandlers = [(Result) -> Void]() private var accountID: String? { get { return UserDefaults.standard.string(forKey: "harmony-dropbox_accountID") } set { UserDefaults.standard.set(newValue, forKey: "harmony-dropbox_accountID") } } private(set) var propertyGroupTemplate: (String, FileProperties.PropertyGroupTemplate)? private override init() { super.init() } } public extension DropboxService { func authenticate(withPresentingViewController viewController: UIViewController, completionHandler: @escaping (Result) -> Void) { self.authorizationCompletionHandlers.append(completionHandler) DropboxClientsManager.authorizeFromController(UIApplication.shared, controller: viewController) { (url) in UIApplication.shared.open(url, options: [:], completionHandler: nil) } } func authenticateInBackground(completionHandler: @escaping (Result) -> Void) { guard let accountID = self.accountID else { return completionHandler(.failure(.noSavedCredentials)) } self.authorizationCompletionHandlers.append(completionHandler) DropboxClientsManager.reauthorizeClient(accountID) self.finishAuthentication() } func deauthenticate(completionHandler: @escaping (Result) -> Void) { DropboxClientsManager.unlinkClients() self.accountID = nil completionHandler(.success) } func handleDropboxURL(_ url: URL) -> Bool { guard let result = DropboxClientsManager.handleRedirectURL(url) else { return false } switch result { case .cancel: self.authorizationCompletionHandlers.forEach { $0(.failure(.other(GeneralError.cancelled))) } self.authorizationCompletionHandlers.removeAll() case .success: self.finishAuthentication() case .error(let error, let description): print("Error authorizing with Dropbox.", error, description) let oAuthError = OAuthError(error: error, description: description) self.authorizationCompletionHandlers.forEach { $0(.failure(.other(oAuthError))) } self.authorizationCompletionHandlers.removeAll() } return true } } private extension DropboxService { func finishAuthentication() { func finish(_ result: Result) { // Reset self.authorizationCompletionHandlers _before_ calling all the completion handlers. // This stops us from accidentally calling completion handlers twice in some instances. let completionHandlers = self.authorizationCompletionHandlers self.authorizationCompletionHandlers.removeAll() completionHandlers.forEach { $0(result) } } guard let dropboxClient = DropboxClientsManager.authorizedClient else { return finish(.failure(.notAuthenticated)) } dropboxClient.users.getCurrentAccount().response { (account, error) in do { let account = try self.process(Result(account, error)) self.createSyncDirectoryIfNeeded() { (result) in switch result { case .success: // Validate metadata first so we can also retrieve property group template ID. let dummyMetadata = HarmonyMetadataKey.allHarmonyKeys.reduce(into: [:], { $0[$1] = $1.rawValue as Any }) self.validateMetadata(dummyMetadata) { (result) in switch result { case .success: // We could just always use DropboxClientsManager.authorizedClient, // but this way dropboxClient is nil until _all_ authentication steps are finished. self.dropboxClient = dropboxClient self.accountID = account.accountId let account = Account(name: account.name.displayName, emailAddress: account.email) finish(.success(account)) case .failure(let error): finish(.failure(AuthenticationError(error))) } } case .failure(let error): finish(.failure(AuthenticationError(error))) } } } catch { finish(.failure(AuthenticationError(error))) } } } func createSyncDirectoryIfNeeded(completionHandler: @escaping (Result) -> Void) { do { guard let dropboxClient = DropboxClientsManager.authorizedClient else { throw AuthenticationError.notAuthenticated } let path = try self.remotePath(filename: nil) dropboxClient.files.getMetadata(path: path).response(queue: self.responseQueue) { (metadata, error) in // Retrieved metadata successfully, which means folder exists, so no need to do anything else. guard let error = error else { return completionHandler(.success) } if case .routeError(let error, _, _, _) = error, case .path(.notFound) = error.unboxed { dropboxClient.files.createFolderV2(path: path).response(queue: self.responseQueue) { (result, error) in do { try self.process(Result(error)) completionHandler(.success) } catch { completionHandler(.failure(error)) } } } else { completionHandler(.failure(CallError(error))) } } } catch { completionHandler(.failure(error)) } } } extension DropboxService { func process(_ result: Result>) throws -> T { do { do { let value = try result.get() return value } catch let error { do { throw error } catch CallError.error(.authError(let authError, _, _, _)) { switch authError { case .invalidAccessToken: throw AuthenticationError.notAuthenticated case .expiredAccessToken: throw AuthenticationError.tokenExpired default: break } } catch CallError.error(.rateLimitError) { throw ServiceError.rateLimitExceeded } catch CallError.error(.clientError(let error as URLError)) { throw ServiceError.connectionFailed(error) } catch CallError.error(.routeError(let boxedError, _, _, _)) { switch boxedError.unboxed { case let error as Files.DownloadError: if case .path(.notFound) = error { throw ServiceError.itemDoesNotExist } if case .path(.restrictedContent) = error { throw ServiceError.restrictedContent } case let error as Files.UploadError: if case .path(let failure) = error { switch failure.reason { case .insufficientSpace: throw ServiceError.insufficentSpace default: break } } case let error as Files.GetMetadataError: if case .path(.notFound) = error { throw ServiceError.itemDoesNotExist } case let error as Files.DeleteError: if case .pathLookup(.notFound) = error { throw ServiceError.itemDoesNotExist } case let error as Files.ListRevisionsError: if case .path(.notFound) = error { throw ServiceError.itemDoesNotExist } default: break } } catch { // Ignore, just here to prevent propagating to outer do-catch. } // If we haven't re-thrown the error as a HarmonyError by now, throw it now. throw ServiceError(error) } } catch let error as HarmonyError { throw error } catch { assertionFailure("Non-HarmonyError thrown from DropboxService.process(_:)") throw error } } func validateMetadata(_ metadata: [HarmonyMetadataKey: T], completionHandler: @escaping (Result) -> Void) { let fields = metadata.keys.map { FileProperties.PropertyFieldTemplate(name: $0.rawValue, description_: $0.rawValue, type: .string_) } do { guard let dropboxClient = DropboxClientsManager.authorizedClient else { throw AuthenticationError.notAuthenticated } if let (templateID, propertyGroupTemplate) = self.propertyGroupTemplate { let existingFields = Set(propertyGroupTemplate.fields.map { $0.name }) let addedFields = fields.filter { !existingFields.contains($0.name) } guard !addedFields.isEmpty else { return completionHandler(.success(templateID)) } dropboxClient.file_properties.templatesUpdateForUser(templateId: templateID, name: nil, description_: nil, addFields: addedFields).response(queue: self.responseQueue) { (result, error) in do { let result = try self.process(Result(result, error)) let templateID = result.templateId self.fetchPropertyGroupTemplate(forTemplateID: templateID) { (result) in switch result { case .success: completionHandler(.success(templateID)) case .failure(let error): completionHandler(.failure(error)) } } } catch { completionHandler(.failure(error)) } } } else { dropboxClient.file_properties.templatesListForUser().response(queue: self.responseQueue) { (result, error) in do { let result = try self.process(Result(result, error)) if let templateID = result.templateIds.first { self.fetchPropertyGroupTemplate(forTemplateID: templateID) { (result) in switch result { case .success: self.validateMetadata(metadata, completionHandler: completionHandler) case .failure(let error): completionHandler(.failure(error)) } } } else { dropboxClient.file_properties.templatesAddForUser(name: "Harmony", description_: "Harmony syncing metadata.", fields: fields).response(queue: self.responseQueue) { (result, error) in do { let result = try self.process(Result(result, error)) let templateID = result.templateId self.fetchPropertyGroupTemplate(forTemplateID: templateID) { (result) in switch result { case .success: completionHandler(.success(templateID)) case .failure(let error): completionHandler(.failure(error)) } } } catch { completionHandler(.failure(error)) } } } } catch { completionHandler(.failure(error)) } } } } catch { completionHandler(.failure(error)) } } func fetchPropertyGroupTemplate(forTemplateID templateID: String, completionHandler: @escaping (Result) -> Void) { do { guard let dropboxClient = DropboxClientsManager.authorizedClient else { throw AuthenticationError.notAuthenticated } dropboxClient.file_properties.templatesGetForUser(templateId: templateID).response(queue: self.responseQueue) { (result, error) in do { let result = try self.process(Result(result, error)) self.propertyGroupTemplate = (templateID, result) completionHandler(.success(result)) } catch { completionHandler(.failure(error)) } } } catch { completionHandler(.failure(error)) } } func remotePath(filename: String?) throws -> String { guard let directoryName = self.preferredDirectoryName else { throw DropboxError.nilDirectoryName } var remotePath = "/" + directoryName if let filename = filename { remotePath += "/" + filename } return remotePath } }