mirror of
https://github.com/keymanapp/keyman.git
synced 2026-09-17 21:27:40 +00:00
382 lines
14 KiB
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
|
|
}
|
|
}
|