Merge pull request #3466 from keymanapp/feat/ios/app/universal-links

feat(ios/app): universal links for keyboard installation
This commit is contained in:
Joshua Horton 2020-08-10 08:01:57 +07:00 • committed by GitHub
commit 9b6fea7fd8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 194 additions and 67 deletions

View file

@ -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 = "<group>"; };
CEA14874240780EF00C6ECD2 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/ResourceInfoView.strings; sourceTree = "<group>"; };
CEA148772407869200C6ECD2 /* km */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = km; path = km.lproj/ResourceInfoView.strings; sourceTree = "<group>"; };
CE9E95CC24CE786900F6DD78 /* UniversalLinks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalLinks.swift; sourceTree = "<group>"; };
CE9E95CE24CE7A2C00F6DD78 /* UniversalLinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalLinkTests.swift; sourceTree = "<group>"; };
CEA6BA9C249872E7002D44CE /* Simple 13.0 Migration.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = "Simple 13.0 Migration.bundle"; sourceTree = "<group>"; };
CEA70CCE24CAC3AE001C12E6 /* Obsoletions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Obsoletions.swift; sourceTree = "<group>"; };
CEA9670824BEC4030035AACF /* ResourceUpdateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceUpdateTests.swift; sourceTree = "<group>"; };
@ -778,6 +782,7 @@
CED8B63124A9C2400054E300 /* ResourceDownloadManagerTests.swift */,
CEA9670824BEC4030035AACF /* ResourceUpdateTests.swift */,
CE88143924A9B7F4002809C3 /* ResourceDownloadQueueTests.swift */,
CE9E95CE24CE7A2C00F6DD78 /* UniversalLinkTests.swift */,
);
path = KeymanEngineTests;
sourceTree = "<group>";
@ -1096,6 +1101,7 @@
CE79B24823C711FF007E72AE /* KeyboardScaleMap.swift */,
CE7ADD6523DE89FC00BC9A00 /* Alerts.swift */,
CEA70CCE24CAC3AE001C12E6 /* Obsoletions.swift */,
CE9E95CC24CE786900F6DD78 /* UniversalLinks.swift */,
);
path = Classes;
sourceTree = "<group>";
@ -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 */,

View file

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

View file

@ -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<FullKeyboardID>) {
@ -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 {

View file

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

View file

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

View file

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

View file

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

View file

@ -2,6 +2,11 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:keyman.com</string>
<string>applinks:keyman-staging.com</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.KM4I</string>