diff --git a/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj b/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj index af3372d603..3f043a9fe3 100644 --- a/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj +++ b/ios/engine/KMEI/KeymanEngine.xcodeproj/project.pbxproj @@ -210,6 +210,8 @@ CEA1486C2407808F00C6ECD2 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = CEA1486F2407808F00C6ECD2 /* Localizable.strings */; }; CEA1486D2407808F00C6ECD2 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = CEA1486F2407808F00C6ECD2 /* Localizable.strings */; }; CEA14870240780E100C6ECD2 /* ResourceInfoView.xib in Resources */ = {isa = PBXBuildFile; fileRef = CEA14872240780E100C6ECD2 /* ResourceInfoView.xib */; }; + CE9E95CD24CE786900F6DD78 /* UniversalLinks.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE9E95CC24CE786900F6DD78 /* UniversalLinks.swift */; }; + CE9E95CF24CE7A2C00F6DD78 /* UniversalLinkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE9E95CE24CE7A2C00F6DD78 /* UniversalLinkTests.swift */; }; CEA6BA9D249872E8002D44CE /* Simple 13.0 Migration.bundle in Resources */ = {isa = PBXBuildFile; fileRef = CEA6BA9C249872E7002D44CE /* Simple 13.0 Migration.bundle */; }; CEA70CCF24CAC3AE001C12E6 /* Obsoletions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEA70CCE24CAC3AE001C12E6 /* Obsoletions.swift */; }; CEA9670924BEC4030035AACF /* ResourceUpdateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEA9670824BEC4030035AACF /* ResourceUpdateTests.swift */; }; @@ -515,6 +517,8 @@ CEA14871240780E100C6ECD2 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/ResourceInfoView.xib; sourceTree = ""; }; CEA14874240780EF00C6ECD2 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/ResourceInfoView.strings; sourceTree = ""; }; CEA148772407869200C6ECD2 /* km */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = km; path = km.lproj/ResourceInfoView.strings; sourceTree = ""; }; + CE9E95CC24CE786900F6DD78 /* UniversalLinks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalLinks.swift; sourceTree = ""; }; + CE9E95CE24CE7A2C00F6DD78 /* UniversalLinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalLinkTests.swift; sourceTree = ""; }; CEA6BA9C249872E7002D44CE /* Simple 13.0 Migration.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = "Simple 13.0 Migration.bundle"; sourceTree = ""; }; CEA70CCE24CAC3AE001C12E6 /* Obsoletions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Obsoletions.swift; sourceTree = ""; }; CEA9670824BEC4030035AACF /* ResourceUpdateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceUpdateTests.swift; sourceTree = ""; }; @@ -778,6 +782,7 @@ CED8B63124A9C2400054E300 /* ResourceDownloadManagerTests.swift */, CEA9670824BEC4030035AACF /* ResourceUpdateTests.swift */, CE88143924A9B7F4002809C3 /* ResourceDownloadQueueTests.swift */, + CE9E95CE24CE7A2C00F6DD78 /* UniversalLinkTests.swift */, ); path = KeymanEngineTests; sourceTree = ""; @@ -1096,6 +1101,7 @@ CE79B24823C711FF007E72AE /* KeyboardScaleMap.swift */, CE7ADD6523DE89FC00BC9A00 /* Alerts.swift */, CEA70CCE24CAC3AE001C12E6 /* Obsoletions.swift */, + CE9E95CC24CE786900F6DD78 /* UniversalLinks.swift */, ); path = Classes; sourceTree = ""; @@ -1498,6 +1504,7 @@ CEDFEF8F23FE43B700BECF39 /* MigrationTests.swift in Sources */, CED8B63224A9C2400054E300 /* ResourceDownloadManagerTests.swift in Sources */, CE9CD88023FCC1CA002BF2F8 /* TestUtils.swift in Sources */, + CE9E95CF24CE7A2C00F6DD78 /* UniversalLinkTests.swift in Sources */, CE973D812484A56500F66045 /* PackageJSON.swift in Sources */, CE88143A24A9B7F4002809C3 /* ResourceDownloadQueueTests.swift in Sources */, CE8EDEB123F53D1A009E1FF6 /* FileManagementTests.swift in Sources */, @@ -1540,6 +1547,7 @@ C0B901AA1FA1AFC200764EB8 /* UserDefaults+Types.swift in Sources */, CE1F67A32304EB3800FF6972 /* ResourceDownloadManager.swift in Sources */, 9A079E372238680700581263 /* KMPLexicalModel.swift in Sources */, + CE9E95CD24CE786900F6DD78 /* UniversalLinks.swift in Sources */, CE89641F24A4686000D5EB8E /* Queries.swift in Sources */, CE5C8BE324B5B3BA00FAFB7F /* Queries+LexicalModel.swift in Sources */, 9A3E832522EAC14A00D22D2A /* KeyboardSwitcherViewController.swift in Sources */, diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Model/FullKeyboardID.swift b/ios/engine/KMEI/KeymanEngine/Classes/Model/FullKeyboardID.swift index e97f53e9a0..67f98e7c54 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Model/FullKeyboardID.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Model/FullKeyboardID.swift @@ -22,6 +22,11 @@ public struct FullKeyboardID: Codable, LanguageResourceFullID, Equatable { public var type: LanguageResourceType { return .keyboard } + + public init(keyboardID: String, languageID: String) { + self.keyboardID = keyboardID + self.languageID = languageID + } } // MARK: - CustomStringConvertible diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/KeyboardSearchViewController.swift b/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/KeyboardSearchViewController.swift index 7a15950f0e..e505172194 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/KeyboardSearchViewController.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/KeyboardSearchViewController.swift @@ -80,8 +80,6 @@ public class KeyboardSearchViewController: UIViewController, WKNavigationDelegat return baseURL } - private static let REGEX_FOR_DOWNLOAD_INTERCEPT = try! NSRegularExpression(pattern: "^http(?:s)?:\\/\\/[^\\/]+\\/keyboards\\/install\\/([^?\\/]+)(?:\\?(.+))?$") - public init(languageCode: String? = nil, withSession session: URLSession = URLSession.shared, keyboardSelectionBlock: @escaping SelectionCompletedHandler) { @@ -115,13 +113,13 @@ public class KeyboardSearchViewController: UIViewController, WKNavigationDelegat decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { if navigationAction.navigationType == .linkActivated { let link = navigationAction.request.url! - if let (keyboard_id, lang_id) = KeyboardSearchViewController.tryParseLink(link) { + if let parsedLink = UniversalLinks.tryParseKeyboardInstallLink(link) { decisionHandler(.cancel) // Notify our caller of the search results. self.hasFinalized = true // Prevent popViewController from triggering cancellation events. self.navigationController?.popViewController(animated: true) // Rewind UI - finalize(with: keyboard_id, for: lang_id) + finalize(with: parsedLink) return } } @@ -136,35 +134,12 @@ public class KeyboardSearchViewController: UIViewController, WKNavigationDelegat } } - internal static func tryParseLink(_ link: URL) -> (String, String?)? { - let linkString = link.absoluteString - - // If it matches the format for the Keyboard Universal Link URL Pattern... - // (see https://docs.google.com/document/d/1rhgMeJlCdXCi6ohPb_CuyZd0PZMoSzMqGpv1A8cMFHY/edit?ts=5f11cb13#heading=h.qw7pas2adckj) - if let match = REGEX_FOR_DOWNLOAD_INTERCEPT.firstMatch(in: linkString, - options: [], - range: NSRange(location: 0, length: linkString.utf16.count)) { - let keyboard_id_range = Range(match.range(at: 1), in: linkString)! - let keyboard_id = String(linkString[keyboard_id_range]) - - var lang_id: String? = nil - let urlComponents = URLComponents(string: linkString)! - if let lang_id_component = urlComponents.queryItems?.first(where: { $0.name == "bcp47" }) { - lang_id = lang_id_component.value - } - - return (keyboard_id, lang_id) - } else { - return nil - } - } - - internal func finalize(with keyboard_id: String, for lang_id: String?) { - let packageKey = KeymanPackage.Key(id: keyboard_id, type: .keyboard) + internal func finalize(with parsedLink: UniversalLinks.ParsedKeyboardInstallLink) { + let packageKey = KeymanPackage.Key(id: parsedLink.keyboard_id, type: .keyboard) // If we have a language ID AND do not yet have a model for it. - if let lang_id = lang_id { - let resourceKey = FullKeyboardID(keyboardID: keyboard_id, languageID: lang_id) + if let lang_id = parsedLink.lang_id { + let resourceKey = FullKeyboardID(keyboardID: parsedLink.keyboard_id, languageID: lang_id) let kbdURL = ResourceDownloadManager.shared.defaultDownloadURL(forPackage: packageKey, andResource: resourceKey, asUpdate: false) self.keyboardSelectionClosure(.tagged(packageKey, kbdURL, resourceKey)) } else { diff --git a/ios/engine/KMEI/KeymanEngine/Classes/UniversalLinks.swift b/ios/engine/KMEI/KeymanEngine/Classes/UniversalLinks.swift new file mode 100644 index 0000000000..0e82bff10b --- /dev/null +++ b/ios/engine/KMEI/KeymanEngine/Classes/UniversalLinks.swift @@ -0,0 +1,45 @@ +// +// UniversalLinks.swift +// KeymanEngine +// +// Created by Joshua Horton on 7/27/20. +// Copyright © 2020 SIL International. All rights reserved. +// + +import Foundation + +public class UniversalLinks { + public struct ParsedKeyboardInstallLink { + public let keyboard_id: String + public let lang_id: String? + + public var packageKey: KeymanPackage.Key { + return KeymanPackage.Key(id: keyboard_id, type: .keyboard) + } + } + + private static let KEYBOARD_INSTALL_LINK_REGEX = try! NSRegularExpression(pattern: "^http(?:s)?:\\/\\/[^\\/]+\\/keyboards\\/install\\/([^?\\/]+)(?:\\?(.+))?$") + + public static func tryParseKeyboardInstallLink(_ link: URL) -> ParsedKeyboardInstallLink? { + let linkString = link.absoluteString + + // If it matches the format for the Keyboard Universal Link URL Pattern... + // (see https://docs.google.com/document/d/1rhgMeJlCdXCi6ohPb_CuyZd0PZMoSzMqGpv1A8cMFHY/edit?ts=5f11cb13#heading=h.qw7pas2adckj) + if let match = KEYBOARD_INSTALL_LINK_REGEX.firstMatch(in: linkString, + options: [], + range: NSRange(location: 0, length: linkString.utf16.count)) { + let keyboard_id_range = Range(match.range(at: 1), in: linkString)! + let keyboard_id = String(linkString[keyboard_id_range]) + + var lang_id: String? = nil + let urlComponents = URLComponents(string: linkString)! + if let lang_id_component = urlComponents.queryItems?.first(where: { $0.name == "bcp47" }) { + lang_id = lang_id_component.value + } + + return ParsedKeyboardInstallLink(keyboard_id: keyboard_id, lang_id: lang_id) + } else { + return nil + } + } +} diff --git a/ios/engine/KMEI/KeymanEngineTests/KeyboardSearchTests.swift b/ios/engine/KMEI/KeymanEngineTests/KeyboardSearchTests.swift index 143e3742de..88e262f1e5 100644 --- a/ios/engine/KMEI/KeymanEngineTests/KeyboardSearchTests.swift +++ b/ios/engine/KMEI/KeymanEngineTests/KeyboardSearchTests.swift @@ -32,40 +32,6 @@ class KeyboardSearchTests: XCTestCase { } } - func testTryLinkParse() { - var tuple = KeyboardSearchViewController.tryParseLink(URL.init(string: "\(KeymanHosts.KEYMAN_COM)/randomURL")!) - - XCTAssertNil(tuple) - - tuple = KeyboardSearchViewController.tryParseLink(URL.init(string: "\(KeymanHosts.KEYMAN_COM)/keyboards/install/khmer_angkor")!) - - if let tuple = tuple { - XCTAssertEqual(tuple.0, "khmer_angkor") - XCTAssertNil(tuple.1) - } - - tuple = KeyboardSearchViewController.tryParseLink(URL.init(string: "\(KeymanHosts.KEYMAN_COM)/keyboards/install/khmer_angkor?bcp47=km")!) - - if let tuple = tuple { - XCTAssertEqual(tuple.0, "khmer_angkor") - XCTAssertEqual(tuple.1, "km") - } - - tuple = KeyboardSearchViewController.tryParseLink(URL.init(string: "\(KeymanHosts.KEYMAN_COM)/keyboards/install/sil_euro_latin")!) - - if let tuple = tuple { - XCTAssertEqual(tuple.0, "sil_euro_latin") - XCTAssertNil(tuple.1) - } - - tuple = KeyboardSearchViewController.tryParseLink(URL.init(string: "\(KeymanHosts.KEYMAN_COM)/keyboards/install/foo?bcp47=bar")!) - - if let tuple = tuple { - XCTAssertEqual(tuple.0, "foo") - XCTAssertEqual(tuple.1, "bar") - } - } - func testFinalizeNoLanguage() { let kbdExpectation = XCTestExpectation() @@ -84,7 +50,7 @@ class KeyboardSearchTests: XCTestCase { let searchNoLang = KeyboardSearchViewController(languageCode: nil, withSession: mockedURLSession!, keyboardSelectionBlock: kbdBlock) - searchNoLang.finalize(with: "khmer_angkor", for: nil) + searchNoLang.finalize(with: UniversalLinks.ParsedKeyboardInstallLink(keyboard_id: "khmer_angkor", lang_id: nil)) wait(for: [kbdExpectation], timeout: 5) } @@ -107,7 +73,7 @@ class KeyboardSearchTests: XCTestCase { let search = KeyboardSearchViewController(languageCode: nil, withSession: mockedURLSession!, keyboardSelectionBlock: kbdBlock) - search.finalize(with: "sil_euro_latin", for: "en") + search.finalize(with: UniversalLinks.ParsedKeyboardInstallLink(keyboard_id: "sil_euro_latin", lang_id: "en")) wait(for: [kbdExpectation], timeout: 5) } diff --git a/ios/engine/KMEI/KeymanEngineTests/UniversalLinkTests.swift b/ios/engine/KMEI/KeymanEngineTests/UniversalLinkTests.swift new file mode 100644 index 0000000000..7512121ead --- /dev/null +++ b/ios/engine/KMEI/KeymanEngineTests/UniversalLinkTests.swift @@ -0,0 +1,74 @@ +// +// UniversalLinkTests.swift +// KeymanEngineTests +// +// Created by Joshua Horton on 7/27/20. +// Copyright © 2020 SIL International. All rights reserved. +// + +import XCTest +@testable import KeymanEngine + +class UniversalLinkTests: XCTestCase { + func testTryLinkParse() { + var parsedLink = UniversalLinks.tryParseKeyboardInstallLink(URL.init(string: "\(KeymanHosts.KEYMAN_COM)/randomURL")!) + + XCTAssertNil(parsedLink) + + parsedLink = UniversalLinks.tryParseKeyboardInstallLink(URL.init(string: "\(KeymanHosts.KEYMAN_COM)/keyboards/install/khmer_angkor")!) + + if let parsedLink = parsedLink { + XCTAssertEqual(parsedLink.keyboard_id, "khmer_angkor") + XCTAssertNil(parsedLink.lang_id) + } else { + XCTFail() + } + + parsedLink = UniversalLinks.tryParseKeyboardInstallLink(URL.init(string: "\(KeymanHosts.KEYMAN_COM)/keyboards/install/khmer_angkor?bcp47=km")!) + + if let parsedLink = parsedLink { + XCTAssertEqual(parsedLink.keyboard_id, "khmer_angkor") + XCTAssertEqual(parsedLink.lang_id, "km") + } else { + XCTFail() + } + + parsedLink = UniversalLinks.tryParseKeyboardInstallLink(URL.init(string: "\(KeymanHosts.KEYMAN_COM)/keyboards/install/sil_euro_latin")!) + + if let parsedLink = parsedLink { + XCTAssertEqual(parsedLink.keyboard_id, "sil_euro_latin") + XCTAssertNil(parsedLink.lang_id) + } else { + XCTFail() + } + + parsedLink = UniversalLinks.tryParseKeyboardInstallLink(URL.init(string: "\(KeymanHosts.KEYMAN_COM)/keyboards/install/foo?bcp47=bar")!) + + if let parsedLink = parsedLink { + XCTAssertEqual(parsedLink.keyboard_id, "foo") + XCTAssertEqual(parsedLink.lang_id, "bar") + } else { + XCTFail() + } + } + + func testTryLinkParseWithExtraneousComponents() { + var parsedLink = UniversalLinks.tryParseKeyboardInstallLink(URL.init(string: "\(KeymanHosts.KEYMAN_COM)/keyboards/install/foo?bcp47=bar&baz=nope")!) + + if let parsedLink = parsedLink { + XCTAssertEqual(parsedLink.keyboard_id, "foo") + XCTAssertEqual(parsedLink.lang_id, "bar") + } else { + XCTFail() + } + + parsedLink = UniversalLinks.tryParseKeyboardInstallLink(URL.init(string: "\(KeymanHosts.KEYMAN_COM)/keyboards/install/foo?_t=123&bcp47=bar")!) + + if let parsedLink = parsedLink { + XCTAssertEqual(parsedLink.keyboard_id, "foo") + XCTAssertEqual(parsedLink.lang_id, "bar") + } else { + XCTFail() + } + } +} diff --git a/ios/keyman/Keyman/Keyman/AppDelegate.swift b/ios/keyman/Keyman/Keyman/AppDelegate.swift index f59d711f33..e0b9e67d59 100644 --- a/ios/keyman/Keyman/Keyman/AppDelegate.swift +++ b/ios/keyman/Keyman/Keyman/AppDelegate.swift @@ -92,6 +92,55 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return true } + // Handles universal links. + func application(_ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { + if userActivity.activityType == NSUserActivityTypeBrowsingWeb { + guard let incomingURL = userActivity.webpageURL else { + return false + } + + if let parsedLink = UniversalLinks.tryParseKeyboardInstallLink(incomingURL) { + // We use this mostly to shorten line lengths, b/c lint warnings. + let downloadManager = ResourceDownloadManager.shared + + // Aha! We know this link type! + let downloadLink: URL + if let langID = parsedLink.lang_id { + let fullID = FullKeyboardID(keyboardID: parsedLink.keyboard_id, languageID: langID) + downloadLink = downloadManager.defaultDownloadURL(forPackage: parsedLink.packageKey, + andResource: fullID, + asUpdate: false) + } else { + downloadLink = downloadManager.defaultDownloadURL(forPackage: parsedLink.packageKey, asUpdate: false) + } + downloadManager.downloadPackage(withKey: parsedLink.packageKey, + from: downloadLink) { (package: KeyboardKeymanPackage?, error: Error?) in + guard error == nil, let package = package else { + // Maybe add an alert about the package error? + return + } + if let vc = self.window?.rootViewController { + // Force the app to the top-level view. (Prompts won't display if we're in a submenu!) + vc.dismiss(animated: true, completion: nil) + + // We choose to prompt the user for comfirmation, rather + // than automatically installing the package. + let nvc = UINavigationController.init() + ResourceFileManager.shared.promptPackageInstall(of: package, in: nvc, isCustom: true) + vc.present(nvc, animated: true, completion: nil) + } else { + log.error("Cannot find app's root UIViewController") + } + } + return true + } + } + + return false + } + func applicationDidEnterBackground(_ application: UIApplication) { _overlayWindow = nil FontManager.shared.unregisterCustomFonts() diff --git a/ios/keyman/Keyman/Keyman/Keyman.entitlements b/ios/keyman/Keyman/Keyman/Keyman.entitlements index ccdcdabc00..326f39220d 100644 --- a/ios/keyman/Keyman/Keyman/Keyman.entitlements +++ b/ios/keyman/Keyman/Keyman/Keyman.entitlements @@ -2,6 +2,11 @@ + com.apple.developer.associated-domains + + applinks:keyman.com + applinks:keyman-staging.com + com.apple.security.application-groups group.KM4I