diff --git a/ios/engine/KMEI/KeymanEngine/Classes/KeyboardRepository/APILexicalModelRepository.swift b/ios/engine/KMEI/KeymanEngine/Classes/KeyboardRepository/APILexicalModelRepository.swift index 7378d8766c..46dd30d38c 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/KeyboardRepository/APILexicalModelRepository.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/KeyboardRepository/APILexicalModelRepository.swift @@ -15,6 +15,7 @@ public enum APILexicalModelFetchError: Error { case parsingError(Error) } +@available(*, deprecated) public class APILexicalModelRepository: LexicalModelRepository { private let modelsAPIURL = URLComponents(string: "https://api.keyman.com/model")! diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Queries/Queries+LexicalModel.swift b/ios/engine/KMEI/KeymanEngine/Classes/Queries/Queries+LexicalModel.swift index 6fb1c7bb10..348ce07e86 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Queries/Queries+LexicalModel.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Queries/Queries+LexicalModel.swift @@ -45,7 +45,12 @@ extension Queries { private static let MODEL_ENDPOINT = URLComponents(string: "https://api.keyman.com/model")! public static func fetch(forLanguageCode bcp47: String, - withSession session: URLSession = .shared, + fetchCompletion: @escaping JSONQueryCompletionBlock<[Result]>) { + fetch(forLanguageCode: bcp47, withSession: URLSession.shared, fetchCompletion: fetchCompletion) + } + + internal static func fetch(forLanguageCode bcp47: String, + withSession session: URLSession, fetchCompletion: @escaping JSONQueryCompletionBlock<[Result]>) { // Step 1: build the query var urlComponents = MODEL_ENDPOINT diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Queries/Queries+PackageVersion.swift b/ios/engine/KMEI/KeymanEngine/Classes/Queries/Queries+PackageVersion.swift index ee058e78b9..979486fb9c 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Queries/Queries+PackageVersion.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Queries/Queries+PackageVersion.swift @@ -30,6 +30,11 @@ extension Queries { } } + enum Entry { + case success(ResultEntry) + case failure(ResultError?) + } + struct Result: Decodable { let keyboards: [String : ResultComponent]? let models: [String : ResultComponent]? @@ -62,6 +67,21 @@ extension Queries { let modelValueSet = try rootValues.nestedContainer(keyedBy: IDCodingKey.self, forKey: .models) models = try modelValueSet.allKeys.reduce([String: ResultComponent](), dictionaryReducer(container: modelValueSet, category: "lexical model")) } + + func entryFor(_ fullID: FullID) -> Entry { + var result: ResultComponent? + if let fullID = fullID as? FullKeyboardID, let keyboards = self.keyboards { + result = keyboards[fullID.keyboardID] + } else if let fullID = fullID as? FullLexicalModelID, let models = self.models { + result = models[fullID.lexicalModelID] + } + + if let entry = result as? ResultEntry { + return .success(entry) + } else { + return .failure(result as? ResultError) + } + } } private static let PACKAGE_VERSION_ENDPOINT = URLComponents(string: "https://api.keyman.com/package-version")! diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Queries/Queries.swift b/ios/engine/KMEI/KeymanEngine/Classes/Queries/Queries.swift index 7cf67527b0..5d60ebd99e 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Queries/Queries.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Queries/Queries.swift @@ -10,7 +10,7 @@ import Foundation // The core `Queries` definition provides common utility methods, handlers, etc across our API queries. class Queries { - public enum FetchError: Error { + public enum FetchError: LocalizedError { case networkError(Error) case noData case parsingError(Error) @@ -27,6 +27,17 @@ class Queries { } } + public enum ResultError: LocalizedError { + case unqueried + + var localizedDescription: String { + switch self { + case .unqueried: + return "Query was not run against specified parameter" + } + } + } + public struct IDCodingKey: CodingKey { /* Required by CodingKey, though we don't particularly need these */ public var intValue: Int? diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/ResourceDownloadManager.swift b/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/ResourceDownloadManager.swift index 6cbf5af97a..ad58b552ea 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/ResourceDownloadManager.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/ResourceDownloadManager.swift @@ -15,6 +15,7 @@ import Foundation // only accessible within the library. public class ResourceDownloadManager { // internal b/c testing access. + internal var session: URLSession internal var downloader: ResourceDownloadQueue private var isDidUpdateCheck = false @@ -25,11 +26,13 @@ public class ResourceDownloadManager { public static let shared = ResourceDownloadManager() internal init() { + session = URLSession.shared downloader = ResourceDownloadQueue() } // Intended only for use in testing! internal init(session: URLSession, autoExecute: Bool) { + self.session = session downloader = ResourceDownloadQueue(session: session, autoExecute: autoExecute) } @@ -220,24 +223,8 @@ public class ResourceDownloadManager { } // MARK - Lexical models - - private func getInstallableLexicalModelMetadata(withID lexicalModelID: String, languageID: String) -> InstallableLexicalModel? { - // Grab info for the relevant API version of the keyboard. - guard let keyboard = Manager.shared.apiLexicalModelRepository.installableLexicalModel(withID: lexicalModelID, languageID: languageID) - else { - let message = "Lexical model not found with id: \(lexicalModelID), languageID: \(languageID)" - let error = NSError(domain: "Keyman", code: 0, - userInfo: [NSLocalizedDescriptionKey: message]) - - // Ideal - use a LexicalModelFullID instead. But, that's a API shift. - self.resourceDownloadFailed(for: [] as [InstallableLexicalModel], with: error) - return nil - } - - return keyboard - } - // Can be called by the cloud keyboard downloader and utilized. + // Can be called by the keyboard downloader and utilized. /// Starts the process of fetching the package file of the lexical model for the given language ID /// first it fetches the list of lexical models for the given language @@ -282,56 +269,47 @@ public class ResourceDownloadManager { isUpdate: Bool, fetchRepositoryIfNeeded: Bool = true, completionBlock: CompletionHandler? = nil) { - - // TODO: We should always force a refetch after new keyboards are installed so we can redo our language queries. - // That should probably be done on successful keyboard installs, not here, though. - if fetchRepositoryIfNeeded { - // A temp measure to make sure things aren't totally broken. Definitely not optimal. - Manager.shared.apiLexicalModelRepository.fetch(completionHandler: nil) - } - - guard let _ = Manager.shared.apiLexicalModelRepository.lexicalModels else { - if fetchRepositoryIfNeeded { - log.info("Fetching repository from API for lexicalModel download") - Manager.shared.apiLexicalModelRepository.fetch(completionHandler: fetchHandler(for: .lexicalModel) { - self.downloadLexicalModel(withID: lexicalModelID, languageID: languageID, isUpdate: isUpdate, fetchRepositoryIfNeeded: false, completionBlock: completionBlock) - }) - return - } else { - let message = "Lexical model repository not yet fetched" - let error = NSError(domain: "Keyman", code: 0, userInfo: [NSLocalizedDescriptionKey: message]) - self.resourceDownloadFailed(for: [] as [InstallableLexicalModel], with: error) + // Note: in this case, someone knows the "full ID" of the model already, but NOT its location. + // For lexical models, we can use either the LexicalModel query or the PackageVersion query. + // For consistency with keyboard download behavior, we use the PackageVersion query here. + + let lmFullID = FullLexicalModelID(lexicalModelID: lexicalModelID, languageID: languageID) + Queries.PackageVersion.fetch(for: [lmFullID], withSession: session) { result, error in + guard let result = result, error == nil else { + log.info("Error occurred requesting location for \(lmFullID.description)") + self.resourceDownloadFailed(forFullID: lmFullID, with: error ?? .noData) return } - } - // Grab info for the relevant API version of the keyboard. - guard let lexicalModel = getInstallableLexicalModelMetadata(withID: lexicalModelID, languageID: languageID), - let filename = Manager.shared.apiLexicalModelRepository.lexicalModels?[lexicalModelID]?.packageFilename - else { - return - } - - // Perform common 'can download' check. We need positive reachability and no prior download queue. - let queueState = downloader.state - guard queueState == .clear else { - resourceDownloadFailed(for: [lexicalModel], with: queueState.error ?? NSError(domain: "Keyman", code: 0, userInfo: [NSLocalizedDescriptionKey: "Already busy downloading something"])) - return - } - - let batch = self.buildPackageBatch(forFullID: FullLexicalModelID(lexicalModelID: lexicalModelID, languageID: languageID), - from: URL.init(string: filename)!, - withNotifications: !isUpdate, - withResource: lexicalModel, - completionBlock: completionBlock ?? { package, error in - // If the caller doesn't specify a completion block, this will carry out a default installation. - if let package = package { - try? ResourceFileManager.shared.install(resourceWithID: FullLexicalModelID(lexicalModelID: lexicalModelID, languageID: languageID), from: package) + guard case let .success(data) = result.entryFor(lmFullID) else { + if case let .failure(errorEntry) = result.entryFor(lmFullID) { + if let errorEntry = errorEntry { + log.info("Query reported error: \(String(describing: errorEntry.error))") + } + } + self.resourceDownloadFailed(forFullID: lmFullID, with: Queries.ResultError.unqueried) + return } - // else error: already handled by wrapping closure set within buildPackageBatch. - }) - downloader.queue(.simpleBatch(batch)) + // Perform common 'can download' check. We need positive reachability and no prior download queue. + guard self.downloader.state == .clear else { + self.resourceDownloadFailed(forFullID: lmFullID, with: self.downloader.state.error ?? + NSError(domain: "Keyman", code: 0, userInfo: [NSLocalizedDescriptionKey: "Already busy downloading something"])) + return + } + + let completionClosure: CompletionHandler = completionBlock ?? { package, error in + // If the caller doesn't specify a completion block, this will carry out a default installation. + if let package = package { + try? ResourceFileManager.shared.install(resourceWithID: FullLexicalModelID(lexicalModelID: lexicalModelID, languageID: languageID), from: package) + } + } + + self.downloadPackage(forFullID: lmFullID, + from: URL.init(string: data.packageURL)!, + withNotifications: !isUpdate, + completionBlock: completionClosure) + } } @@ -743,4 +721,18 @@ public class ResourceDownloadManager { value: notification) } } + + internal func resourceDownloadFailed(forFullID fullID: FullID, with error: Error) { + if let _ = fullID as? FullKeyboardID { + let notification = KeyboardDownloadFailedNotification(keyboards: [], error: error) + NotificationCenter.default.post(name: Notifications.keyboardDownloadFailed, + object: self, + value: notification) + } else if let _ = fullID as? FullLexicalModelID { + let notification = LexicalModelDownloadFailedNotification(lmOrLanguageID: fullID.languageID, error: error) + NotificationCenter.default.post(name: Notifications.lexicalModelDownloadFailed, + object: self, + value: notification) + } + } } diff --git a/ios/engine/KMEI/KeymanEngineTests/QueryPackageVersionTests.swift b/ios/engine/KMEI/KeymanEngineTests/QueryPackageVersionTests.swift index 250a6a7aec..4432bf5947 100644 --- a/ios/engine/KMEI/KeymanEngineTests/QueryPackageVersionTests.swift +++ b/ios/engine/KMEI/KeymanEngineTests/QueryPackageVersionTests.swift @@ -42,10 +42,13 @@ class QueryPackageVersionTests: XCTestCase { TestUtils.LexicalModels.mtnt.fullID, badLexFullID] + let expectation = XCTestExpectation(description: "Query complete and results analyzed") + // As it's a mocked fetch, it happens synchronously. Queries.PackageVersion.fetch(for: fullIDs, withSession: mockedURLSession!) { results, error in if let _ = error { XCTFail(String(describing: error)) + expectation.fulfill() return } XCTAssertNotNil(results) @@ -94,6 +97,60 @@ class QueryPackageVersionTests: XCTestCase { } } } + expectation.fulfill() } + + wait(for: [expectation], timeout: 5) + } + + func testResultEntryFor() throws { + let mockedResult = TestUtils.Downloading.MockResult(location: TestUtils.Queries.package_version_case_1, error: nil) + mockedURLSession?.queueMockResult(.data(mockedResult)) + + let badKbdFullID = FullKeyboardID(keyboardID: "foo", languageID: "en") + let badLexFullID = FullLexicalModelID(lexicalModelID: "bar", languageID: "km") + let fullIDs = [TestUtils.Keyboards.khmer_angkor.fullID, + TestUtils.Keyboards.sil_euro_latin.fullID, + badKbdFullID, + TestUtils.LexicalModels.mtnt.fullID, + badLexFullID] + + let expectation = XCTestExpectation(description: "Query complete and results analyzed") + + // As it's a mocked fetch, it happens synchronously. + Queries.PackageVersion.fetch(for: fullIDs, withSession: mockedURLSession!) { results, error in + if let _ = error { + XCTFail(String(describing: error)) + expectation.fulfill() + return + } + XCTAssertNotNil(results) + + if let results = results { + XCTAssertNotNil(results.keyboards) + XCTAssertNotNil(results.models) + + let khmer_angkor = results.entryFor(TestUtils.Keyboards.khmer_angkor.fullID) + if case .failure(_) = khmer_angkor { + XCTFail("API result object reported error for khmer_angkor, not a version entry") + } + + + let foo = results.entryFor(badKbdFullID) + if case .success(_) = foo { + XCTFail("API result object reported a version entry for foo, not an error") + } + + + let foobar = results.entryFor(FullKeyboardID(keyboardID: "foobar", languageID: "en")) + if case .success(_) = foobar { + XCTFail("Query should not have results data for unqueried resource") + } + } + + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5) } } diff --git a/ios/keyman/Keyman/Keyman/MainViewController.swift b/ios/keyman/Keyman/Keyman/MainViewController.swift index 3071330ac0..e973fe3975 100644 --- a/ios/keyman/Keyman/Keyman/MainViewController.swift +++ b/ios/keyman/Keyman/Keyman/MainViewController.swift @@ -133,7 +133,6 @@ class MainViewController: UIViewController, TextViewDelegate, UIActionSheetDeleg // Pre-load for use in update checks. Manager.shared.apiKeyboardRepository.fetch() - Manager.shared.apiLexicalModelRepository.fetch() // Implement a default color... var bgColor = UIColor(red: 1.0, green: 1.0, blue: 207.0 / 255.0, alpha: 1.0)