spiegel-keyman/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/LanguagePickAssociator.swift

382 lines
14 KiB
Swift

//
// LanguagePickAssociator.swift
// KeymanEngine
//
// Created by Joshua Horton on 8/3/20.
// Copyright © 2020 SIL International. All rights reserved.
//
import Foundation
/**
* This class is designed for use with the `PackageInstallViewController` or
* other language-picking UI classes. As languages are selected in a picker, instances of
* this class will use the provided `AssociationSearcher` to look up associated
* resources that are compatible. These searches will be run concurrently in order to
* reduce waiting time for end users.
*
* When the picker signals that language selection is complete, any detected associations
* will be returned through the instance's provided `AssociationReceiver`.
* It is then up to the `AssociationReceiver` to download and install these associations.
*
* A default `AssociationSearcher` for lexical model lookups is provided as
* `static let lexicalModelSearcher`.
*/
public class LanguagePickAssociator {
public enum Progress {
/**
* Indicates that the language-picking process was aborted. No further calls to the receiving AssociationReceiver will occur.
*/
case cancelled
/**
* Indicates that a set of association queries has returned. Reports the number of completed queries followed by
* the number of detected associated packages found.
*
* For calculating a progress percent, note that the total number of queries
* may be found at `LanguagePickAssociator.languagesQueried`.
*/
case inProgress(Int, Int)
/**
* Indicates that all generated association queries have returned. Reports the total number of queries that occurred,
* followed by a map of package keys to the corresponding URL and language codes to install from the package.
*/
case complete(Int, AssociationMap)
}
/**
* A callback that returns available packages corresponding to provided language codes.
*
* This callback may preemptively filter out unwanted associations if desired. One good example:
* not searching for associated resources when one of the appropriate type is already installed
* for the specified language code.
*/
public typealias AssociationSearcher = (Set<String>, @escaping ([String: (KeymanPackage.Key, URL)?]) -> Void) -> Void
/**
* Tracks the progress of the association process. See `LanguagePickAssociator.Progress` for more information.
*
* May be called multiple times, though a few guarantees exist:
* * Either `.cancelled` or `.complete` will be called exactly once, with the other never occurring, so long as `pickerInitialized` has been called.
* * When either occurs, it will be the final call to the `AssociationReceiver`.
*/
public typealias AssociationReceiver = (Progress) -> Void
public typealias AssociationMap = [KeymanPackage.Key: Association]
public struct Association {
public let url: URL
public let languageCodes: Set<String>
}
/**
* The default AssociationSearcher for lexical model lookups.
*/
public static let lexicalModelSearcher: AssociationSearcher = constructLexicalModelSearcher(session: URLSession.shared)
internal static func constructLexicalModelSearcher(session: URLSession) -> AssociationSearcher {
return { lgCodes, completion in
// Sadly, we don't yet have an actual multi-language-code server-side model query yet.
// So, we have to sync up the results of this query first.
//
// Should we implement such a server-side query, this becomes markedly simpler.
let queueDispatch = DispatchGroup()
var modelMap: [String: (KeymanPackage.Key, URL)?] = [:]
lgCodes.forEach { lgCode in
// First, filter out any language codes that already have an installed lexical model.
let alreadyInstalled = Storage.active.userDefaults.userLexicalModels?.contains(where: { $0.languageID == lgCode}) ?? false
if(!alreadyInstalled) {
queueDispatch.enter()
Queries.LexicalModel.fetchModels(forLanguageCode: lgCode,
withSession: session) { models, error in
guard error == nil, let models = models, models.count > 0 else {
modelMap[lgCode] = nil
queueDispatch.leave()
return
}
modelMap[lgCode] = (models[0].0.packageKey, models[0].1)
queueDispatch.leave()
}
}
}
queueDispatch.notify(queue: DispatchQueue.global()) {
completion(modelMap)
}
}
}
/**
* Provides an internal data structure for storing closure-referenced values
* in order to facilitate proper Swift memory management.
*
* If closures using `self` haven't yet been called when the class
* otherwise goes out of scope, those closures maintain `self` and
* prevent `deinit`.
*
* By moving closure-referenced properties to a class field and creating local
* variables to reference it as needed, , `self` is no longer kept alive by
* closures, allowing `deinit` to occur naturally.
*
* The 'master' `deinit` may then ensure everything else `deinit`s properly..
*/
private class ClosureSharedSpace {
/**
* Used to track whether or not a language picker is currently active.
* Installs will not be started until after this DispatchGroup is completed.
*
* Also used to ensure any queued queries actually start before listening
* for all currently-active queries to complete.
*/
let pickerDispatch: DispatchGroup = DispatchGroup()
/**
* Used to track whether or not any queries are pending completion.
*
* Note that due to internal constraints,`wait()` **must not** be
* called on this `DispatchGroup` until after `pickerDispatch`'s
* tasks have completed.
*/
let queryDispatch: DispatchGroup = DispatchGroup()
/**
* The data structure used to collate query results.
*/
var packageMap: [KeymanPackage.Key: Association] = [:]
/**
* The currently-picked set of languages
*/
var languageSet: Set<String> = Set()
/**
* Used as a write-lock for collating query results.
*/
let querySyncQueue: DispatchQueue = DispatchQueue(label: "com.keyman.associationQueue.sync")
/**
* Performs package queries based on picked languages.
*/
let searcher: AssociationSearcher
/**
* The closure provided to the `LanguagePickAssociator` containing this class
* for processing any detected language associations.
*/
let progressClosure: AssociationReceiver
/**
* Tracks how many languages have been successfully queried, regardless of whether
* or not they reported that associated resources were available.
*/
var queriesComplete: Int = 0
/**
* Notifies the worker threads of query cancellation.
*/
var isCancelled: Bool = false
public init(searcher: @escaping AssociationSearcher, closure: @escaping AssociationReceiver) {
self.searcher = searcher
self.progressClosure = closure
}
}
/**
* Used to facilitate common references across various closures and threads by the class.
*/
private let closureShared: ClosureSharedSpace
/**
* Used to facilitate concurrent association searches and manage internal concurrency.
*/
private let associationQueue: DispatchQueue
/**
* The set of languages that have been queried, whether or not they are _currently_ picked.
*/
private var languageSetSearched: Set<String>
/**
* Used to prevent resource leaks from waiting threads/query processing.
*/
private var pickerActive: Bool = false
private static var queueToken: Int = 0
public init(searchWith searcher: @escaping AssociationSearcher, progressClosure: @escaping AssociationReceiver) {
closureShared = ClosureSharedSpace(searcher: searcher, closure: progressClosure)
let queueID = LanguagePickAssociator.queueToken
LanguagePickAssociator.queueToken += 1
associationQueue = DispatchQueue(label: "com.keyman.associationQueue.\(queueID)",
qos: .default,
attributes: .concurrent,
autoreleaseFrequency: .inherit,
target: nil)
languageSetSearched = Set()
}
deinit {
if(pickerActive) {
// When the class deinits in this state, we have a 'constructive dismissal'.
// Cancelling all selections and then allowing all closures to complete naturally
// will facilitate deinit for everything else.
self.pickerDismissed()
}
}
public func pickerInitialized() {
let closureShared = self.closureShared
closureShared.pickerDispatch.enter()
pickerActive = true
closureShared.queryDispatch.enter()
closureShared.pickerDispatch.notify(queue: DispatchQueue.main) {
closureShared.queryDispatch.leave()
}
// Establishes the actual 'return' callback for the class asynchronously.
// We `notify` on the single-threaded queue to ensure all writes (performed
// exclusively on the same queue) have already completed.
closureShared.queryDispatch.notify(queue: closureShared.querySyncQueue) {
if closureShared.isCancelled {
return
}
// With all association info collated, it's time to prepare the actual 'return' values,
// filtered to only give data regarding the picked languages.
var associationMap: [KeymanPackage.Key: Association] = [:]
closureShared.packageMap.forEach { (key, association) in
let selectedLanguages = association.languageCodes.filter { closureShared.languageSet.contains($0) }
if selectedLanguages.count > 0 {
associationMap[key] = Association(url: association.url, languageCodes: selectedLanguages)
}
}
// Now that all association stuff is computed, use the finalization callback
// Use the 'main' (UI) thread to prevent unexpected UI issues.
DispatchQueue.main.async {
closureShared.progressClosure(.complete(closureShared.queriesComplete, associationMap))
}
}
}
public func selectLanguages(_ languages: Set<String>) {
let unsearched = languages.filter { !languageSetSearched.contains($0) }
languages.forEach { lgCode in
closureShared.querySyncQueue.sync {
_ = closureShared.languageSet.insert(lgCode)
}
languageSetSearched.insert(lgCode)
}
// Start fetch queries for any previously-unsearched language codes!
if unsearched.count > 0 {
queueSelection(forLanguages: unsearched)
}
}
internal func queueSelection(forLanguages languages: Set<String>) {
let closureShared = self.closureShared
// We synchronize on this to ensure that the queue starts before pickerDispatch can leave.
// Not that human inputs _should_ be able to trigger a race condition here,
// but it's still best to be sure.
closureShared.pickerDispatch.enter()
// Queries are allowed to function across multiple threads without issue.
// They should be embarrasingly parallel until the fetch is complete.
associationQueue.async {
closureShared.queryDispatch.enter()
closureShared.pickerDispatch.leave()
// Use the closure provided at initialization to search for associated resource packages.
closureShared.searcher(languages) { map in
// The only non-parallel parts - writing to the result cache and reporting progress.
// We synchronize by evaluating all writes on our single-threaded DispatchQueue.
closureShared.querySyncQueue.async {
if closureShared.isCancelled {
return
}
// Since the results may be sparse, we rely on the original parameter's count.
closureShared.queriesComplete += languages.count
map.forEach { (lgCode, tuple) in
guard let (packageKey, url) = tuple else {
return
}
var languageSet = (closureShared.packageMap[packageKey]?.languageCodes ?? Set())
languageSet.insert(lgCode)
closureShared.packageMap[packageKey] = Association(url: url, languageCodes: languageSet)
}
// How many picked languages have matching dictionaries?
let finishedCodeList: [String] = closureShared.packageMap.flatMap { (key, association) in
association.languageCodes
}
let finishedCodeSet = Set(finishedCodeList)
let selectedSet = finishedCodeSet.intersection(closureShared.languageSet)
let packagesToInstall = closureShared.packageMap.filter { (key, value) in
return selectedSet.intersection(value.languageCodes).count > 0
}
// Report progress.
closureShared.progressClosure(.inProgress(closureShared.queriesComplete, packagesToInstall.count))
}
// We leave this dispatch group AFTER collating all relevant install requests.
closureShared.queryDispatch.leave()
}
}
}
public func deselectLanguages(_ languages: Set<String>) {
closureShared.querySyncQueue.sync {
languages.forEach { closureShared.languageSet.remove($0) }
}
}
public func pickerDismissed() {
closureShared.querySyncQueue.sync {
closureShared.languageSet = Set()
closureShared.isCancelled = true
closureShared.progressClosure(.cancelled)
}
pickerActive = false
// Signals that no further queries will start.
// Once all current queries complete, completionClosure will be called.
closureShared.pickerDispatch.leave()
}
public func pickerFinalized() {
pickerActive = false
// Signals that no further queries will start.
// Once all current queries complete, completionClosure will be called.
closureShared.pickerDispatch.leave()
}
public var languagesPicked: Int {
return closureShared.querySyncQueue.sync { closureShared.languageSet.count }
}
public var languagesQueried: Int {
return languageSetSearched.count
}
}