// // KeymanWebViewController.swift // KeymanEngine // // Created by Gabriel Wong on 2017-10-31. // Copyright © 2017 SIL International. All rights reserved. // import UIKit import WebKit private let keyboardChangeHelpText = "Tap here to change keyboard" private let subKeyColor = #colorLiteral(red: 244.0 / 255.0, green: 244.0 / 255.0, blue: 244.0 / 255.0, alpha: 1.0) private let subKeyColorHighlighted = #colorLiteral(red: 136.0 / 255.0, green: 136.0 / 255.0, blue: 1.0, alpha: 1.0) // UI In-App Keyboard Constants private let phonePortraitInAppKeyboardHeight: CGFloat = 183.0 private let phoneLandscapeInAppKeyboardHeight: CGFloat = 183.0 private let padPortraitInAppKeyboardHeight: CGFloat = 385.0 private let padLandscapeInAppKeyboardHeight: CGFloat = 385.0 // UI System Keyboard Constants private let phonePortraitSystemKeyboardHeight: CGFloat = 216.0 private let phoneLandscapeSystemKeyboardHeight: CGFloat = 162.0 private let padPortraitSystemKeyboardHeight: CGFloat = 264.0 private let padLandscapeSystemKeyboardHeight: CGFloat = 352.0 // MARK: - UIViewController class KeymanWebViewController: UIViewController { let storage: Storage weak var delegate: KeymanWebDelegate? private var useSpecialFont = false // Views var webView: WKWebView! private var helpBubbleView: PopoverView? private var keyPreviewView: KeyPreviewView? private var subKeysView: SubKeysView? private var keyboardMenuView: KeyboardMenuView? // Arrays private var subKeyIDs: [String] = [] private var subKeyTexts: [String] = [] private var subKeys: [UIButton] = [] private var subKeyAnchor = CGRect.zero init(storage: Storage) { self.storage = storage super.init(nibName: nil, bundle: nil) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func loadView() { let config = WKWebViewConfiguration() let prefs = WKPreferences() prefs.javaScriptEnabled = true config.preferences = prefs config.suppressesIncrementalRendering = false let userContentController = WKUserContentController() userContentController.add(self, name: "keyman") config.userContentController = userContentController webView = WKWebView(frame: CGRect(origin: .zero, size: keyboardSize), configuration: config) webView.isOpaque = false webView.translatesAutoresizingMaskIntoConstraints = false webView.backgroundColor = UIColor.clear webView.navigationDelegate = self webView.scrollView.isScrollEnabled = false view = webView // Set UILongPressGestureRecognizer to show sub keys let hold = UILongPressGestureRecognizer(target: self, action: #selector(self.holdAction)) hold.minimumPressDuration = 0.5 hold.delegate = self view.addGestureRecognizer(hold) NotificationCenter.default.addObserver(self, selector: #selector(self.keyboardWillShow), name: .UIKeyboardWillShow, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(self.keyboardWillHide), name: .UIKeyboardWillHide, object: nil) reloadKeyboard() } override func viewWillAppear(_ animated: Bool) { view.frame = CGRect(origin: .zero, size: keyboardSize) super.viewWillAppear(animated) } } // MARK: - JavaScript functions extension KeymanWebViewController { func languageMenuPosition(_ completion: @escaping (CGRect) -> Void) { webView.evaluateJavaScript("langMenuPos();") { result, _ in guard let result = result as? String, !result.isEmpty else { return } let components = result.components(separatedBy: ",") let x = CGFloat(Float(components[0])!) let y = CGFloat(Float(components[1])!) let w = CGFloat(Float(components[2])!) let h = CGFloat(Float(components[3])!) completion(KeymanWebViewController.keyFrame(x: x, y: y, w: w, h: h)) } } // FIXME: text is unused in the JS func executePopupKey(id: String, text: String) { // Text must be checked for ', ", and \ characters; they must be escaped properly! do { let encodingArray = [ text ]; let jsonString = try String(data: JSONSerialization.data(withJSONObject: encodingArray), encoding: .utf8)! let start = jsonString.index(jsonString.startIndex, offsetBy: 2) let end = jsonString.index(jsonString.endIndex, offsetBy: -2) let escapedText = jsonString[start.. [String: Any]? { guard let font = font else { return nil } // family does not have to match the name in the font file. It only has to be unique. return [ "family": "\(keyboardID)__\(isOsk ? "osk" : "display")", "files": font.source.map { storage.fontURL(forKeyboardID: keyboardID, filename: $0).absoluteString } ] } func setKeyboard(_ keyboard: InstallableKeyboard) { var stub: [String: Any] = [ "KI": "Keyboard_\(keyboard.id)", "KN": keyboard.name, "KLC": keyboard.languageID, "KL": keyboard.languageName, "KF": storage.keyboardURL(for: keyboard).absoluteString ] let displayFont = fontObject(from: keyboard.font, keyboardID: keyboard.id, isOsk: false) let oskFont = fontObject(from: keyboard.oskFont, keyboardID: keyboard.id, isOsk: true) ?? displayFont if let displayFont = displayFont { stub["KFont"] = displayFont } if let oskFont = oskFont { stub["KOskFont"] = oskFont } let data: Data do { data = try JSONSerialization.data(withJSONObject: stub, options: []) } catch { log.error("Failed to serialize keyboard stub: \(error)") return } guard let stubString = String(data: data, encoding: .utf8) else { log.error("Failed to create stub string") return } log.debug("Keyboard stub: \(stubString)") webView.evaluateJavaScript("setKeymanLanguage(\(stubString));", completionHandler: nil) } } // MARK: - WKScriptMessageHandler extension KeymanWebViewController: WKScriptMessageHandler { func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard let fragment = message.body as? String, !fragment.isEmpty else { return } if fragment.hasPrefix("#insertText-") { let dnRange = fragment.range(of: "+dn=")! let sRange = fragment.range(of: "+s=")! let dn = Int(fragment[dnRange.upperBound.. CGRect { // kmw adds w/2 to x. return CGRect(x: x - w / 2.0, y: y, width: w, height: h) } } // MARK: - WKNavigationDelegate extension KeymanWebViewController: WKNavigationDelegate { func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { guard let url = webView.url else { return } guard url.lastPathComponent == Resources.kmwFilename && (url.fragment?.isEmpty ?? true) else { return } keyboardLoaded(self) delegate?.keyboardLoaded(self) } } // MARK: - KeymanWebDelegate extension KeymanWebViewController: KeymanWebDelegate { func insertText(_ view: KeymanWebViewController, numCharsToDelete: Int, newText: String) { dismissHelpBubble() Manager.shared.isKeymanHelpOn = false } func showKeyPreview(_ view: KeymanWebViewController, keyFrame: CGRect, preview: String) { if UIDevice.current.userInterfaceIdiom == .pad || (Util.isSystemKeyboard && !Manager.shared.isSystemKeyboardTopBarEnabled) || isSubKeysMenuVisible { return } dismissKeyPreview() clearSubKeyArrays() keyPreviewView = KeyPreviewView(frame: keyFrame) keyPreviewView!.setLabelText(preview) let oskFontName = Manager.shared.oskFontNameForKeyboard(withFullID: Manager.shared.currentKeyboardID!) ?? Manager.shared.fontNameForKeyboard(withFullID: Manager.shared.currentKeyboardID!) keyPreviewView!.setLabelFont(oskFontName) self.view.addSubview(keyPreviewView!) } func dismissKeyPreview(_ view: KeymanWebViewController) { if UIDevice.current.userInterfaceIdiom == .pad || keyPreviewView == nil { return } let dismissKeyPreview = #selector(self.dismissKeyPreview as () -> Void) NSObject.cancelPreviousPerformRequests(withTarget: self, selector: dismissKeyPreview, object: nil) perform(dismissKeyPreview, with: nil, afterDelay: 0.1) clearSubKeyArrays() } func showSubkeys(_ view: KeymanWebViewController, keyFrame: CGRect, subkeyIDs: [String], subkeyTexts: [String], useSpecialFont: Bool) { dismissHelpBubble() Manager.shared.isKeymanHelpOn = false dismissSubKeys() dismissKeyboardMenu() subKeyAnchor = keyFrame subKeyIDs = subkeyIDs subKeyTexts = subkeyTexts self.useSpecialFont = useSpecialFont } func menuKeyUp(_ view: KeymanWebViewController) { dismissHelpBubble() Manager.shared.isKeymanHelpOn = false if Util.isSystemKeyboard { let userData = UserDefaults.standard userData.set(true, forKey: Key.keyboardPickerDisplayed) userData.synchronize() } } func hideKeyboard(_ view: KeymanWebViewController) { dismissHelpBubble() dismissSubKeys() dismissKeyboardMenu() } } // MARK: - UIGestureRecognizerDelegate extension KeymanWebViewController: UIGestureRecognizerDelegate { public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { return true } @objc func holdAction(_ sender: UILongPressGestureRecognizer) { switch sender.state { case .ended: // Touch Ended if let subKeysView = subKeysView { subKeysView.removeFromSuperview() subKeysView.subviews.forEach { $0.removeFromSuperview() } self.subKeysView = nil setPopupVisible(false) } var buttonClicked = false for button in subKeys where button.isHighlighted { button.isHighlighted = false button.backgroundColor = subKeyColor button.isEnabled = false button.sendActions(for: .touchUpInside) buttonClicked = true break } if !buttonClicked { clearSubKeyArrays() } case .began: // Touch & Hold Began let touchPoint = sender.location(in: sender.view) // Check if touch was for language menu button languageMenuPosition { keyFrame in if keyFrame.contains(touchPoint) { self.delegate?.menuKeyHeld(self) return } self.touchHoldBegan() } default: // Hold & Move guard let subKeysView = subKeysView else { return } let touchPoint = sender.location(in: subKeysView.containerView) for button in subKeys { if button.frame.contains(touchPoint) { button.isEnabled = true button.isHighlighted = true button.backgroundColor = subKeyColorHighlighted } else { button.isHighlighted = false button.isEnabled = false button.backgroundColor = subKeyColor } } } } private func touchHoldBegan() { let isPad = UIDevice.current.userInterfaceIdiom == .pad let fontSize = isPad ? UIFont.buttonFontSize * 2 : UIFont.buttonFontSize let oskFontName = Manager.shared.oskFontNameForKeyboard(withFullID: Manager.shared.currentKeyboardID!) ?? Manager.shared.fontNameForKeyboard(withFullID: Manager.shared.currentKeyboardID!) if subKeyIDs.isEmpty { subKeys = [] return } subKeys = subKeyTexts.enumerated().map { i, subKeyText in let button = UIButton(type: .custom) button.tag = i button.backgroundColor = subKeyColor button.setRoundedBorder(withRadius: 4.0, borderWidth: 1.0, color: .gray) button.setTitleColor(.black, for: .disabled) button.setTitleColor(.black, for: .highlighted) if let oskFontName = oskFontName { button.titleLabel?.font = UIFont(name: oskFontName, size: fontSize) } else { button.titleLabel?.font = UIFont.systemFont(ofSize: fontSize) } if useSpecialFont{ if FontManager.shared.registerFont(at: Storage.active.specialOSKFontURL), let fontName = FontManager.shared.fontName(at: Storage.active.specialOSKFontURL) { button.titleLabel?.font = UIFont(name: fontName, size: fontSize) } button.setTitleColor(.gray, for: .disabled) } button.addTarget(self, action: #selector(subKeyButtonClick), for: .touchUpInside) button.setTitle(subKeyText, for: .normal) button.tintColor = UIColor(red: 181.0 / 255.0, green: 181.0 / 255.0, blue: 181.0 / 255.0, alpha: 1.0) button.isEnabled = false return button } dismissKeyPreview() subKeysView = SubKeysView(keyFrame: subKeyAnchor, subKeys: subKeys) view.addSubview(subKeysView!) setPopupVisible(true) } @objc func subKeyButtonClick(_ sender: UIButton) { let keyIndex = sender.tag if keyIndex < subKeyIDs.count && keyIndex < subKeyTexts.count { let subKeyID = subKeyIDs[keyIndex] let subKeyText = subKeyTexts[keyIndex] executePopupKey(id: subKeyID, text: subKeyText) } subKeys.removeAll() subKeyIDs.removeAll() subKeyTexts.removeAll() } } // MARK: - Manage views extension KeymanWebViewController { // MARK: - Sizing public var keyboardHeight: CGFloat { if Util.isSystemKeyboard { return keyboardHeight(isPortrait: InputViewController.isPortrait) } else { return keyboardHeight(isPortrait: UIDevice.current.orientation.isPortrait) } } func keyboardHeight(with orientation: UIInterfaceOrientation) -> CGFloat { return keyboardHeight(isPortrait: orientation.isPortrait) } func keyboardHeight(isPortrait: Bool) -> CGFloat { let isSystemKeyboard = Util.isSystemKeyboard if UIDevice.current.userInterfaceIdiom == .pad { if isPortrait { return isSystemKeyboard ? padPortraitSystemKeyboardHeight : padPortraitInAppKeyboardHeight } else { return isSystemKeyboard ? padLandscapeSystemKeyboardHeight : padLandscapeInAppKeyboardHeight } } else { if isPortrait { return isSystemKeyboard ? phonePortraitSystemKeyboardHeight : phonePortraitInAppKeyboardHeight } else { return isSystemKeyboard ? phoneLandscapeSystemKeyboardHeight : phoneLandscapeInAppKeyboardHeight } } } var keyboardWidth: CGFloat { return UIScreen.main.bounds.width } var keyboardSize: CGSize { return CGSize(width: keyboardWidth, height: keyboardHeight) } func resizeKeyboard() { let newSize = keyboardSize view.frame = CGRect(origin: .zero, size: newSize) setOskWidth(Int(newSize.width)) setOskHeight(Int(newSize.height)) } @objc func resizeDelay() { // + 1000 to work around iOS bug with resizing on landscape orientation. Technically we only // need this for landscape but it doesn't hurt to do it with both. 1000 is a big number that // should hopefully work on all devices. let kbWidth = keyboardWidth let kbHeight = keyboardHeight view.frame = CGRect(x: 0.0, y: 0.0, width: kbWidth, height: kbHeight + 1000) } func resizeKeyboard(with orientation: UIInterfaceOrientation) { // TODO: Update to use new size instead of orientation since viewWillRotate() is deprecated // TODO: Refactor to use resizeKeyboard() let kbWidth = keyboardWidth let kbHeight = keyboardHeight(with: orientation) view.frame = CGRect(x: 0.0, y: 0.0, width: kbWidth, height: kbHeight) var oskHeight = Int(kbHeight) oskHeight -= oskHeight % (Util.isSystemKeyboard ? 10 : 20) setOskWidth(Int(kbWidth)) setOskHeight(oskHeight) } // MARK: - Show/hide views func reloadKeyboard() { if #available(iOS 9.0, *) { webView.loadFileURL(Storage.active.kmwURL, allowingReadAccessTo: Storage.active.baseDir) } else { // WKWebView in iOS < 9 is missing loadFileURL(). let request = URLRequest(url: Storage.active.kmwURL, cachePolicy: .reloadIgnoringCacheData, timeoutInterval: 60.0) webView.load(request) } } @objc func showHelpBubble() { // Help bubble is always disabled for system-wide keyboard if Util.isSystemKeyboard || keyboardMenuView != nil { return } languageMenuPosition { keyFrame in self.showHelpBubble(at: keyFrame.origin) } } // TODO: The bulk of this should be moved to PopoverView private func showHelpBubble(at point: CGPoint) { self.helpBubbleView?.removeFromSuperview() let helpBubbleView = PopoverView(frame: CGRect.zero) self.helpBubbleView = helpBubbleView helpBubbleView.backgroundColor = UIColor(red: 253.0 / 255.0, green: 244.0 / 255.0, blue: 196.0 / 255.0, alpha: 1.0) helpBubbleView.backgroundColor2 = UIColor(red: 233.0 / 255.0, green: 224.0 / 255.0, blue: 176.0 / 255.0, alpha: 1.0) helpBubbleView.borderColor = UIColor(red: 0.5, green: 0.25, blue: 0.25, alpha: 1.0) let isPad = UIDevice.current.userInterfaceIdiom == .pad let sizeMultiplier = CGFloat(isPad ? 1.5 : 1.0) let frameWidth = 90.0 * sizeMultiplier let frameHeight = (40.0 + helpBubbleView.arrowHeight) * sizeMultiplier let fontSize = 10.0 * sizeMultiplier let inputViewFrame = view.frame let screenWidth = inputViewFrame.size.width // TODO: Refactor this out let isPortrait = UIDevice.current.orientation.isPortrait let adjY: CGFloat if isPortrait { adjY = Util.isSystemKeyboard ? 9.0 : 4.0 } else { adjY = Util.isSystemKeyboard ? 3.0 : 4.0 } let px = point.x let py = point.y + adjY + (isPad ? 2.0 : 1.0) var x = px - frameWidth / 2 let y = py - frameHeight if x < 0 { x = 0 } else if x + frameWidth > screenWidth { x = screenWidth - frameWidth } helpBubbleView.frame = CGRect(x: x, y: y, width: frameWidth, height: frameHeight) if x == 0 { helpBubbleView.arrowPosX = px } else if x == screenWidth - frameWidth { helpBubbleView.arrowPosX = (px - x) } else { helpBubbleView.arrowPosX = frameWidth / 2 } let helpText = UILabel(frame: CGRect(x: 5, y: 0, width: frameWidth - 10, height: frameHeight - helpBubbleView.arrowHeight)) helpText.backgroundColor = UIColor.clear helpText.font = helpText.font.withSize(fontSize) helpText.textAlignment = .center helpText.textColor = UIColor.darkText helpText.lineBreakMode = .byWordWrapping helpText.numberOfLines = 0 helpText.text = keyboardChangeHelpText helpBubbleView.addSubview(helpText) view.addSubview(helpBubbleView) } func dismissHelpBubble() { helpBubbleView?.removeFromSuperview() helpBubbleView = nil } @objc func dismissKeyPreview() { keyPreviewView?.removeFromSuperview() keyPreviewView = nil } var isSubKeysMenuVisible: Bool { return subKeysView != nil } func dismissSubKeys() { if let subKeysView = subKeysView { subKeysView.removeFromSuperview() subKeysView.subviews.forEach { $0.removeFromSuperview() } self.subKeysView = nil setPopupVisible(false) } clearSubKeyArrays() } func clearSubKeyArrays() { if subKeysView == nil { subKeys.removeAll() subKeyIDs.removeAll() subKeyTexts.removeAll() } } func showHelpBubble(afterDelay delay: TimeInterval) { helpBubbleView?.removeFromSuperview() let showHelpBubble = #selector(self.showHelpBubble as () -> Void) NSObject.cancelPreviousPerformRequests(withTarget: self, selector: showHelpBubble, object: nil) perform(showHelpBubble, with: nil, afterDelay: delay) } func showKeyboardMenu(_ ic: InputViewController, closeButtonTitle: String?) { let parentView = ic.view ?? view languageMenuPosition { keyFrame in if keyFrame != .zero { self.keyboardMenuView?.removeFromSuperview() self.keyboardMenuView = KeyboardMenuView(keyFrame: keyFrame, inputViewController: ic, closeButtonTitle: closeButtonTitle) parentView?.addSubview(self.keyboardMenuView!) } } } func dismissKeyboardMenu() { keyboardMenuView?.removeFromSuperview() keyboardMenuView = nil } var isKeyboardMenuVisible: Bool { return keyboardMenuView != nil } } // MARK: - Keyboard Notifications extension KeymanWebViewController { @objc func keyboardWillShow(_ notification: Notification) { dismissSubKeys() dismissKeyPreview() resizeKeyboard() if Manager.shared.isKeymanHelpOn { showHelpBubble(afterDelay: 1.5) } } @objc func keyboardWillHide(_ notification: Notification) { dismissHelpBubble() dismissSubKeys() dismissKeyPreview() } }