diff --git a/ios/engine/KMEI/KeymanEngine/Classes/KeyboardKeymanPackage.swift b/ios/engine/KMEI/KeymanEngine/Classes/KeyboardKeymanPackage.swift index c33e344f40..71993234b7 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/KeyboardKeymanPackage.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/KeyboardKeymanPackage.swift @@ -11,7 +11,7 @@ import Foundation public class KeyboardKeymanPackage : TypedKeymanPackage { internal var keyboards: [KMPKeyboard]! - override init(metadata: KMPMetadata, folder: URL) { + override internal init(metadata: KMPMetadata, folder: URL) { super.init(metadata: metadata, folder: folder) self.keyboards = [] diff --git a/ios/engine/KMEI/KeymanEngine/Classes/KeymanPackage.swift b/ios/engine/KMEI/KeymanEngine/Classes/KeymanPackage.swift index 37c9baeff0..dce8a0d192 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/KeymanPackage.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/KeymanPackage.swift @@ -15,14 +15,18 @@ public enum KMPError : String, Error { case fileSystem = "Unable to create directory structure in file system." case copyFiles = "Unable to copy keyboard files to file system." case wrongPackageType = "Package provided does not match the type expected by this method" + case resourceNotInPackage = "Resource cannot be found in specified package" } +/** + * Common base class for the different KeymanPackage types. + */ public class KeymanPackage { static private let kmpFile = "kmp.json" public let sourceFolder: URL internal let metadata: KMPMetadata - init(metadata: KMPMetadata, folder: URL) { + internal init(metadata: KMPMetadata, folder: URL) { sourceFolder = folder self.metadata = metadata } @@ -31,6 +35,21 @@ public class KeymanPackage { return metadata.packageType == .Keyboard } + /** + * Returns the type of LanguageResource contained within the parsed Package. + */ + public func resourceType() -> LanguageResourceType { + switch metadata.packageType { + case .Keyboard: + return .keyboard + case .LexicalModel: + return .lexicalModel + default: + // See KeymanPackage.parse below; an error is thrown there for case .Unsupported. + fatalError("KeymanPackage.parse failed to block construction of unsupported KeymanPackage instance") + } + } + // to be overridden by subclasses public func defaultInfoHtml() -> String { return "base class!" @@ -67,7 +86,13 @@ public class KeymanPackage { public var installableResourceSets: [[AnyLanguageResource]] { fatalError("abstract base method went unimplemented by derived class") } - + + /** + * Parses a decompressed KMP's metadata file, producing the typed KeymanPackage instance corresponding to it. + * + * Typecast the return value to either KeyboardKeymanPackage or LexicalModelKeymanPackage for richly-typed + * information about the package's contents. + */ static public func parse(_ folder: URL) -> KeymanPackage? { do { var path = folder @@ -133,4 +158,20 @@ public class TypedKeymanPackage: Keyman public override var installableResourceSets: [[AnyLanguageResource]] { return installables } + + public func findResource(withID fullID: TypedLanguageResource.FullID) -> TypedLanguageResource? { + let matchesFound: [TypedLanguageResource] = self.installables.compactMap { set in + let setMatches: [TypedLanguageResource] = set.compactMap { other in + if fullID.id == other.id && fullID.languageID == other.languageID { + return other + } else { + return nil + } + } + + return setMatches.count > 0 ? setMatches[0] : nil + } + + return matchesFound.count > 0 ? matchesFound[0] : nil + } } diff --git a/ios/engine/KMEI/KeymanEngine/Classes/LexicalModelKeymanPackage.swift b/ios/engine/KMEI/KeymanEngine/Classes/LexicalModelKeymanPackage.swift index 44ea0d87d9..9936b21ad6 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/LexicalModelKeymanPackage.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/LexicalModelKeymanPackage.swift @@ -11,7 +11,7 @@ import Foundation public class LexicalModelKeymanPackage : TypedKeymanPackage { internal var models : [KMPLexicalModel]! - override init(metadata: KMPMetadata, folder: URL) { + override internal init(metadata: KMPMetadata, folder: URL) { super.init(metadata: metadata, folder: folder) self.models = [] diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Manager.swift b/ios/engine/KMEI/KeymanEngine/Classes/Manager.swift index e005da3070..9d406e80de 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Manager.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Manager.swift @@ -617,60 +617,11 @@ public class Manager: NSObject, UIGestureRecognizerDelegate { throw KMPError.wrongPackageType } - for r in kmp.resources as! [KMPKeyboard] { - let installableFiles: [(InstallableKeyboard, [(String, URL)])] = r.typedInstallableResources.map { resource in - // (source file, destination file) - var set: [(String, URL)] = [(resource.sourceFilename, Storage.active.resourceURL(for: resource)!)] - let installableFonts: [(String, URL)] = resource.fonts.map { font in - // KMPs only list a single font file for each entry whenever one is included. - // A pre-existing assumption. - let fontFile = font.source[0] - return (fontFile, Storage.active.fontURL(forResource: resource, filename: fontFile)!) - } - - set.append(contentsOf: installableFonts) - - // (resource, source-destination file mapping) - return (resource, set) - } - - var haveInstalledOne = false - for set in installableFiles { - let resource = set.0 - let files = set.1 - - do { - try FileManager.default.createDirectory(at: Storage.active.resourceDir(for: resource)!, - withIntermediateDirectories: true) - } catch { - log.error("Could not create dir for download: \(error)") - throw KMPError.fileSystem - } - - do { - for item in files { - var filePath = folder - filePath.appendPathComponent(item.0) - - // TODO: The rest of this block may be replaced with ResourceFileManager.copyWithOverwrite - // once this method is fully abstracted and its core is placed within said class. - if(FileManager.default.fileExists(atPath: (item.1).path)) { - try FileManager.default.removeItem(at: item.1) - } - try FileManager.default.copyItem(at: filePath, to: item.1) - - } - } catch { - log.error("Error saving the download: \(error)") - throw KMPError.copyFiles - } - - // Only one keyboard is installed by default from a KMP. - // Also, only the first language-pairing it contains - if !haveInstalledOne { - Manager.shared.addKeyboard(resource) - haveInstalledOne = true - } + for resourceSet in kmp.installables { + for resource in resourceSet { + try ResourceFileManager.shared.install(resourceWithID: resource.fullID, from: kmp) + // Install the keyboard for only the first language pairing defined in the package. + break } } } @@ -681,48 +632,9 @@ public class Manager: NSObject, UIGestureRecognizerDelegate { throw KMPError.wrongPackageType } - for r in kmp.resources as! [KMPLexicalModel] { - let installableFiles: [(InstallableLexicalModel, [(String, URL)])] = r.installableResources.map { resource in - // (source file, destination file) - let set: [(String, URL)] = [(resource.sourceFilename, Storage.active.resourceURL(for: resource)!)] - // no fonts for lexical models - - // (resource, source-destination file mapping) - return (resource, set) - } - - for set in installableFiles { - let resource = set.0 - let files = set.1 - - do { - try FileManager.default.createDirectory(at: Storage.active.resourceDir(for: resource)!, - withIntermediateDirectories: true) - } catch { - log.error("Could not create dir for download: \(error)") - throw KMPError.fileSystem - } - - do { - for item in files { - var filePath = folder - filePath.appendPathComponent(item.0) - - // TODO: The rest of this block may be replaced with ResourceFileManager.copyWithOverwrite - // once this method is fully abstracted and its core is placed within said class. - if(FileManager.default.fileExists(atPath: (item.1).path)) { - try FileManager.default.removeItem(at: item.1) - } - try FileManager.default.copyItem(at: filePath, to: item.1) - } - } catch { - log.error("Error saving the lexical model download: \(error)") - throw KMPError.copyFiles - } - - // All pairings are installed for lexical models. - // Also, all models within the KMP - Manager.addLexicalModel(resource) + try kmp.installables.forEach { resourceSet in + try resourceSet.forEach { resource in + try ResourceFileManager.shared.install(resourceWithID: resource.fullID, from: kmp) } } } diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/ResourceFileManager.swift b/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/ResourceFileManager.swift index b9f1098568..d8d76085ee 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/ResourceFileManager.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/ResourceFileManager.swift @@ -178,4 +178,54 @@ public class ResourceFileManager { completionHandler(error) } } + + public func install> ( + resourceWithID fullID: ResourceType.FullID, + from package: PackageType) throws { + + guard let resource = package.findResource(withID: fullID) else { + throw KMPError.resourceNotInPackage + } + + // (source, destination) + var installableFiles: [(String, URL)] = [(resource.sourceFilename, Storage.active.resourceURL(for: resource)!)] + let installableFonts: [(String, URL)] = resource.fonts.map { font in + // KMPs only list a single font file for each entry whenever one is included. + // A pre-existing assumption. + let fontFile = font.source[0] + return (fontFile, Storage.active.fontURL(forResource: resource, filename: fontFile)!) + } + + installableFiles.append(contentsOf: installableFonts) + + do { + try FileManager.default.createDirectory(at: Storage.active.resourceDir(for: resource)!, + withIntermediateDirectories: true) + } catch { + log.error("Could not create installation directory: \(error)") + throw KMPError.fileSystem + } + + do { + for item in installableFiles { + var filePath = package.sourceFolder + filePath.appendPathComponent(item.0) + try copyWithOverwrite(from: filePath, to: item.1) + } + } catch { + log.error("Error installing the resource: \(error)") + throw KMPError.copyFiles + } + + // There's no generalized method for this quite yet. Manager doesn't need even + // more of these. + if let keyboard = resource as? InstallableKeyboard { + Manager.shared.addKeyboard(keyboard) + } else if let lexicalModel = resource as? InstallableLexicalModel { + Manager.addLexicalModel(lexicalModel) + } else { + fatalError("Cannot install instance of unexpected LanguageResource subclass") + } + } } diff --git a/ios/engine/KMEI/KeymanEngineTests/FileManagementTests.swift b/ios/engine/KMEI/KeymanEngineTests/FileManagementTests.swift index 50d50123f6..96bc4f6dfc 100644 --- a/ios/engine/KMEI/KeymanEngineTests/FileManagementTests.swift +++ b/ios/engine/KMEI/KeymanEngineTests/FileManagementTests.swift @@ -69,4 +69,65 @@ class FileManagementTests: XCTestCase { } } } + + func testInstallKeyboardFromPackage() throws { + // Standard installation + ResourceFileManager.shared.prepareKMPInstall(from: TestUtils.Keyboards.khmerAngkorKMP) { kmp, error in + XCTAssertNotNil(kmp, "Failed to prepare KMP for installation") + XCTAssertNil(error, "Error occurred while preparing KMP for installation") + guard let kmp = kmp as? KeyboardKeymanPackage else { + XCTFail("KMP resource type improperly recognized - expected a keyboard package!") + return + } + + do { + try ResourceFileManager.shared.install(resourceWithID: TestUtils.Keyboards.khmer_angkor.fullID, from: kmp) + } catch { + XCTFail("Unexpected error during KeyboardPackage install") + } + + let installURL = Storage.active.keyboardURL(forID: "khmer_angkor", version: "1.0.6") + + XCTAssertTrue(FileManager.default.fileExists(atPath: installURL.path), + "Could not find installed keyboard file") + + let keyboards = Storage.active.userDefaults.userKeyboards! + + XCTAssertEqual(keyboards.count, 1, "Unexpected number of keyboards were installed") + XCTAssertEqual(keyboards[0].id, "khmer_angkor", "Installed keyboard ID mismatch") + + // While the LanguageResource definition we provided lacks font definitions, the + // KMP's definition has that data. By default, the KMP's definition takes precedence. + let fontURL = Storage.active.fontURL(forKeyboardID: "khmer_angkor", filename: "Mondulkiri-R.ttf") + XCTAssertTrue(FileManager.default.fileExists(atPath: fontURL.path)) + } + } + + func testInstallLexicalModelFromPackage() { + ResourceFileManager.shared.prepareKMPInstall(from: TestUtils.LexicalModels.mtntKMP) { kmp, error in + XCTAssertNotNil(kmp, "Failed to prepare KMP for installation") + XCTAssertNil(error, "Error occurred while preparing KMP for installation") + guard let kmp = kmp as? LexicalModelKeymanPackage else { + XCTFail("KMP resource type improperly recognized - expected a lexical model package!") + return + } + + do { + try ResourceFileManager.shared.install(resourceWithID: TestUtils.LexicalModels.mtnt.fullID, from: kmp) + } catch { + XCTFail("Unexpected error during LexicalModelPackage install") + } + + let installURL = Storage.active.lexicalModelURL(forID: "nrc.en.mtnt", version: "0.1.4") + + XCTAssertTrue(FileManager.default.fileExists(atPath: installURL.path), + "Could not find installed lexical model file") + + let models = Storage.active.userDefaults.userLexicalModels! + + // This variant is selective - only a single pairing should be installed for the model. + XCTAssertEqual(models.count, 1, "Unexpected number of models were installed") + XCTAssertEqual(models[0].id, "nrc.en.mtnt", "Installed lexical model ID mismatch") + } + } } diff --git a/ios/engine/KMEI/KeymanEngineTests/KeymanPackageTests.swift b/ios/engine/KMEI/KeymanEngineTests/KeymanPackageTests.swift index 8a5046bcc4..3295eb0849 100644 --- a/ios/engine/KMEI/KeymanEngineTests/KeymanPackageTests.swift +++ b/ios/engine/KMEI/KeymanEngineTests/KeymanPackageTests.swift @@ -74,4 +74,30 @@ class KeymanPackageTests: XCTestCase { XCTFail("KeymanPackage.extract failed with error \(error)") } } + + func testPackageFindResourceMatch() { + ResourceFileManager.shared.prepareKMPInstall(from: TestUtils.Keyboards.khmerAngkorKMP) { kmp, _ in + guard let kmp = kmp as? KeyboardKeymanPackage else { + XCTFail("Incorrect package type loaded for test") + return + } + XCTAssertNotNil(kmp.findResource(withID: TestUtils.Keyboards.khmer_angkor.fullID)) + // This keyboard's not in the specified testing package. + XCTAssertNil(kmp.findResource(withID: TestUtils.Keyboards.khmer10.fullID)) + + // Thanks to our package typing hierarchy, it's impossible to even TRY finding + // a FullLexicalModelID within a KeyboardKeymanPackage! + } + + ResourceFileManager.shared.prepareKMPInstall(from: TestUtils.LexicalModels.mtntKMP) { kmp, _ in + guard let kmp = kmp as? LexicalModelKeymanPackage else { + XCTFail("Incorrect package type loaded for test") + return + } + XCTAssertNotNil(kmp.findResource(withID: TestUtils.LexicalModels.mtnt.fullID)) + + // Thanks to our package typing hierarchy, it's impossible to even TRY finding + // a FullKeyboardID within a LexicalModelKeymanPackage! + } + } } diff --git a/ios/engine/KMEI/KeymanEngineTests/TestUtils/Keyboards.swift b/ios/engine/KMEI/KeymanEngineTests/TestUtils/Keyboards.swift index 05226fee0f..cba090233f 100644 --- a/ios/engine/KMEI/KeymanEngineTests/TestUtils/Keyboards.swift +++ b/ios/engine/KMEI/KeymanEngineTests/TestUtils/Keyboards.swift @@ -14,7 +14,7 @@ extension TestUtils { static let khmerAngkorKMP = TestUtils.keyboardsBundle.url(forResource: "khmer_angkor", withExtension: "kmp")! static let khmer_angkor = InstallableKeyboard(id: "khmer_angkor", name: "Khmer Angkor", - languageID: "kh", + languageID: "km", languageName: "Khmer (Central)", version: "1.0.6", isRTL: false, @@ -23,7 +23,7 @@ extension TestUtils { isCustom: false) static let khmer10 = InstallableKeyboard(id: "khmer10", name: "Khmer (NiDA)", - languageID: "kh", + languageID: "km", languageName: "Khmer (Central)", version: "1.3", isRTL: false,