Merge pull request #3207 from keymanapp/refactor/ios/engine/selective-resource-install-abstraction

refactor(ios): Selective resource installation via abstraction
This commit is contained in:
Joshua Horton 2020-06-10 14:08:25 +07:00 • committed by GitHub
commit acaa3fae16
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 192 additions and 102 deletions

View file

@ -11,7 +11,7 @@ import Foundation
public class KeyboardKeymanPackage : TypedKeymanPackage<InstallableKeyboard> {
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 = []

View file

@ -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<TypedLanguageResource: LanguageResource>: 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
}
}

View file

@ -11,7 +11,7 @@ import Foundation
public class LexicalModelKeymanPackage : TypedKeymanPackage<InstallableLexicalModel> {
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 = []

View file

@ -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)
}
}
}

View file

@ -178,4 +178,54 @@ public class ResourceFileManager {
completionHandler(error)
}
}
public func install<ResourceType: LanguageResource,
PackageType: TypedKeymanPackage<ResourceType>> (
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")
}
}
}

View file

@ -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")
}
}
}

View file

@ -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!
}
}
}

View file

@ -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,