// // AssociatingPackageInstaller.swift // KeymanEngine // // Created by Joshua Horton on 8/5/20. // Copyright © 2020 SIL International. All rights reserved. // import Foundation import UIKit /** * Given a `KeymanPackage` that KeymanEngine knows how to install, this class facilitates language selection as part of the * package installation process. * * "Associations" may also be specified to look up 'associated' resources for the selected language codes and have them install alongside * the base package. For example, installing the `sil_euro_latin` keyboard for `en` (English) with the `.lexicalModels` association * will query for `en` models and install them as well as the `sil_euro_latin` package. * * At the time of writing, this will return the `nrc.en.mtnt` model and will install it for use with `en` * before indicating that overall package installation is complete. */ public class AssociatingPackageInstaller> where Resource.Package == Package { // The second parameter should be used to report on installation success on a per-package basis. public typealias AssociationInstaller = (LanguagePickAssociator.AssociationMap, @escaping (KeymanPackage.Key, PackageInstallResult) -> Void) -> Void public typealias AssociatorBuilder = (AssociatingPackageInstaller, LanguagePickAssociator.AssociationReceiver) -> LanguagePickAssociator /** * Defines behaviors for finding and installing resources associated with the set of languages selected for installation. */ public enum Associator { /** * Facilitates installing lexical models for the languages being installed. */ case lexicalModels /** * Designed to allow framework users to specify their own associations if desired. */ case custom(AssociatorBuilder, AssociationInstaller) internal func instance(for installer: AssociatingPackageInstaller, withSession session: URLSession, withReceiver receiver: @escaping LanguagePickAssociator.AssociationReceiver) -> LanguagePickAssociator { switch self { case .lexicalModels: return LanguagePickAssociator(searchWith: LanguagePickAssociator.constructLexicalModelSearcher(session: session), progressClosure: receiver) case .custom(let constructor, _): return constructor(installer, receiver) } } internal func installer(downloadManager: ResourceDownloadManager) -> AssociationInstaller { switch self { case .lexicalModels: return { associatedLexicalModelMap, completionClosure in associatedLexicalModelMap.forEach { packageKey, association in let url = association.url let lgCodes = association.languageCodes downloadManager.downloadPackage(withKey: packageKey, from: url) { (package: LexicalModelKeymanPackage?, error: Error?) in guard error == nil, let package = package else { completionClosure(packageKey, .error(error ?? KeymanError.unknown)) return } let associatedResources = lgCodes.flatMap { package.installableResources(forLanguage: $0) } as! [InstallableLexicalModel] let associatedResourceIDs = associatedResources.map { $0.typedFullID } do { try ResourceFileManager.shared.install(resourcesWithIDs: associatedResourceIDs, from: package) completionClosure(packageKey, .complete) } catch { completionClosure(packageKey, .error(error)) } } } } case .custom(_, let closure): return closure } } } public enum PackageInstallResult { case error(Error) case complete } public enum Progress { case cancelled case starting // signals that language picks are finalized, so progress reports will now begin. case inProgress case complete } public typealias ProgressReceiver = (Progress) -> Void typealias AssociationInstallMap = [KeymanPackage.Key: PackageInstallResult] private class ClosureShared { // Synchronization let queryGroup = DispatchGroup() let installGroup = DispatchGroup() let progressQueue = DispatchQueue(label: "com.keyman.associatingPackageInstaller") // Specifications needed across multiple threads. let associationSpecs: [Associator] var associationInstallers: [Int: AssociationInstaller] = [:] // Holds data needed for progress tracking. var pickingCompleted: Bool = false var associationQueryProgress: [Int: LanguagePickAssociator.Progress] = [:] var installProgressMap: [KeymanPackage.Key: PackageInstallResult?] = [:] let externalProgressCallback: ProgressReceiver var promptProgressCallback: ProgressReceiver? let downloadManager: ResourceDownloadManager var isCancelled = false init(with associationSpecs: [Associator], downloadManager: ResourceDownloadManager, receiver: @escaping ProgressReceiver) { self.associationSpecs = associationSpecs self.downloadManager = downloadManager self.externalProgressCallback = receiver } // Since progress info is stored here, it makes the most sense to track progress-related // methods here too. internal var reportsProgress: Bool { return !isCancelled && pickingCompleted } internal func notifyProgress(_ status: Progress) { // Prompt gets first dibs - that way, if a prompt exists, its UI code // executes before control transfers back to other modules. self.promptProgressCallback?(status) self.externalProgressCallback(status) } /** * Computes the initial level of progress made toward the overall installation at the time that language selections were * finalized. */ internal func initializeProgress() { notifyProgress(.starting) } /** * Generates data useful for reporting about the installation's overall progress. */ internal func reportProgress(complete: Bool = false) { if reportsProgress { if complete { notifyProgress(.complete) } else { //progressCallback(.inProgress) } } } internal func reportCancelled() { notifyProgress(.cancelled) } } private let closureShared: ClosureShared let package: Package let defaultLgCode: String? var associationQueriers: [LanguagePickAssociator]? /** * Constructs an instance for installing the specified package alongside any detected packages containing specified types * of associated resources for the languages that are selected for installation. * * - parameters: * - package: An already-download package to install. Any language selection must choose languages supported by this package. * - defaultLanguageCode: The BCP-47 code for the default language to select when prompting the user for languages. * - withAssociators: A set of desired "language associations" to install alongside the package if they exist. * Currently supports the following options: * - `.lexicalModels`: intended for use with keyboard packages. Will query for and install lexical models for any * picked languages as part of the installation process. * - `.custom`: allows specifying custom language associations. * - progressReceiver: This will _always_ be called, even should picking never start before the class is deinitialized. * It will either receive `.cancelled` (exactly once) or a sequence like the following: * - `.starting` (exactly once) * - `.complete` (exactly once) * * In the future, `.inProgress` may be implemented; at that time, it will provide data for reporting installation progress and * may be called multiple times between `.starting` and `.complete`. */ public convenience init(for package: Package, defaultLanguageCode: String? = nil, withAssociators associators: [Associator] = [], progressReceiver: @escaping ProgressReceiver) { self.init(for: package, defaultLanguageCode: defaultLanguageCode, downloadManager: ResourceDownloadManager.shared, withAssociators: associators, progressReceiver: progressReceiver) } internal init(for package: Package, defaultLanguageCode: String? = nil, downloadManager: ResourceDownloadManager = ResourceDownloadManager.shared, withAssociators associators: [Associator] = [], progressReceiver: @escaping ProgressReceiver) { self.package = package if let defaultLanguageCode = defaultLanguageCode { if package.languages.contains(where: { $0.id == defaultLanguageCode }) { self.defaultLgCode = defaultLanguageCode } else if let match = package.languages.first(where: { $0.id.hasPrefix(defaultLanguageCode) }) { // The package specifies a more precise ID self.defaultLgCode = match.id } else if let match = package.languages.first(where: { defaultLanguageCode.hasPrefix($0.id) }) { // The provided default language code is more precise than the id found in the package self.defaultLgCode = match.id } else { self.defaultLgCode = nil } } else { self.defaultLgCode = nil } self.closureShared = ClosureShared(with: associators, downloadManager: downloadManager, receiver: progressReceiver) } deinit { // Ensure we send a 'cancel' signal IF this is a 'constructive cancellation'. // That is, if no installs were triggered... even if picking never even started. if !closureShared.pickingCompleted, self.associationQueriers != nil, !closureShared.isCancelled { // Auto-dismiss any association queriers that might still be executing. // Necessary for their closures to exit their DispatchGroups cleanly. self.associationQueriers = nil // Now we may safely send an artificial 'cancel'. let installClosure = coreInstallationClosure() installClosure(nil) } else if self.associationQueriers == nil { // We never started the associators or a picker, so bypass all of that and directly // report cancellation. closureShared.reportCancelled() } } internal func constructAssociationPickers() { // We instantiate this now, indicating that language selection has begun. associationQueriers = [] // So, the for-loop below throws an error for this case. Yay, Swift weirdnesses. if closureShared.associationSpecs.count == 0 { return } // Using a classical for-loop allows us to 'map' receivers (within their closures) to their associators. for i in 0 ... closureShared.associationSpecs.count-1 { let associationSpec = closureShared.associationSpecs[i] closureShared.queryGroup.enter() let receiver: LanguagePickAssociator.AssociationReceiver = constructAssociationReceiver(specIndex: i) associationQueriers!.append(associationSpec.instance(for: self, withSession: closureShared.downloadManager.session, withReceiver: receiver)) } } private func constructAssociationReceiver(specIndex: Int) -> LanguagePickAssociator.AssociationReceiver { let closureShared = self.closureShared return { status in switch status { case .cancelled: // Do not trigger installation or even _start_ attempting installation. // Do a simple, graceful exit. closureShared.queryGroup.leave() // TODO: // case .inProgress(let queryCount, let associationsFound): // break case .complete(_, let associationMap): // Assumption: .complete will only occur if 'picking' was not cancelled. // We know this holds true for the current LanguagePickAssociation implementation. let installer = closureShared.associationSpecs[specIndex].installer(downloadManager: closureShared.downloadManager) closureShared.associationInstallers[specIndex] = installer // Since completions will be signaled once per package, // we enter the install group once per package. associationMap.forEach { _, _ in closureShared.installGroup.enter() } closureShared.queryGroup.leave() installer(associationMap) { packageKey, result in closureShared.progressQueue.async { closureShared.installProgressMap[packageKey] = result closureShared.reportProgress() // Last thing in this closure. closureShared.installGroup.leave() } } // Since we aren't yet directly handling case .inProgress. default: break } closureShared.progressQueue.async { closureShared.associationQueryProgress[specIndex] = status if case let .complete(_, associationMap) = status { associationMap.forEach { (key, _) in // Allows tracking install-completion progress. closureShared.installProgressMap[key] = nil // indicates 'downloading'. } } closureShared.reportProgress() } } } internal func initializeSynchronizationGroups() { let closureShared = self.closureShared closureShared.queryGroup.enter() closureShared.installGroup.enter() // All queries must have returned in order for us to be sure all // needed installations have occurrred. closureShared.queryGroup.notify(queue: closureShared.progressQueue) { closureShared.installGroup.leave() // ? } closureShared.installGroup.notify(queue: DispatchQueue.main) { // The final 'progress report' / completion signaller. closureShared.reportProgress(complete: true) } } internal func coreInstallationClosure() -> ([Resource.FullID]?) -> Void { // For use in closures called by other DispatchGroups. let packageKey = self.package.key let closureShared = self.closureShared // This closure should be triggered from UI, thus from the standard, main UI thread / DispatchGroup. return { fullIDs in guard let fullIDs = fullIDs else { closureShared.isCancelled = true closureShared.queryGroup.leave() closureShared.reportCancelled() return } closureShared.progressQueue.sync { // Indicates a transition to 'installing' state. closureShared.pickingCompleted = true closureShared.installProgressMap[packageKey] = nil closureShared.initializeProgress() } closureShared.installGroup.enter() // Enter the 'install' group before exiting the 'query' group, to be safe. closureShared.queryGroup.leave() var result: PackageInstallResult do { try ResourceFileManager.shared.install(resourcesWithIDs: fullIDs, from: self.package) result = .complete if let kbdID = fullIDs.first(where: { $0.type == .keyboard }) { _ = Manager.shared.setKeyboard(withFullID: kbdID as! FullKeyboardID) } } catch { result = .error(error) } closureShared.progressQueue.async { closureShared.installProgressMap[packageKey] = result closureShared.reportProgress() } closureShared.installGroup.leave() } } /** * Summons UI that allows the user to interactively select which languages are installed for this package, * searching for specified associations in the background and adding them to the installation set. * * May only be called once during the lifetime of its instance and is mutually exclusive with `pickLanguages`, * the programmatic alternative. */ public func promptForLanguages(inNavigationVC navVC: UINavigationController) { guard self.associationQueriers == nil, !closureShared.pickingCompleted else { fatalError("Invalid state - language picking has already been triggered.") } initializeSynchronizationGroups() constructAssociationPickers() closureShared.installGroup.enter() let wrappedCompletionHandler = { self.closureShared.installGroup.leave() } let pickerPrompt = PackageInstallViewController(for: self.package, defaultLanguageCode: defaultLgCode, languageAssociators: associationQueriers!, pickingCompletionHandler: coreInstallationClosure(), uiCompletionHandler: wrappedCompletionHandler) closureShared.promptProgressCallback = { progress in pickerPrompt.progressUpdate(progress) } navVC.pushViewController(pickerPrompt, animated: true) } /** * Allows programmatically selecting which languages are installed for this package. * The specified associations will be used to find related packages and add them to the installation set. * * May only be called once during the lifetime of its instance and is mutually exclusive with `promptForLanguages`, * the user-interactive alternative. */ public func pickLanguages(withCodes languageIDs: Set) { guard self.associationQueriers == nil, !closureShared.pickingCompleted else { fatalError("Invalid state - language picking has already been triggered.") } initializeSynchronizationGroups() constructAssociationPickers() let baseResources: [Resource] = languageIDs.flatMap { package.installables(forLanguage: $0) } let baseFullIDs: [Resource.FullID] = baseResources.map { $0.typedFullID } self.associationQueriers?.forEach { associator in associator.pickerInitialized() associator.selectLanguages(languageIDs) } let installClosure = coreInstallationClosure() installClosure(baseFullIDs) self.associationQueriers?.forEach { $0.pickerFinalized() } } }