From d05038edf2776ae7316583ca86643e1072dd19cd Mon Sep 17 00:00:00 2001 From: jahorton Date: Tue, 20 Aug 2019 10:08:17 +0700 Subject: [PATCH] Split the downloading management into own class --- .../KeymanEngine.xcodeproj/project.pbxproj | 4 + .../ResourceDownloadManager.swift | 574 +++--------------- .../ResourceDownloadQueue.swift | 411 +++++++++++++ 3 files changed, 506 insertions(+), 483 deletions(-) create mode 100644 ios/engine/KMEI/KeymanEngine/Classes/KeyboardRepository/ResourceDownloadQueue.swift diff --git a/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj b/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj index 52dc05267b..8efe42b228 100644 --- a/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj +++ b/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj @@ -215,6 +215,7 @@ CE17ABDE23069E76005FBB14 /* LanguageResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE17ABDD23069E76005FBB14 /* LanguageResource.swift */; }; CE1E1EC12303C8CC001C7BE0 /* ResourceDownloadStatusToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1E1EC02303C8CC001C7BE0 /* ResourceDownloadStatusToolbar.swift */; }; CE1F67A32304EB3800FF6972 /* ResourceDownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1F67A22304EB3800FF6972 /* ResourceDownloadManager.swift */; }; + CE22DFBA230B94DB00A4551C /* ResourceDownloadQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE22DFB9230B94DB00A4551C /* ResourceDownloadQueue.swift */; }; CE24ECF021B763740052D291 /* KeymanResponder+Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE24ECEF21B763740052D291 /* KeymanResponder+Types.swift */; }; CE24ECF121B763740052D291 /* KeymanResponder+Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE24ECEF21B763740052D291 /* KeymanResponder+Types.swift */; }; CE24ECF221B763740052D291 /* KeymanResponder+Types.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE24ECEF21B763740052D291 /* KeymanResponder+Types.swift */; }; @@ -420,6 +421,7 @@ CE17ABDD23069E76005FBB14 /* LanguageResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageResource.swift; sourceTree = ""; }; CE1E1EC02303C8CC001C7BE0 /* ResourceDownloadStatusToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceDownloadStatusToolbar.swift; sourceTree = ""; }; CE1F67A22304EB3800FF6972 /* ResourceDownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceDownloadManager.swift; sourceTree = ""; }; + CE22DFB9230B94DB00A4551C /* ResourceDownloadQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceDownloadQueue.swift; sourceTree = ""; }; CE24ECEF21B763740052D291 /* KeymanResponder+Types.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeymanResponder+Types.swift"; sourceTree = ""; }; CE2B1E4521B60E7C007D092E /* DeviceKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = DeviceKit.framework; path = ../../Carthage/Build/iOS/DeviceKit.framework; sourceTree = ""; }; CE67D960228A6F190029F2B5 /* KeyboardCommandStructs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardCommandStructs.swift; sourceTree = ""; }; @@ -724,6 +726,7 @@ C07A9D8D1FD1798900828ADD /* APIKeyboardRepository.swift */, 9A9CB0812241704800231FB9 /* APILexicalModelRepository.swift */, CE1F67A22304EB3800FF6972 /* ResourceDownloadManager.swift */, + CE22DFB9230B94DB00A4551C /* ResourceDownloadQueue.swift */, ); path = KeyboardRepository; sourceTree = ""; @@ -1286,6 +1289,7 @@ 9A079E40223B602B00581263 /* LexicalModel.swift in Sources */, C0E30C8C1FC40D0400C80416 /* Storage.swift in Sources */, C045148A1F85DF9100D88416 /* InputViewController.swift in Sources */, + CE22DFBA230B94DB00A4551C /* ResourceDownloadQueue.swift in Sources */, 9A9CB084224170F700231FB9 /* LexicalModelRepository.swift in Sources */, C06D37461F81F5C400F61AE0 /* PopoverView.swift in Sources */, 9A4609992242047400B0BFD1 /* LexicalModelAPICall.swift in Sources */, diff --git a/ios/engine/KMEI/KeymanEngine/Classes/KeyboardRepository/ResourceDownloadManager.swift b/ios/engine/KMEI/KeymanEngine/Classes/KeyboardRepository/ResourceDownloadManager.swift index 6aa6af177d..2e0aedd344 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/KeyboardRepository/ResourceDownloadManager.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/KeyboardRepository/ResourceDownloadManager.swift @@ -7,252 +7,25 @@ // import Foundation -import Reachability -private protocol DownloadNode { } - -private class DownloadTask: DownloadNode { - public enum Resource { - case keyboard, lexicalModel, other - } - - public final var type: Resource - public final var resources: [LanguageResource]? - public final var request: HTTPDownloadRequest - - public init(do request: HTTPDownloadRequest, for resources: [LanguageResource]?, type: DownloadTask.Resource) { - self.request = request - self.type = type - self.resources = resources - } -} - -/** - * Represents one overall resource-related command for requests against the Keyman Cloud API. - */ -private class DownloadBatch: DownloadNode { - public enum Activity { - case download, update, composite - } - - /* - * Three main cases so far: - * - [.download, .keyboard]: download new Keyboard (possibly with an associated lexical model) - * - [.download, .lexicalModel]: download new LexicalModel. - * - [.composite, .other]: batch update of all resources, containing the following within its compositeQueue - * - [.update, .keyboard]: updates one Keyboard - * - [.update, .lexicalModel]: updates one LexicalModel - * - Depending on needs, this may be extended to allow 'nested' .composite instances. - */ - public final var activity: Activity - public final var type: DownloadTask.Resource - - public final var tasks: [DownloadTask]? - public final var batchQueue: [DownloadBatch]? - //public final var promise: Int - - public init?(do tasks: [DownloadTask], as activity: Activity, ofType type: DownloadTask.Resource) { - self.activity = activity - self.type = type - self.tasks = tasks - - // Indicates 'composite' mode, which should use the other initializer. - if activity == .composite { - return nil - } - - // A batch containing tasks should always be targeting a 'primary' language resource. - // .other exists to handle fonts packaged with keyboards, not our primary resources. - if type == .other { - return nil - } - - self.batchQueue = nil - } - - public init(queue: [DownloadBatch]) { - self.activity = .composite - self.type = .other - self.batchQueue = queue - - self.tasks = nil - } -} - -private class DownloadQueueFrame { - public var nodes: [DownloadNode] = [] - public var index: Int = 0 - private(set) var isComposite = false - - public static func from(batch: DownloadBatch) { - let frame = DownloadQueueFrame() - - if batch.activity == .composite { - frame.isComposite = true - frame.nodes.append(contentsOf: batch.batchQueue!) - } else { - frame.nodes.append(contentsOf: batch.tasks!) - } - } -} - -public class ResourceDownloadManager: HTTPDownloadDelegate { - private var queueRoot: DownloadQueueFrame? - private var batchQueue: [DownloadQueueFrame] = [] - - private var downloadQueue: HTTPDownloader? = nil - var currentRequest: HTTPDownloadRequest? - private var reachability: Reachability! - private let keymanHostName = "api.keyman.com" - - private var updateKbdQueue: [InstallableKeyboard]? = nil - private var updateLexQueue: [InstallableLexicalModel]? = nil - private var _isDoneButtonEnabled = false +public class ResourceDownloadManager { + private var downloader: ResourceDownloadQueue private var isDidUpdateCheck = false public static let shared = ResourceDownloadManager() private init() { - //downloadQueue = HTTPDownloader(self) // TODO: Consider using a persistent one. - reachability = Reachability(hostname: keymanHostName) - } - - deinit { - // Just to be safe, we'll invalidate any pending download requests when this class is deinitialized. - if let currentRequest = currentRequest { - currentRequest.userInfo["completionBlock"] = nil - } - } - - // MARK: Update checks + management - - public func updatesAvailable() -> Bool { - if Manager.shared.apiKeyboardRepository.languages == nil && Manager.shared.apiLexicalModelRepository.languages == nil { - return false - } - - isDidUpdateCheck = true - let userKeyboards = Storage.active.userDefaults.userKeyboards - let hasKbdUpdate = userKeyboards?.contains { keyboard in - let kbID = keyboard.id - return stateForKeyboard(withID: kbID) == .needsUpdate - } ?? false - - let userLexicalModels = Storage.active.userDefaults.userLexicalModels - let hasLexUpdate = userLexicalModels?.contains { lexicalModel in - let lmID = lexicalModel.id - return stateForLexicalModel(withID: lmID) == .needsUpdate - } ?? false - - // FIXME: Testing only! Forces 'update'. - return true - //return hasKbdUpdate || hasLexUpdate - } - - // TODO: Not yet ready. - public func performUpdates() { - // The plan is to create new notifications to handle batch updates here, rather than - // require a UI to manage the update queue. - updateKeyboards() - updateLexicalModels() - } - - private func updateKeyboards() { - updateKbdQueue = [] - var kbIDs = Set() - - // Build the keyboard update queue - Storage.active.userDefaults.userKeyboards?.forEach { kb in - let kbState = stateForKeyboard(withID: kb.id) - if kbState == .needsUpdate { - if(!kbIDs.contains(kb.id)) { - kbIDs.insert(kb.id) - updateKbdQueue?.append(kb) - } - } - } - - // Execute the keyboard update queue - if !updateKbdQueue!.isEmpty { - let langID = updateKbdQueue![0].languageID - let kbID = updateKbdQueue![0].id - downloadKeyboard(withID: kbID, languageID: langID, isUpdate: true) - } - } - - private func updateLexicalModels() { - // Build the lexical model update queue - updateLexQueue = [] - var lmIDs = Set() - - Storage.active.userDefaults.userLexicalModels?.forEach { lm in - let lmState = stateForLexicalModel(withID: lm.id) - if lmState == .needsUpdate { - if !lmIDs.contains(lm.id) { - lmIDs.insert(lm.id) - updateLexQueue!.append(lm) - } - } - } - - // Execute the lexical model update queue - if !updateLexQueue!.isEmpty { - let langID = updateLexQueue![0].languageID - let lmID = updateLexQueue![0].id - downloadLexicalModel(withID: lmID, languageID: langID, isUpdate: true) - } + downloader = ResourceDownloadQueue() } // MARK: - Common functionality - private func checkCanExecute(_ batch: DownloadBatch) -> Bool { - guard reachability.connection != Reachability.Connection.none else { - let error = NSError(domain: "Keyman", code: 0, - userInfo: [NSLocalizedDescriptionKey: "No internet connection"]) - downloadFailed(forBatch: batch, error: error) - return false - } - - // At this stage, we now have everything needed to generate download requests. - guard downloadQueue == nil else { - let error = NSError(domain: "Keyman", code: 0, - userInfo: [NSLocalizedDescriptionKey: "Download queue is busy"]) - downloadFailed(forBatch: batch, error: error) - return false - } - - return true - } - - private func executeDownloadBatch(_ batch: DownloadBatch) { - // TODO: Handling composite batches. - downloadQueue = HTTPDownloader(self) - downloadQueue!.userInfo = [Key.downloadBatch: batch] - - batch.tasks?.forEach { task in - downloadQueue!.addRequest(task.request) - } - - //currentBatch = batch - queueRoot = DownloadQueueFrame() - queueRoot!.nodes.append(batch) - downloadQueue!.run() - } - - private func queueDownloadBatch(_ batch: DownloadBatch) { - if queueRoot == nil { - executeDownloadBatch(batch) - } else { - queueRoot?.nodes.append(batch) - } - } - private func fetchHandler(for resourceType: DownloadTask.Resource, _ completionHandler: @escaping () -> Void) -> (_ error: Error?) -> Void { return { error in if let error = error { // TODO: Connect to an error handler (or just render appropriate text) based on the resource type. - self.downloadFailed(forKeyboards: [], error: error) + self.downloader.downloadFailed(forKeyboards: [], error: error) } else { log.info("Fetched repository. Continuing with download.") completionHandler() @@ -269,7 +42,7 @@ public class ResourceDownloadManager: HTTPDownloadDelegate { let message = "Keyboard not found with id: \(keyboardID), languageID: \(languageID)" let error = NSError(domain: "Keyman", code: 0, userInfo: [NSLocalizedDescriptionKey: message]) - downloadFailed(forKeyboards: [], error: error) + downloader.downloadFailed(forKeyboards: [], error: error) return nil } @@ -289,11 +62,11 @@ public class ResourceDownloadManager: HTTPDownloadDelegate { // Perform common 'can download' check. We need positive reachability and no prior download queue. // The parameter facilitates error logging. - if !checkCanExecute(dlBatch) { + if !downloader.canExecute(dlBatch) { return nil } - queueDownloadBatch(dlBatch) + downloader.queue(dlBatch) return dlBatch } return nil @@ -359,7 +132,7 @@ public class ResourceDownloadManager: HTTPDownloadDelegate { } else { let message = "Keyboard repository not yet fetched" let error = NSError(domain: "Keyman", code: 0, userInfo: [NSLocalizedDescriptionKey: message]) - downloadFailed(forKeyboards: [], error: error) + downloader.downloadFailed(forKeyboards: [], error: error) return } } @@ -388,17 +161,17 @@ public class ResourceDownloadManager: HTTPDownloadDelegate { /// - Parameters: /// - url: URL to a JSON description of the keyboard public func downloadKeyboard(from url: URL) { - guard reachability.connection != Reachability.Connection.none else { + guard downloader.hasConnection() else { let error = NSError(domain: "Keyman", code: 0, userInfo: [NSLocalizedDescriptionKey: "No connection"]) - downloadFailed(forKeyboards: [], error: error) + downloader.downloadFailed(forKeyboards: [], error: error) return } guard let data = try? Data(contentsOf: url) else { let error = NSError(domain: "Keyman", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch JSON file"]) - downloadFailed(forKeyboards: [], error: error) + downloader.downloadFailed(forKeyboards: [], error: error) return } @@ -422,7 +195,7 @@ public class ResourceDownloadManager: HTTPDownloadDelegate { let keyboard = try decoder.decode(KeyboardAPICall.self, from: data) downloadKeyboard(keyboard) } catch { - downloadFailed(forKeyboards: [], error: error) + downloader.downloadFailed(forKeyboards: [], error: error) } } } @@ -447,7 +220,7 @@ public class ResourceDownloadManager: HTTPDownloadDelegate { public func stateForKeyboard(withID keyboardID: String) -> KeyboardState { // Needs validation - I don't think this if-condition can be met in Keyman's current state // (as of 2019-08-16) - if keyboardIdForCurrentRequest() == keyboardID { + if downloader.keyboardIdForCurrentRequest() == keyboardID { return .downloading } let userKeyboards = Storage.active.userDefaults.userKeyboards @@ -466,22 +239,6 @@ public class ResourceDownloadManager: HTTPDownloadDelegate { return .upToDate } - // Needs validation - I don't think this function is practically utilized (as of 2019-08-16) - func keyboardIdForCurrentRequest() -> String? { - if let currentRequest = currentRequest { - let tmpStr = currentRequest.url.lastPathComponent - if tmpStr.hasJavaScriptExtension { - return String(tmpStr.dropLast(3)) - } - } else if let downloadQueue = downloadQueue { - let kbInfo = downloadQueue.userInfo[Key.keyboardInfo] - if let keyboards = kbInfo as? [InstallableKeyboard], let keyboard = keyboards.first { - return keyboard.id - } - } - return nil - } - // MARK - Lexical models private func getInstallableLexicalModelMetadata(withID lexicalModelID: String, languageID: String) -> InstallableLexicalModel? { @@ -492,7 +249,7 @@ public class ResourceDownloadManager: HTTPDownloadDelegate { let error = NSError(domain: "Keyman", code: 0, userInfo: [NSLocalizedDescriptionKey: message]) // TODO: better error target. - downloadFailed(forLanguageID: "", error: error) + downloader.downloadFailed(forLanguageID: "", error: error) return nil } @@ -512,11 +269,11 @@ public class ResourceDownloadManager: HTTPDownloadDelegate { // Perform common 'can download' check. We need positive reachability and no prior download queue. // The parameter facilitates error logging. - if !checkCanExecute(dlBatch) { + if !downloader.canExecute(dlBatch) { return nil } - queueDownloadBatch(dlBatch) + downloader.queue(dlBatch) return dlBatch } return nil @@ -565,7 +322,7 @@ public class ResourceDownloadManager: HTTPDownloadDelegate { func listCompletionHandler(lexicalModels: [LexicalModel]?, error: Error?) -> Void { if let error = error { log.info("Failed to fetch lexical model list for "+languageID+". error: "+(error as! String)) - self.downloadFailed(forLanguageID: languageID, error: error) //???forKeyboards + downloader.downloadFailed(forLanguageID: languageID, error: error) } else if nil == lexicalModels { //TODO: put up an alert instead log.info("No lexical models available for language \(languageID) (nil)") @@ -616,7 +373,7 @@ public class ResourceDownloadManager: HTTPDownloadDelegate { } else { let message = "Keyboard repository not yet fetched" let error = NSError(domain: "Keyman", code: 0, userInfo: [NSLocalizedDescriptionKey: message]) - downloadFailed(forKeyboards: [], error: error) + downloader.downloadFailed(forKeyboards: [], error: error) return } } @@ -635,17 +392,17 @@ public class ResourceDownloadManager: HTTPDownloadDelegate { /// - Parameters: /// - url: URL to a JSON description of the lexical model public func downloadLexicalModel(from url: URL) { - guard reachability.connection != Reachability.Connection.none else { + guard downloader.hasConnection() else { let error = NSError(domain: "Keyman", code: 0, userInfo: [NSLocalizedDescriptionKey: "No connection"]) - downloadFailed(forKeyboards: [], error: error) //??? forLexicalModels + downloader.downloadFailed(forKeyboards: [], error: error) //??? forLexicalModels return } guard let data = try? Data(contentsOf: url) else { let error = NSError(domain: "Keyman", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to fetch JSON file"]) - downloadFailed(forKeyboards: [], error: error) //??? forLexicalModels + downloader.downloadFailed(forKeyboards: [], error: error) //??? forLexicalModels return } @@ -669,7 +426,7 @@ public class ResourceDownloadManager: HTTPDownloadDelegate { let lexicalModel = try decoder.decode(LexicalModelAPICall.self, from: data) downloadLexicalModel(lexicalModel) } catch { - downloadFailed(forKeyboards: [], error: error) //??? forLexicalModels + downloader.downloadFailed(forKeyboards: [], error: error) //??? forLexicalModels } } } @@ -696,18 +453,18 @@ public class ResourceDownloadManager: HTTPDownloadDelegate { let isUpdate = Storage.active.userDefaults.userLexicalModels?.contains { $0.id == lexicalModel.id } ?? false if let batch = buildLexicalModelDownloadBatch(for: installableLexicalModels[0], fromPath: lexicalModelURL, asActivity: isUpdate ? .update : .download) { - if !checkCanExecute(batch) { + if !downloader.canExecute(batch) { return } - queueDownloadBatch(batch) + downloader.queue(batch) } } /// - Returns: The current state for a lexical model //TODO: rename KeyboardState to ResourceState? so it can be used with both keybaoards and lexical models without confusion public func stateForLexicalModel(withID lexicalModelID: String) -> KeyboardState { - if lexicalModelIdForCurrentRequest() == lexicalModelID { + if downloader.lexicalModelIdForCurrentRequest() == lexicalModelID { return .downloading } let userLexicalModels = Storage.active.userDefaults.userLexicalModels @@ -726,233 +483,84 @@ public class ResourceDownloadManager: HTTPDownloadDelegate { return .upToDate } - func lexicalModelIdForCurrentRequest() -> String? { - if let currentRequest = currentRequest { - let tmpStr = currentRequest.url.lastPathComponent - if tmpStr.hasJavaScriptExtension { - return String(tmpStr.dropLast(3)) - } - } else if let downloadQueue = downloadQueue { - let kbInfo = downloadQueue.userInfo[Key.lexicalModelInfo] - if let lexicalModels = kbInfo as? [InstallableLexicalModel], let lexicalModel = lexicalModels.first { - return lexicalModel.id - } - } - return nil - } + // MARK: Update checks + management - // Processes fetched lexical models. - // return a lexical model so caller can use it in a downloadSucceeded call - // is called by other class funcs - func installLexicalModelPackage(downloadedPackageFile: URL) -> InstallableLexicalModel? { - var installedLexicalModel: InstallableLexicalModel? = nil - let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - var destination = documentsDirectory - destination.appendPathComponent("temp/\(downloadedPackageFile.lastPathComponent)") + public func updatesAvailable() -> Bool { + if Manager.shared.apiKeyboardRepository.languages == nil && Manager.shared.apiLexicalModelRepository.languages == nil { + return false + } + + isDidUpdateCheck = true + let userKeyboards = Storage.active.userDefaults.userKeyboards + let hasKbdUpdate = userKeyboards?.contains { keyboard in + let kbID = keyboard.id + return stateForKeyboard(withID: kbID) == .needsUpdate + } ?? false - KeymanPackage.extract(fileUrl: downloadedPackageFile, destination: destination, complete: { kmp in - if let kmp = kmp as! LexicalModelKeymanPackage? { - do { - try Manager.shared.parseLMKMP(kmp.sourceFolder) - log.info("successfully parsed the lexical model in: \(kmp.sourceFolder)") - installedLexicalModel = kmp.models[0].installableLexicalModels[0] - //this can fail gracefully and not show errors to users - try FileManager.default.removeItem(at: downloadedPackageFile) - } catch { - log.error("Error installing the lexical model: \(error)") - } - } else { - log.error("Error extracting the lexical model from the package: \(KMPError.invalidPackage)") - } - }) - return installedLexicalModel - } - - - - // MARK - deprecated helper/handler methods - they help avoid errors for now. - - private func downloadFailed(forBatch batch: DownloadBatch, error: Error) { - if batch.activity == .composite { - // It's an update operation. - // TODO: Needs implementation. - } else if batch.type == .keyboard { - let keyboards = batch.tasks?.compactMap { task in - return task.resources as? [InstallableKeyboard] - }.flatMap {$0} - downloadFailed(forKeyboards: keyboards ?? [], error: error) - } else if batch.type == .lexicalModel { - let lexModels = batch.tasks?.compactMap { task in - return task.resources as? [InstallableLexicalModel] - }.flatMap {$0} - downloadFailed(forLanguageID: lexModels![0].languageID, error: error) - } - } - - private func downloadFailed(forKeyboards keyboards: [InstallableKeyboard], error: Error) { - let notification = KeyboardDownloadFailedNotification(keyboards: keyboards, error: error) - NotificationCenter.default.post(name: Notifications.keyboardDownloadFailed, - object: self, - value: notification) - } - - private func downloadFailed(forLanguageID languageID: String, error: Error) { - let notification = LexicalModelDownloadFailedNotification(lmOrLanguageID: languageID, error: error) - NotificationCenter.default.post(name: Notifications.lexicalModelDownloadFailed, - object: self, - value: notification) - } - - private func downloadFailed(forLexicalModelPackage packageURL: String, error: Error) { - let notification = LexicalModelDownloadFailedNotification(lmOrLanguageID: packageURL, error: error) - NotificationCenter.default.post(name: Notifications.lexicalModelDownloadFailed, - object: self, - value: notification) - } - - private func downloadSucceeded(forLexicalModel lm: InstallableLexicalModel) { - let notification = LexicalModelDownloadCompletedNotification([lm]) - NotificationCenter.default.post(name: Notifications.lexicalModelDownloadCompleted, - object: self, - value: notification) - } - - //MARK: - HTTPDownloadDelegate methods - - func downloadQueueFinished(_ queue: HTTPDownloader) { - // We can use the properties of the current "batch" to generate specialized notifications. - queueRoot = nil - } - - func downloadRequestStarted(_ request: HTTPDownloadRequest) { - // If we're downloading a new keyboard. - // The extra check is there to filter out other potential request types in the future. - if request.tag == 0 { - let task = request.userInfo[Key.downloadTask] as! DownloadTask - if task.type == .keyboard { - NotificationCenter.default.post(name: Notifications.keyboardDownloadStarted, - object: self, - value: task.resources as! [InstallableKeyboard]) - } else if task.type == .lexicalModel { - NotificationCenter.default.post(name: Notifications.lexicalModelDownloadStarted, - object: self, - value: task.resources as! [InstallableLexicalModel]) - } - } - } - - func downloadRequestFinished(_ request: HTTPDownloadRequest) { - let batch = request.userInfo[Key.downloadBatch] as! DownloadBatch - let isUpdate = batch.activity == .update + let userLexicalModels = Storage.active.userDefaults.userLexicalModels + let hasLexUpdate = userLexicalModels?.contains { lexicalModel in + let lmID = lexicalModel.id + return stateForLexicalModel(withID: lmID) == .needsUpdate + } ?? false - let task = request.userInfo[Key.downloadTask] as! DownloadTask + // FIXME: Testing only! Forces 'update'. + return true + //return hasKbdUpdate || hasLexUpdate + } + + // TODO: Not yet ready. + public func performUpdates() { + // The plan is to create new notifications to handle batch updates here, rather than + // require a UI to manage the update queue. + updateKeyboards() + updateLexicalModels() + } + + private func updateKeyboards() { + var updateKbdQueue: [InstallableKeyboard]? = [] + var kbIDs = Set() - // FIXME - if let statusCode = request.responseStatusCode, statusCode == 200 { - if task.type == .keyboard { - // The request has succeeded. - if downloadQueue!.requestsCount == 0 { - let keyboards = task.resources as? [InstallableKeyboard] - - // Download queue finished. - downloadQueue = nil - FontManager.shared.registerCustomFonts() - log.info("Downloaded keyboard: \(keyboards![0].id).") - - NotificationCenter.default.post(name: Notifications.keyboardDownloadCompleted, - object: self, - value: keyboards!) - // TODO: Trigger by notification. Needs to be done on Manager.swift, not this class. -// if isUpdate { -// shouldReloadKeyboard = true -// inputViewController.reload() -// } - let userDefaults = Storage.active.userDefaults - userDefaults.set([Date()], forKey: Key.synchronizeSWKeyboard) - userDefaults.synchronize() - } - } else if task.type == .lexicalModel { - if let lm = installLexicalModelPackage(downloadedPackageFile: URL.init(string: task.request.destinationFile!)!) { - downloadSucceeded(forLexicalModel: lm) - } else { - let installError = NSError(domain: "Keyman", code: 0, - userInfo: [NSLocalizedDescriptionKey: "installError"]) - downloadFailed(forLexicalModelPackage: "\(task.request.url)", error: installError ) - } - - // Temp - gotta clear the queue to match original behavior for now. - if downloadQueue!.requestsCount == 0 { - downloadQueue = nil + // Build the keyboard update queue + Storage.active.userDefaults.userKeyboards?.forEach { kb in + let kbState = stateForKeyboard(withID: kb.id) + if kbState == .needsUpdate { + if(!kbIDs.contains(kb.id)) { + kbIDs.insert(kb.id) + updateKbdQueue?.append(kb) } } - } else { // Possible request error (400 Bad Request, 404 Not Found, etc.) - downloadQueue!.cancelAllOperations() - downloadQueue = nil + } - let errorMessage = "\(request.responseStatusMessage ?? ""): \(request.url)" - let error = NSError(domain: "Keyman", code: 0, - userInfo: [NSLocalizedDescriptionKey: errorMessage]) - - if task.type == .keyboard { - let keyboards = task.resources as? [InstallableKeyboard] - log.error("Keyboard download failed: \(error).") + // Execute the keyboard update queue + if !updateKbdQueue!.isEmpty { + let langID = updateKbdQueue![0].languageID + let kbID = updateKbdQueue![0].id + downloadKeyboard(withID: kbID, languageID: langID, isUpdate: true) + } + } - if !isUpdate { - // Clean up keyboard file if anything fails - // TODO: Also clean up remaining fonts - try? FileManager.default.removeItem(at: Storage.active.keyboardURL(for: keyboards![0])) + private func updateLexicalModels() { + // Build the lexical model update queue + var updateLexQueue: [InstallableLexicalModel]? = [] + var lmIDs = Set() + + Storage.active.userDefaults.userLexicalModels?.forEach { lm in + let lmState = stateForLexicalModel(withID: lm.id) + if lmState == .needsUpdate { + if !lmIDs.contains(lm.id) { + lmIDs.insert(lm.id) + updateLexQueue!.append(lm) } - - downloadFailed(forKeyboards: keyboards ?? [], error: error) - } else if task.type == .lexicalModel { - let lexicalModels = task.resources as? [InstallableLexicalModel] - log.error("Dictionary download failed: \(error).") - - if !isUpdate { - // Clean up keyboard file if anything fails - // TODO: Also clean up remaining fonts - try? FileManager.default.removeItem(at: Storage.active.lexicalModelURL(for: lexicalModels![0])) - } - - downloadFailed(forLanguageID: lexicalModels?[0].languageID ?? "", error: error) } } + + // Execute the lexical model update queue + if !updateLexQueue!.isEmpty { + let langID = updateLexQueue![0].languageID + let lmID = updateLexQueue![0].id + downloadLexicalModel(withID: lmID, languageID: langID, isUpdate: true) + } } - //func downloadRequestSuccess( - func downloadRequestFailed(_ request: HTTPDownloadRequest) { - switch request.typeCode { - case .downloadFile: - downloadQueue = nil - let error = request.error! - - let task = request.userInfo[Key.downloadTask] as! DownloadTask - let batch = request.userInfo[Key.downloadBatch] as! DownloadBatch - let isUpdate = batch.activity == .update - - if task.type == .keyboard { - log.error("Keyboard download failed: \(error).") - let keyboards = task.resources as? [InstallableKeyboard] - - if !isUpdate { - // Clean up keyboard file if anything fails - // TODO: Also clean up remaining fonts - try? FileManager.default.removeItem(at: Storage.active.keyboardURL(for: keyboards![0])) - } - - downloadFailed(forKeyboards: keyboards ?? [], error: error as NSError) - } else if task.type == .lexicalModel { - log.error("Dictionary download failed: \(error).") - let lexicalModels = task.resources as? [InstallableLexicalModel] - - if !isUpdate { - try? FileManager.default.removeItem(at: Storage.active.lexicalModelURL(for: lexicalModels![0])) - } - - downloadFailed(forLanguageID: lexicalModels?[0].languageID ?? "", error: error as NSError) - } - } - } - // End REWORK THIS SECTION ------ } diff --git a/ios/engine/KMEI/KeymanEngine/Classes/KeyboardRepository/ResourceDownloadQueue.swift b/ios/engine/KMEI/KeymanEngine/Classes/KeyboardRepository/ResourceDownloadQueue.swift new file mode 100644 index 0000000000..46fb8226fc --- /dev/null +++ b/ios/engine/KMEI/KeymanEngine/Classes/KeyboardRepository/ResourceDownloadQueue.swift @@ -0,0 +1,411 @@ +// +// ResourceDownloadQueue.swift +// KeymanEngine +// +// Created by Joshua Horton on 8/20/19. +// Copyright © 2019 SIL International. All rights reserved. +// + +import Foundation +import Reachability + +private protocol DownloadNode { } + +class DownloadTask: DownloadNode { + public enum Resource { + case keyboard, lexicalModel, other + } + + public final var type: Resource + public final var resources: [LanguageResource]? + public final var request: HTTPDownloadRequest + + public init(do request: HTTPDownloadRequest, for resources: [LanguageResource]?, type: DownloadTask.Resource) { + self.request = request + self.type = type + self.resources = resources + } +} + +/** + * Represents one overall resource-related command for requests against the Keyman Cloud API. + */ +class DownloadBatch: DownloadNode { + public enum Activity { + case download, update, composite + } + + /* + * Three main cases so far: + * - [.download, .keyboard]: download new Keyboard (possibly with an associated lexical model) + * - [.download, .lexicalModel]: download new LexicalModel. + * - [.composite, .other]: batch update of all resources, containing the following within its compositeQueue + * - [.update, .keyboard]: updates one Keyboard + * - [.update, .lexicalModel]: updates one LexicalModel + * - Depending on needs, this may be extended to allow 'nested' .composite instances. + */ + public final var activity: Activity + public final var type: DownloadTask.Resource + + public final var tasks: [DownloadTask]? + public final var batchQueue: [DownloadBatch]? + //public final var promise: Int + + public init?(do tasks: [DownloadTask], as activity: Activity, ofType type: DownloadTask.Resource) { + self.activity = activity + self.type = type + self.tasks = tasks + + // Indicates 'composite' mode, which should use the other initializer. + if activity == .composite { + return nil + } + + // A batch containing tasks should always be targeting a 'primary' language resource. + // .other exists to handle fonts packaged with keyboards, not our primary resources. + if type == .other { + return nil + } + + self.batchQueue = nil + } + + public init(queue: [DownloadBatch]) { + self.activity = .composite + self.type = .other + self.batchQueue = queue + + self.tasks = nil + } +} + +private class DownloadQueueFrame { + public var nodes: [DownloadNode] = [] + public var index: Int = 0 + private(set) var isComposite = false + + public static func from(batch: DownloadBatch) { + let frame = DownloadQueueFrame() + + if batch.activity == .composite { + frame.isComposite = true + frame.nodes.append(contentsOf: batch.batchQueue!) + } else { + frame.nodes.append(contentsOf: batch.tasks!) + } + } +} + +class ResourceDownloadQueue: HTTPDownloadDelegate { + private var queueRoot: DownloadQueueFrame? + private var batchQueue: [DownloadQueueFrame] = [] + + private var downloader: HTTPDownloader? = nil + private var reachability: Reachability! + private let keymanHostName = "api.keyman.com" + + public init() { + reachability = Reachability(hostname: keymanHostName) + } + + public func hasConnection() -> Bool { + return reachability.connection != Reachability.Connection.none + } + + // Might should add a "withNotification: Bool" option for clarity. + public func canExecute(_ batch: DownloadBatch) -> Bool { + guard reachability.connection != Reachability.Connection.none else { + let error = NSError(domain: "Keyman", code: 0, + userInfo: [NSLocalizedDescriptionKey: "No internet connection"]) + downloadFailed(forBatch: batch, error: error) + return false + } + + // At this stage, we now have everything needed to generate download requests. + guard downloader == nil else { + let error = NSError(domain: "Keyman", code: 0, + userInfo: [NSLocalizedDescriptionKey: "Download queue is busy"]) + downloadFailed(forBatch: batch, error: error) + return false + } + + return true + } + + private func execute(_ batch: DownloadBatch) { + // TODO: Handling composite batches. + downloader = HTTPDownloader(self) + downloader!.userInfo = [Key.downloadBatch: batch] + + batch.tasks?.forEach { task in + downloader!.addRequest(task.request) + } + + //currentBatch = batch + queueRoot = DownloadQueueFrame() + queueRoot!.nodes.append(batch) + downloader!.run() + } + + public func queue(_ batch: DownloadBatch) { + if queueRoot == nil { + execute(batch) + } else { + queueRoot?.nodes.append(batch) + } + } + + // MARK - helper methods for ResourceDownloadManager + + var currentRequest: HTTPDownloadRequest? { + get { + // This was literally the state of this property when this refactor was performed. + return nil; + } + } + + // Needs validation - I don't think this function is practically utilized (as of 2019-08-16) + func keyboardIdForCurrentRequest() -> String? { + if let currentRequest = currentRequest { + let tmpStr = currentRequest.url.lastPathComponent + if tmpStr.hasJavaScriptExtension { + return String(tmpStr.dropLast(3)) + } + } else if let downloadQueue = downloader { + let kbInfo = downloadQueue.userInfo[Key.keyboardInfo] + if let keyboards = kbInfo as? [InstallableKeyboard], let keyboard = keyboards.first { + return keyboard.id + } + } + return nil + } + + func lexicalModelIdForCurrentRequest() -> String? { + if let currentRequest = currentRequest { + let tmpStr = currentRequest.url.lastPathComponent + if tmpStr.hasJavaScriptExtension { + return String(tmpStr.dropLast(3)) + } + } else if let downloadQueue = downloader { + let kbInfo = downloadQueue.userInfo[Key.lexicalModelInfo] + if let lexicalModels = kbInfo as? [InstallableLexicalModel], let lexicalModel = lexicalModels.first { + return lexicalModel.id + } + } + return nil + } + + // MARK - notification methods + + private func downloadFailed(forBatch batch: DownloadBatch, error: Error) { + if batch.activity == .composite { + // It's an update operation. + // TODO: Needs implementation. + } else if batch.type == .keyboard { + let keyboards = batch.tasks?.compactMap { task in + return task.resources as? [InstallableKeyboard] + }.flatMap {$0} + downloadFailed(forKeyboards: keyboards ?? [], error: error) + } else if batch.type == .lexicalModel { + let lexModels = batch.tasks?.compactMap { task in + return task.resources as? [InstallableLexicalModel] + }.flatMap {$0} + downloadFailed(forLanguageID: lexModels![0].languageID, error: error) + } + } + + public func downloadFailed(forKeyboards keyboards: [InstallableKeyboard], error: Error) { + let notification = KeyboardDownloadFailedNotification(keyboards: keyboards, error: error) + NotificationCenter.default.post(name: Notifications.keyboardDownloadFailed, + object: self, + value: notification) + } + + public func downloadFailed(forLanguageID languageID: String, error: Error) { + let notification = LexicalModelDownloadFailedNotification(lmOrLanguageID: languageID, error: error) + NotificationCenter.default.post(name: Notifications.lexicalModelDownloadFailed, + object: self, + value: notification) + } + + public func downloadFailed(forLexicalModelPackage packageURL: String, error: Error) { + let notification = LexicalModelDownloadFailedNotification(lmOrLanguageID: packageURL, error: error) + NotificationCenter.default.post(name: Notifications.lexicalModelDownloadFailed, + object: self, + value: notification) + } + + public func downloadSucceeded(forLexicalModel lm: InstallableLexicalModel) { + let notification = LexicalModelDownloadCompletedNotification([lm]) + NotificationCenter.default.post(name: Notifications.lexicalModelDownloadCompleted, + object: self, + value: notification) + } + + //MARK: - HTTPDownloadDelegate methods + + func downloadQueueFinished(_ queue: HTTPDownloader) { + // We can use the properties of the current "batch" to generate specialized notifications. + queueRoot = nil + } + + func downloadRequestStarted(_ request: HTTPDownloadRequest) { + // If we're downloading a new keyboard. + // The extra check is there to filter out other potential request types in the future. + if request.tag == 0 { + let task = request.userInfo[Key.downloadTask] as! DownloadTask + if task.type == .keyboard { + NotificationCenter.default.post(name: Notifications.keyboardDownloadStarted, + object: self, + value: task.resources as! [InstallableKeyboard]) + } else if task.type == .lexicalModel { + NotificationCenter.default.post(name: Notifications.lexicalModelDownloadStarted, + object: self, + value: task.resources as! [InstallableLexicalModel]) + } + } + } + + func downloadRequestFinished(_ request: HTTPDownloadRequest) { + let batch = request.userInfo[Key.downloadBatch] as! DownloadBatch + let isUpdate = batch.activity == .update + + let task = request.userInfo[Key.downloadTask] as! DownloadTask + + // FIXME + if let statusCode = request.responseStatusCode, statusCode == 200 { + if task.type == .keyboard { + // The request has succeeded. + if downloader!.requestsCount == 0 { + let keyboards = task.resources as? [InstallableKeyboard] + + // Download queue finished. + downloader = nil + FontManager.shared.registerCustomFonts() + log.info("Downloaded keyboard: \(keyboards![0].id).") + + NotificationCenter.default.post(name: Notifications.keyboardDownloadCompleted, + object: self, + value: keyboards!) + // TODO: Trigger by notification. Needs to be done on Manager.swift, not this class. +// if isUpdate { +// shouldReloadKeyboard = true +// inputViewController.reload() +// } + let userDefaults = Storage.active.userDefaults + userDefaults.set([Date()], forKey: Key.synchronizeSWKeyboard) + userDefaults.synchronize() + } + } else if task.type == .lexicalModel { + if let lm = installLexicalModelPackage(downloadedPackageFile: URL.init(string: task.request.destinationFile!)!) { + downloadSucceeded(forLexicalModel: lm) + } else { + let installError = NSError(domain: "Keyman", code: 0, + userInfo: [NSLocalizedDescriptionKey: "installError"]) + downloadFailed(forLexicalModelPackage: "\(task.request.url)", error: installError ) + } + + // Temp - gotta clear the queue to match original behavior for now. + if downloader!.requestsCount == 0 { + downloader = nil + } + } + } else { // Possible request error (400 Bad Request, 404 Not Found, etc.) + downloader!.cancelAllOperations() + downloader = nil + + let errorMessage = "\(request.responseStatusMessage ?? ""): \(request.url)" + let error = NSError(domain: "Keyman", code: 0, + userInfo: [NSLocalizedDescriptionKey: errorMessage]) + + if task.type == .keyboard { + let keyboards = task.resources as? [InstallableKeyboard] + log.error("Keyboard download failed: \(error).") + + if !isUpdate { + // Clean up keyboard file if anything fails + // TODO: Also clean up remaining fonts + try? FileManager.default.removeItem(at: Storage.active.keyboardURL(for: keyboards![0])) + } + + downloadFailed(forKeyboards: keyboards ?? [], error: error) + } else if task.type == .lexicalModel { + let lexicalModels = task.resources as? [InstallableLexicalModel] + log.error("Dictionary download failed: \(error).") + + if !isUpdate { + // Clean up keyboard file if anything fails + // TODO: Also clean up remaining fonts + try? FileManager.default.removeItem(at: Storage.active.lexicalModelURL(for: lexicalModels![0])) + } + + downloadFailed(forLanguageID: lexicalModels?[0].languageID ?? "", error: error) + } + } + } + + //func downloadRequestSuccess( + + func downloadRequestFailed(_ request: HTTPDownloadRequest) { + switch request.typeCode { + case .downloadFile: + downloader = nil + let error = request.error! + + let task = request.userInfo[Key.downloadTask] as! DownloadTask + let batch = request.userInfo[Key.downloadBatch] as! DownloadBatch + let isUpdate = batch.activity == .update + + if task.type == .keyboard { + log.error("Keyboard download failed: \(error).") + let keyboards = task.resources as? [InstallableKeyboard] + + if !isUpdate { + // Clean up keyboard file if anything fails + // TODO: Also clean up remaining fonts + try? FileManager.default.removeItem(at: Storage.active.keyboardURL(for: keyboards![0])) + } + + downloadFailed(forKeyboards: keyboards ?? [], error: error as NSError) + } else if task.type == .lexicalModel { + log.error("Dictionary download failed: \(error).") + let lexicalModels = task.resources as? [InstallableLexicalModel] + + if !isUpdate { + try? FileManager.default.removeItem(at: Storage.active.lexicalModelURL(for: lexicalModels![0])) + } + + downloadFailed(forLanguageID: lexicalModels?[0].languageID ?? "", error: error as NSError) + } + } + } + + // MARK - Language resource installation methods + + // Processes fetched lexical models. + // return a lexical model so caller can use it in a downloadSucceeded call + // is called by other class funcs + func installLexicalModelPackage(downloadedPackageFile: URL) -> InstallableLexicalModel? { + var installedLexicalModel: InstallableLexicalModel? = nil + let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + var destination = documentsDirectory + destination.appendPathComponent("temp/\(downloadedPackageFile.lastPathComponent)") + + KeymanPackage.extract(fileUrl: downloadedPackageFile, destination: destination, complete: { kmp in + if let kmp = kmp as! LexicalModelKeymanPackage? { + do { + try Manager.shared.parseLMKMP(kmp.sourceFolder) + log.info("successfully parsed the lexical model in: \(kmp.sourceFolder)") + installedLexicalModel = kmp.models[0].installableLexicalModels[0] + //this can fail gracefully and not show errors to users + try FileManager.default.removeItem(at: downloadedPackageFile) + } catch { + log.error("Error installing the lexical model: \(error)") + } + } else { + log.error("Error extracting the lexical model from the package: \(KMPError.invalidPackage)") + } + }) + return installedLexicalModel + } +}