diff --git a/ios/engine/KMEI/KeymanEngine/Classes/KeymanPackage.swift b/ios/engine/KMEI/KeymanEngine/Classes/KeymanPackage.swift index f19b3ef3a9..cfe52a6d81 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/KeymanPackage.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/KeymanPackage.swift @@ -54,6 +54,63 @@ public class KeymanPackage { } } + /** + * Indicates the distribution level and state of the package. + */ + public enum SupportState: String, Codable { + /** + * Indicates that the support level for the package is unknown. Generally occurs when a + * package is installed via file-sharing before KeymanEngine is able to perform a package-version check. + */ + case unknown + + /** + * Indicates that the package is publicly distributed but no longer maintained. Indicates that other packages targetting + * the same targets are more favored. + */ + case deprecated + + /** + * Indicates that this package receives full support and maintenance from the resource development community at large. + */ + case publiclyReleased = "publicly released" + + /** + * Indicates that this package is known to not be publicly distributed. + */ + case custom + } + + /** + * Cloud/query related metadata not tracked (or even trackable) within kmp.json regarding the support state of a keyboard. + */ + public struct SupportStateMetadata: Codable { + var latestVersion: String? + var timestampForLastQuery: TimeInterval? + + var supportState: SupportState + + init(from queryResult: Queries.PackageVersion.ResultComponent) { + if let entry = queryResult as? Queries.PackageVersion.ResultEntry { + self.latestVersion = entry.version + self.supportState = .publiclyReleased + } else /* if queryResult is Queries.PackageVersion.ResultError */ { + self.latestVersion = nil + // The package-version query knows nothing about it - must be custom. + self.supportState = .custom + } + + self.timestampForLastQuery = NSDate().timeIntervalSince1970 + } + + init(fromQuery: Bool = true) { + latestVersion = nil + timestampForLastQuery = nil + + supportState = fromQuery ? .publiclyReleased : .unknown + } + } + static private let kmpFile = "kmp.json" public let sourceFolder: URL public let id: String @@ -225,6 +282,63 @@ public class KeymanPackage { return nil } } + + /** + * Runs the package-version query for the specified packages to determine their current support-state. + */ + public static func querySupportStates(for keys: [Key], withSession session: URLSession = URLSession.shared, completionBlock: (([Key : SupportStateMetadata]?, Error?)-> Void)? = nil) { + Queries.PackageVersion.fetch(for: keys, withSession: session) { results, error in + guard error == nil, let results = results else { + completionBlock?(nil, error) + return + } + + var keyboardStates: [Key : SupportStateMetadata] = [:] + keyboardStates.reserveCapacity(results.keyboards?.count ?? 0) + + // Sadly, Dictionaries do not support mapping to other dictionaries when considering the keys. + results.keyboards?.forEach { key, value in + keyboardStates[KeymanPackage.Key(id: key, type: .keyboard)] = KeymanPackage.SupportStateMetadata(from: value) + } + + var lexicalModelStates: [Key : SupportStateMetadata] = [:] + lexicalModelStates.reserveCapacity(results.models?.count ?? 0) + + results.models?.forEach { key, value in + lexicalModelStates[KeymanPackage.Key(id: key, type: .lexicalModel)] = KeymanPackage.SupportStateMetadata(from: value) + } + + // TODO: Save these states to UserDefaults! + + // There will be no 'merge' conflicts, so we ignore them by simply selecting the original. + if let completionBlock = completionBlock { + let stateSet = keyboardStates.merging(lexicalModelStates, uniquingKeysWith: { lhs, _ in return lhs }) + completionBlock(stateSet, nil) + } + } + } + + /** + * Runs the package-version query for the specified packages to determine if any updates are available. + */ + public static func queryCurrentVersions(for keys: [Key], withSession session: URLSession = URLSession.shared, completionBlock: (([Key : Version]?, Error?) -> Void)? = nil) { + querySupportStates(for: keys, withSession: session) { stateSet, error in + guard error == nil, let stateSet = stateSet else { + completionBlock?(nil, error) + return + } + + let versionSet: [Key: Version] = stateSet.compactMapValues { value in + if let versionString = value.latestVersion, let version = Version(versionString) { + return version + } else { + return nil + } + } + + completionBlock?(versionSet, nil) + } + } } /** diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/ResourceDownloadManager.swift b/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/ResourceDownloadManager.swift index eb74e3be2b..78dc063e22 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/ResourceDownloadManager.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/ResourceDownloadManager.swift @@ -399,35 +399,6 @@ public class ResourceDownloadManager { } } - /** - * Runs the package-version query against all installed resources to determine if any updates are available. - */ - public func fetchAvailableUpdates(completionBlock: (([KeymanPackage.Key]?, Error?) -> Void)? = nil) { - let userDefaults = Storage.active.userDefaults - let keyboardPackages = userDefaults.userKeyboards?.map { $0.packageKey } - let lexicalModelPackages = userDefaults.userLexicalModels?.map { $0.packageKey } - - let packageKeys = (keyboardPackages ?? []) + (lexicalModelPackages ?? []) - - Queries.PackageVersion.fetch(for: packageKeys) { results, error in - guard error == nil else { - completionBlock?(nil, error) - return - } - - // If no completionBlock was specified, the caller simply wanted a prefetch. - // Any further processing we might try to do would go to waste, so stop here. - guard let completionBlock = completionBlock else { - return - } - - // Check for updates among the returned versions IF a completion block is specified. - // This facilitates a more proactive update notification. - - // TODO: flesh out! - } - } - public func getAvailableUpdates() -> [AnyLanguageResource]? { // Relies upon KMManager's preload; this was the case before the rework. if Manager.shared.apiKeyboardRepository.languages == nil && Manager.shared.apiLexicalModelRepository.languages == nil { diff --git a/ios/engine/KMEI/KeymanEngineTests/KeymanPackageTests.swift b/ios/engine/KMEI/KeymanEngineTests/KeymanPackageTests.swift index 4af869e89c..7771632cf0 100644 --- a/ios/engine/KMEI/KeymanEngineTests/KeymanPackageTests.swift +++ b/ios/engine/KMEI/KeymanEngineTests/KeymanPackageTests.swift @@ -137,4 +137,55 @@ class KeymanPackageTests: XCTestCase { // Deinit should have triggered - were the files automatically cleaned up? XCTAssertFalse(FileManager.default.fileExists(atPath: tempDir.path)) } + + // Analogous to QueryPackageVersionTests.testMockedBatchFetchParse, but with more analysis applied + // and more integration. + func testQueryCurrentVersions() throws { + let mockedURLSession = TestUtils.Downloading.URLSessionMock() + + let expectation = XCTestExpectation(description: "The query completes as expected.") + + // Test setup + + let mockedResult = TestUtils.Downloading.MockResult(location: TestUtils.Queries.package_version_case_1, error: nil) + mockedURLSession.queueMockResult(.data(mockedResult)) + + let badKbdKey = KeymanPackage.Key(id: "foo", type: .keyboard) + let badLexKey = KeymanPackage.Key(id: "bar", type: .lexicalModel) + let packageKeys = [KeymanPackage.Key(forResource: TestUtils.Keyboards.khmer_angkor), + KeymanPackage.Key(forResource: TestUtils.Keyboards.sil_euro_latin), + KeymanPackage.Key(id: "foo", type: .keyboard), + KeymanPackage.Key(forResource: TestUtils.LexicalModels.mtnt), + KeymanPackage.Key(id: "bar", type: .lexicalModel)] + + KeymanPackage.queryCurrentVersions(for: packageKeys, withSession: mockedURLSession) { results, error in + guard error == nil, let results = results else { + XCTFail() + expectation.fulfill() + return + } + + let khmer_angkor = KeymanPackage.Key(forResource: TestUtils.Keyboards.khmer_angkor) + let sil_euro_latin = KeymanPackage.Key(forResource: TestUtils.Keyboards.sil_euro_latin) + let mtnt = KeymanPackage.Key(forResource: TestUtils.LexicalModels.mtnt) + XCTAssertEqual(results[khmer_angkor], Version("1.0.6")) + XCTAssertEqual(results[sil_euro_latin], Version("1.9.1")) + XCTAssertEqual(results[mtnt], Version("0.1.4")) + XCTAssertNil(results[badKbdKey]) + XCTAssertNil(results[badLexKey]) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5) + + // Post-execution cleanup + let queueWasCleared = mockedURLSession.queueIsEmpty + Queries.PackageVersion.resetCache() + + if !queueWasCleared { + throw NSError(domain: "Keyman", + code: 4, + userInfo: [NSLocalizedDescriptionKey: "A test did not fully utilize its queued mock results!"]) + } + } }