diff --git a/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj b/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj index 876a56dbf4..aafd752f36 100644 --- a/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj +++ b/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj @@ -210,6 +210,8 @@ CE2B1E4821B60E8A007D092E /* DeviceKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE2B1E4521B60E7C007D092E /* DeviceKit.framework */; }; CE2B1E4A21B60FB1007D092E /* DeviceKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = CE2B1E4521B60E7C007D092E /* DeviceKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; CE67D961228A6F190029F2B5 /* KeyboardCommandStructs.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE67D960228A6F190029F2B5 /* KeyboardCommandStructs.swift */; }; + CE71705823A9C14D00A924A1 /* ResourceFileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE71705723A9C14D00A924A1 /* ResourceFileManager.swift */; }; + CE71705F23A9C97F00A924A1 /* PackageInstallViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE71705E23A9C97F00A924A1 /* PackageInstallViewController.swift */; }; CE808A48236697BE00713E6B /* DeviceKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE2B1E4521B60E7C007D092E /* DeviceKit.framework */; }; CE808A4B236697D400713E6B /* ObjcExceptionBridging.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1687ACCD1FD8DE5300926D69 /* ObjcExceptionBridging.framework */; }; CE808A4D236697D500713E6B /* Reachability.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9A0FC9FC22D66D9E00D33F86 /* Reachability.framework */; }; @@ -414,6 +416,8 @@ CE24ECEF21B763740052D291 /* KeymanResponder+Types.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeymanResponder+Types.swift"; sourceTree = ""; }; CE2B1E4521B60E7C007D092E /* DeviceKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = DeviceKit.framework; path = ../../Carthage/Build/iOS/DeviceKit.framework; sourceTree = ""; }; CE67D960228A6F190029F2B5 /* KeyboardCommandStructs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardCommandStructs.swift; sourceTree = ""; }; + CE71705723A9C14D00A924A1 /* ResourceFileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceFileManager.swift; sourceTree = ""; }; + CE71705E23A9C97F00A924A1 /* PackageInstallViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PackageInstallViewController.swift; sourceTree = ""; }; CECB38931F2199BC0098882F /* Reachability.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = Reachability.h; path = KeymanEngine/lib/Reachability/Reachability.h; sourceTree = SOURCE_ROOT; }; CECB38941F2199BC0098882F /* Reachability.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = Reachability.m; path = KeymanEngine/lib/Reachability/Reachability.m; sourceTree = SOURCE_ROOT; }; F243887E14BBD43000A3E055 /* KeymanEngineDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KeymanEngineDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -714,8 +718,6 @@ 9A9CB0852241717C00231FB9 /* LexicalModelRepositoryDelegate.swift */, C07A9D8D1FD1798900828ADD /* APIKeyboardRepository.swift */, 9A9CB0812241704800231FB9 /* APILexicalModelRepository.swift */, - CE1F67A22304EB3800FF6972 /* ResourceDownloadManager.swift */, - CE22DFB9230B94DB00A4551C /* ResourceDownloadQueue.swift */, ); path = KeyboardRepository; sourceTree = ""; @@ -738,6 +740,17 @@ name = KeymanWebView; sourceTree = ""; }; + CE71705923A9C7D300A924A1 /* Resource Management */ = { + isa = PBXGroup; + children = ( + CE1F67A22304EB3800FF6972 /* ResourceDownloadManager.swift */, + CE22DFB9230B94DB00A4551C /* ResourceDownloadQueue.swift */, + CE71705723A9C14D00A924A1 /* ResourceFileManager.swift */, + CE71705E23A9C97F00A924A1 /* PackageInstallViewController.swift */, + ); + path = "Resource Management"; + sourceTree = ""; + }; F243887314BBD43000A3E055 = { isa = PBXGroup; children = ( @@ -825,6 +838,7 @@ F273AB9615641D9300A47CEE /* Classes */ = { isa = PBXGroup; children = ( + CE71705923A9C7D300A924A1 /* Resource Management */, 165EB39F2098992D00040A69 /* Errors */, C055E6E81F99EA320035C2DD /* Extension */, C0452BA91F9F1CAF0064431A /* Model */, @@ -1252,6 +1266,7 @@ C0324B931F87689B00AF3785 /* KeymanURLProtocol.swift in Sources */, C05F43311FBD62550058CBD4 /* JSONDecoder.DateDecodingStrategy+ISO8601Fallback.swift in Sources */, C0452BAB1F9F1FE10064431A /* Language.swift in Sources */, + CE71705F23A9C97F00A924A1 /* PackageInstallViewController.swift in Sources */, C06D37431F81F5C400F61AE0 /* KeyboardPickerBarButtonItem.swift in Sources */, C082CE151F90AFD400860F02 /* Collection+SafeAccess.swift in Sources */, C06D37441F81F5C400F61AE0 /* KeyboardNameTableViewCell.swift in Sources */, @@ -1260,6 +1275,7 @@ C08E69911FDA6F6F0026056B /* FullKeyboardID.swift in Sources */, 9A9CB0822241704800231FB9 /* APILexicalModelRepository.swift in Sources */, C040E5101F8606E300901EE4 /* TextField.swift in Sources */, + CE71705823A9C14D00A924A1 /* ResourceFileManager.swift in Sources */, 9A082559227589360051EBB0 /* Formatter+ISODateExtension.swift in Sources */, C0324B8D1F87480700AF3785 /* TextFieldDelegateProxy.swift in Sources */, C075EB061F8EFF870041F4BD /* String+Helpers.swift in Sources */, diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/PackageInstallViewController.swift b/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/PackageInstallViewController.swift new file mode 100644 index 0000000000..f67d4c2a01 --- /dev/null +++ b/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/PackageInstallViewController.swift @@ -0,0 +1,84 @@ +// +// PackageInstallViewController.swift +// KeymanEngine +// +// Created by Joshua Horton on 12/18/19. +// Copyright © 2019 SIL International. All rights reserved. +// + +import Foundation +import WebKit + +public class PackageInstallViewController: UIViewController { + public typealias CompletionHandler = (Error?) -> Void + + let package: KeymanPackage + var wkWebView: WKWebView? + let completionHandler: CompletionHandler + + public init(for package: KeymanPackage, completionHandler: @escaping CompletionHandler) { + self.package = package + self.completionHandler = completionHandler + super.init(nibName: nil, bundle: nil) + + _ = view + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func loadView() { + wkWebView = WKWebView.init(frame: .zero) + wkWebView!.backgroundColor = .white + view = wkWebView! + + // Ensure the web view fills its available space. + wkWebView?.autoresizingMask = [.flexibleWidth, .flexibleHeight] + + let cancelBtn = UIBarButtonItem(title: "Cancel", style: .plain, + target: self, + action: #selector(cancelBtnHandler)) + let installBtn = UIBarButtonItem(title: "Install", style: .plain, + target: self, + action: #selector(installBtnHandler)) + + navigationItem.leftBarButtonItem = cancelBtn + navigationItem.rightBarButtonItem = installBtn + } + + override public func viewWillAppear(_ animated: Bool) { + wkWebView?.loadHTMLString(package.infoHtml(), baseURL: nil) + } + + @objc func cancelBtnHandler() { + dismiss(animated: true, completion: nil) + } + + @objc func installBtnHandler() { + dismiss(animated: true, completion: { + let package = self.package + do { + // Time to pass the package off to the final installers - the parse__KMP methods. + // ... they should probably be moved to ResourceFileManager eventually. + if package.isKeyboard() { + try Manager.shared.parseKbdKMP(package.sourceFolder) + } else { + try Manager.parseLMKMP(package.sourceFolder) + } + self.completionHandler(nil) + } catch { + log.error(error as! KMPError) + self.completionHandler(error) + } + + //this can fail gracefully and not show errors to users + do { + try FileManager.default.removeItem(at: package.sourceFolder) + } catch { + log.error("unable to delete temp files: \(error)") + self.completionHandler(error) + } + }) + } +} diff --git a/ios/engine/KMEI/KeymanEngine/Classes/KeyboardRepository/ResourceDownloadManager.swift b/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/ResourceDownloadManager.swift similarity index 100% rename from ios/engine/KMEI/KeymanEngine/Classes/KeyboardRepository/ResourceDownloadManager.swift rename to ios/engine/KMEI/KeymanEngine/Classes/Resource Management/ResourceDownloadManager.swift diff --git a/ios/engine/KMEI/KeymanEngine/Classes/KeyboardRepository/ResourceDownloadQueue.swift b/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/ResourceDownloadQueue.swift similarity index 100% rename from ios/engine/KMEI/KeymanEngine/Classes/KeyboardRepository/ResourceDownloadQueue.swift rename to ios/engine/KMEI/KeymanEngine/Classes/Resource Management/ResourceDownloadQueue.swift diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/ResourceFileManager.swift b/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/ResourceFileManager.swift new file mode 100644 index 0000000000..193fc952d3 --- /dev/null +++ b/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/ResourceFileManager.swift @@ -0,0 +1,129 @@ +// +// ResourceFileManager.swift +// KeymanEngine +// +// Created by Joshua Horton on December 18, 2019. +// Copyright © 2019 SIL International. All rights reserved. +// + +import Foundation + +/** + * This class stores common methods used for installing language resources, regardless of source. + * + * It also contains methods for general-purpose installation of language resources from .kmp files. + */ +public class ResourceFileManager { + public static let shared = ResourceFileManager() + + fileprivate init() { + } + + /** + * Apple doesn't provide a method that performs copy-and-overwrite functionality. This function fills in that gap. + */ + private func copyWithOverwrite(from source: URL, to destination: URL) throws { + let fileManager = FileManager.default + + // For now, we'll always allow overwriting. + if fileManager.fileExists(atPath: destination.path) { + try fileManager.removeItem(at: destination) + } + + // Throws an error if the destination file already exists, and there's no + // built-in override parameter. Hence, the previous if-block. + try fileManager.copyItem(at: source, to: destination) + } + + /** + * Use this function to "import" a file from outside the app's designated file system area to a new location within, + * copying the original. It will be placed within the app's Documents folder. + * + * Returns the app-owned destination path, usable for subsequent file operations. + */ + public func importFile(_ url: URL) -> URL? { + var destinationUrl = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + destinationUrl.appendPathComponent(url.lastPathComponent) + + do { + try copyWithOverwrite(from: url, to: destinationUrl) + return destinationUrl + } catch { + log.error(error) + return nil + } + } + + /** + * Use this function to "install" external KMP files to within the Keyman app's alloted iOS file management domain. + * Note that we don't request permissions to support opening/modifying files "in place," so we need to copy .kmps + * before unzipping them. + * + * This implementation does not change how files are managed by the app; only where the file management code + * is located. + */ + @available(iOSApplicationExtension, unavailable) + public func installFile(_ url: URL) { + // Once selected, start the standard install process. + log.info("Installing KMP from \(url)") + + // Step 1: Copy it to a temporary location, making it a .zip in the process + var destinationUrl = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + destinationUrl.appendPathComponent("\(url.lastPathComponent).zip") + + do { + try copyWithOverwrite(from: url, to: destinationUrl) + installAdhocKeyboard(url: destinationUrl) + } catch { + showKMPError(KMPError.copyFiles) + log.error(error) + } + } + + @available(iOSApplicationExtension, unavailable) + private func installAdhocKeyboard(url: URL) { + let documentsDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + var destination = documentsDirectory + destination.appendPathComponent("temp/\(url.lastPathComponent)") + + KeymanPackage.extract(fileUrl: url, destination: destination, complete: { kmp in + if let kmp = kmp { + self.promptAdHocInstall(kmp) + } else { + self.showKMPError(KMPError.invalidPackage) + } + }) + } + + @available(iOSApplicationExtension, unavailable) + private func promptAdHocInstall(_ kmp: KeymanPackage) { + let vc = PackageInstallViewController(for: kmp, completionHandler: { error in + if let err = error { + if let kmpError = err as? KMPError { + self.showKMPError(kmpError) + } + } else { + self.showSimpleAlert(title: "Success", message: "Installed successfully.") + } + }) + + let nvc = UINavigationController.init(rootViewController: vc) + UIApplication.shared.keyWindow?.rootViewController?.present(nvc, animated: true, completion: nil) + } + + @available(iOSApplicationExtension, unavailable) + public func showKMPError(_ error: KMPError) { + showSimpleAlert(title: "Error", message: error.rawValue) + } + + @available(iOSApplicationExtension, unavailable) + public func showSimpleAlert(title: String, message: String) { + let alertController = UIAlertController(title: title, message: message, + preferredStyle: UIAlertController.Style.alert) + alertController.addAction(UIAlertAction(title: "OK", + style: UIAlertAction.Style.default, + handler: nil)) + + UIApplication.shared.keyWindow?.rootViewController?.present(alertController, animated: true, completion: nil) + } +} diff --git a/ios/keyman/Keyman/Keyman/AppDelegate.swift b/ios/keyman/Keyman/Keyman/AppDelegate.swift index 34076ee254..136bdb1928 100644 --- a/ios/keyman/Keyman/Keyman/AppDelegate.swift +++ b/ios/keyman/Keyman/Keyman/AppDelegate.swift @@ -22,27 +22,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { - // .kmp package install, Keyman 10 onwards - var destinationUrl = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - destinationUrl.appendPathComponent("\(url.lastPathComponent).zip") - do { - let fileManager = FileManager.default + // We really should validate that it is a .kmp first... but the app doesn't yet + // process URL links, so it's fine for now. (Will change with QR code stuff.) - // For now, we'll always allow overwriting. - if fileManager.fileExists(atPath: destinationUrl.path) { - try fileManager.removeItem(at: destinationUrl) - } - - // Throws an error if the destination file already exists, and there's no - // built-in override parameter. Hence, the previous if-block. - try fileManager.copyItem(at: url, to: destinationUrl) - installAdhocKeyboard(url: destinationUrl) - return true - } catch { - showKMPError(KMPError.copyFiles) - log.error(error) + guard let destinationUrl = ResourceFileManager.shared.importFile(url) else { return false } + + ResourceFileManager.shared.installFile(destinationUrl) + return true } func application(_ application: UIApplication, @@ -80,13 +68,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return true } - func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -> Bool { - NotificationCenter.default.post(name: launchedFromUrlNotification, object: self, - userInfo: [urlKey: url] - ) - return true - } - func applicationDidEnterBackground(_ application: UIApplication) { _overlayWindow = nil FontManager.shared.unregisterCustomFonts() @@ -123,103 +104,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return _overlayWindow! } - public func installAdhocKeyboard(url: URL) { - let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - var destination = documentsDirectory - destination.appendPathComponent("temp/\(url.lastPathComponent)") - - KeymanPackage.extract(fileUrl: url, destination: destination, complete: { kmp in - if let kmp = kmp { - self.promptAdHocInstall(kmp) - } else { - self.showKMPError(KMPError.invalidPackage) - } - }) - } - - public func showKMPError(_ error: KMPError) { - showSimpleAlert(title: "Error", message: error.rawValue) - } - - public func showSimpleAlert(title: String, message: String) { - let alertController = UIAlertController(title: title, message: message, - preferredStyle: UIAlertController.Style.alert) - alertController.addAction(UIAlertAction(title: "OK", - style: UIAlertAction.Style.default, - handler: nil)) - - self.window?.rootViewController?.present(alertController, animated: true, completion: nil) - } - - public func promptAdHocInstall(_ kmp: KeymanPackage) { - _adhocDirectory = kmp.sourceFolder - let isKbd = kmp.isKeyboard() - - let vc = UIViewController() - vc.view.backgroundColor = .red - let wkWebView = WKWebView.init(frame: vc.view.frame) - wkWebView.backgroundColor = .white - vc.view.addSubview(wkWebView) - let cancelBtn = UIBarButtonItem(title: "Cancel", style: .plain, - target: self, - action: #selector(cancelAdHocBtnHandler)) - let installBtn = UIBarButtonItem(title: "Install", style: .plain, - target: self, - action: (isKbd ? #selector(installAdHocKeyboardBtnHandler) : - #selector(installAdHocLexicalModelBtnHandler)) ) - vc.navigationItem.leftBarButtonItem = cancelBtn - vc.navigationItem.rightBarButtonItem = installBtn - let nvc = UINavigationController.init(rootViewController: vc) - - self.window?.rootViewController?.present(nvc, animated: true, completion: { - wkWebView.loadHTMLString(kmp.infoHtml(), baseURL: nil) - }) - } - - @objc func installAdHocKeyboardBtnHandler() { - if let adhocDir = _adhocDirectory { - self.window?.rootViewController?.dismiss(animated: true, completion: { - do { - try Manager.shared.parseKbdKMP(adhocDir) - self.showSimpleAlert(title: "Success", message: "Installed successfully.") - } catch { - self.showKMPError(error as! KMPError) - } - - //this can fail gracefully and not show errors to users - do { - try FileManager.default.removeItem(at: adhocDir) - } catch { - log.error("unable to delete temp files") - } - }) - } - } - - @objc func installAdHocLexicalModelBtnHandler() { - if let adhocDir = _adhocDirectory { - self.window?.rootViewController?.dismiss(animated: true, completion: { - do { - try Manager.parseLMKMP(adhocDir) - self.showSimpleAlert(title: "Success", message: "Installed successfully.") - } catch { - self.showKMPError(error as! KMPError) - } - - //this can fail gracefully and not show errors to users - do { - try FileManager.default.removeItem(at: adhocDir) - } catch { - log.error("unable to delete temp files") - } - }) - } - } - - @objc func cancelAdHocBtnHandler() { - self.window?.rootViewController?.dismiss(animated: true, completion: nil) - } - @objc func registerCustomFonts() { FontManager.shared.registerCustomFonts() } diff --git a/ios/keyman/Keyman/Keyman/MainViewController.swift b/ios/keyman/Keyman/Keyman/MainViewController.swift index 47572ab10d..8db8d1db5f 100644 --- a/ios/keyman/Keyman/Keyman/MainViewController.swift +++ b/ios/keyman/Keyman/Keyman/MainViewController.swift @@ -476,7 +476,7 @@ class MainViewController: UIViewController, TextViewDelegate, UIActionSheetDeleg if launchUrl != nil { perform(#selector(self.dismissActivityIndicator), with: nil, afterDelay: 1.0) let error = notification.error - appDelegate.showSimpleAlert(title: "Keyboard Download Error", message: error.localizedDescription) + ResourceFileManager.shared.showSimpleAlert(title: "Keyboard Download Error", message: error.localizedDescription) launchUrl = nil } } @@ -825,7 +825,7 @@ class MainViewController: UIViewController, TextViewDelegate, UIActionSheetDeleg if let urlString = params["url"] { // Download and set custom keyboard guard let url = URL(string: urlString) else { - appDelegate.showSimpleAlert(title: "Custom Keyboard", + ResourceFileManager.shared.showSimpleAlert(title: "Custom Keyboard", message: "The keyboard could not be installed: Invalid Url") launchUrl = nil return