-
Notifications
You must be signed in to change notification settings - Fork 13
/
FlickTypeKit.swift
266 lines (220 loc) · 9.93 KB
/
FlickTypeKit.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
//
// FlickTypeKit.swift
// FlickTypeKit
//
// Created by Kosta Eleftheriou on 12/26/18.
// Copyright © 2018-2020 Kpaw. All rights reserved.
//
import WatchKit
public extension WKInterfaceController {
// TODO: address case where `presentTextInputController()` or `presentTextInputControllerWithSuggestions()` are called while
// there's already a system input or FlickType controller presented.
@objc
func presentTextInputController(
withSuggestions suggestions: [String]?,
allowedInputMode inputMode: WKTextInputMode,
flickType flickTypeMode: FlickType.Mode,
flickTypeProperties: [String: String] = [:],
startingText: String = "",
completion: @escaping ([Any]?) -> Void) {
// This source version of FlickTypeKit only supports watchOS 7 or later
guard ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 7 else {
return presentTextInputController(withSuggestions: suggestions, allowedInputMode: inputMode, completion: completion)
}
precondition(Thread.isMainThread)
handlePresentTextInput(TextInputInvocation(
suggestions: suggestions,
suggestionsHandler: nil,
inputMode: inputMode,
flickTypeMode: flickTypeMode,
flickTypeProperties: flickTypeProperties,
startingText: startingText,
completion: completion)
)
}
@objc
func presentTextInputControllerWithSuggestions(
forLanguage suggestionsHandler: ((String) -> [Any]?)?,
allowedInputMode inputMode: WKTextInputMode,
flickType flickTypeMode: FlickType.Mode,
flickTypeProperties: [String: String] = [:],
startingText: String = "",
completion: @escaping ([Any]?) -> Void) {
// This source version of FlickTypeKit only supports watchOS 7 or later
guard ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 7 else {
return presentTextInputControllerWithSuggestions(forLanguage: suggestionsHandler, allowedInputMode: inputMode, completion: completion)
}
precondition(Thread.isMainThread)
handlePresentTextInput(TextInputInvocation(
suggestions: nil,
suggestionsHandler: suggestionsHandler,
inputMode: inputMode,
flickTypeMode: flickTypeMode,
flickTypeProperties: flickTypeProperties,
startingText: startingText,
completion: completion)
)
}
private func handlePresentTextInput(_ invocation: TextInputInvocation) {
// You can only edit existing text with FlickType.
let existingText = invocation.startingText.isEmpty == false
var flickTypeMode: FlickType.Mode = existingText ? .always : invocation.flickTypeMode
// Don't force FlickType if the app is not known to be installed on the device
if flickTypeMode == .always && !FlickType.hasSwitchedFromFlickType {
flickTypeMode = .ask
}
switch flickTypeMode {
case .ask: presentSystemInputController(invocation)
case .always: presentFlickTypeOrIntermediateController(invocation)
case .off: presentSystemInputController(invocation)
}
}
internal func presentSystemInputController(_ invocation: TextInputInvocation) {
let flickTypeText = "⌨︎\tFlickType\n\tKeyboard"
func completionWrapper(textInputControllerReturnedItems: [Any]?) {
if let inputText = textInputControllerReturnedItems?.first as? String {
if inputText == flickTypeText {
return presentFlickTypeOrIntermediateController(invocation)
}
}
invocation.completion(textInputControllerReturnedItems)
}
// Handle the 4 combinations of (list or handler) x (include flicktype or not)
if let suggestionsHandler = invocation.suggestionsHandler {
let wrappedSuggestionsHandler = { (invocation.flickTypeMode == .off ? [] : [flickTypeText]) + (suggestionsHandler($0) ?? []) }
presentTextInputControllerWithSuggestions(forLanguage: wrappedSuggestionsHandler, allowedInputMode: invocation.inputMode, completion: completionWrapper)
} else {
let suggestions = (invocation.flickTypeMode == .off ? [] : [flickTypeText]) + (invocation.suggestions ?? [])
presentTextInputController(withSuggestions: suggestions, allowedInputMode: invocation.inputMode, completion: completionWrapper)
}
}
internal func alert(_ message: String) {
presentAlert(withTitle: "⚠️\nFlickTypeKit", message: message, preferredStyle: .alert, actions: [.init(title: "OK", style: .default, handler: {})])
}
internal func presentFlickTypeOrIntermediateController(_ invocation: TextInputInvocation) {
// A returnURL is mandatory regardless of watchOS version
guard let returnURL = FlickType.returnURL else { return alert("FlickType.returnURL is not set") }
func switchToFlickType(includeStartingText: Bool) {
let token = "\(Date.timeIntervalSinceReferenceDate)"
FlickType.returnHandler = (token, invocation.completionHandler)
let queryItems = [
"token" : token,
"returnURL" : returnURL.absoluteString,
"startingText" : includeStartingText ? invocation.startingText : "",
].merging(invocation.flickTypeProperties, uniquingKeysWith: { current, _ in current })
var urlComps = URLComponents(string: FlickType.typeURL)!
urlComps.queryItems = queryItems.map { URLQueryItem(name: $0.key, value: $0.value) }
WKExtension.shared().openSystemURL(urlComps.url!)
}
if FlickType.hasSwitchedFromFlickType {
switchToFlickType(includeStartingText: true)
} else {
let flickTypeAppStoreURL = URL(string: "https://apps.apple.com/us/app/flicktype-keyboard/id1359485719")!
presentAlert(
withTitle: "⌨️", // TODO: see if another keyboard emoji shows up better
message: "Download “FlickType Keyboard” from the App Store?",
preferredStyle: .alert,
actions: [
.init(title: "Download now", style: .default, handler: { WKExtension.shared().openSystemURL(flickTypeAppStoreURL) }),
.init(title: "I already have it", style: .default, handler: {
// Redact `startingText` until the first successful app-switch roundtrip, to prevent sensitive content from
// ever reaching our server if watchOS implements opening a web browser when our app isn't installed.
switchToFlickType(includeStartingText: false)
}),
]
)
}
}
}
public class FlickType : NSObject {
public static let sdkVersion = "2.0.1/swift"
@objc
public enum CompletionType : Int {
case dismiss
case action
}
@objc
public enum Mode : Int {
case ask
case always
case off
}
private override init() {
}
// eg "https://your.app.domain/flicktype"
public static var returnURL: URL!
fileprivate static let typeURL = "https://flicktype.com/type/"
fileprivate static var returnHandler: (token: String, completion: InvocationCompletionHandler)!
fileprivate static var hasSwitchedFromFlickType: Bool {
get { UserDefaults.standard.bool(forKey: "FlickType_HAS_SWITCHED_FROM_MAIN_APP") }
set { UserDefaults.standard.setValue(newValue, forKey: "FlickType_HAS_SWITCHED_FROM_MAIN_APP")}
}
// Returns true if it was a FlickType response activity
public static func handle(_ userActivity: NSUserActivity) -> Bool {
print("FlickTypeKit: handle userActivity", userActivity)
// TODO: main thread check?
func alert(_ message: String) {
print("⚠️ FlickTypeKit alert: \(message)")
WKExtension.shared().visibleInterfaceController?.alert(message)
}
// Get URL components from the incoming user activity
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let incomingURL = userActivity.webpageURL,
let components = NSURLComponents(url: incomingURL, resolvingAgainstBaseURL: true) else { return false }
guard let returnURL = returnURL else {
alert("Return URL is not set")
return false
}
guard incomingURL.absoluteString.starts(with: returnURL.absoluteString) else { return false }
guard let (expectedToken, completionHandler) = returnHandler else {
alert("Unexpected activity")
return true
}
guard let params = components.queryItems else {
alert("No query items")
return true
}
guard let token = params.first(where: { $0.name == "token" } )?.value else {
alert("No token param")
return true
}
guard token == expectedToken else {
alert("Unexpected token")
return true
}
guard let text = params.first(where: { $0.name == "text" } )?.value else {
alert("No text param")
return true
}
var completionType = CompletionType.action
// App versions < 2020.8 did not supply a completion type on return
if let _completionParam = params.first(where: { $0.name == "completion" } )?.value,
// Make sure we can handle malformed values
let _completionInt = Int(_completionParam),
let _completionType = CompletionType(rawValue: _completionInt) {
// We got a valid CompletionType, use it
completionType = _completionType
}
hasSwitchedFromFlickType = true
returnHandler = nil
completionHandler(text, completionType)
return true
}
}
internal typealias InvocationCompletionHandler = (String, FlickType.CompletionType) -> Void
internal struct TextInputInvocation {
let suggestions: [String]?
let suggestionsHandler: ((String) -> [Any]?)?
let inputMode: WKTextInputMode
let flickTypeMode: FlickType.Mode
let flickTypeProperties: [String: String]
let startingText: String
let completion: ([Any]?) -> Void
var completionHandler: InvocationCompletionHandler { return { self.completion([$0, $1]) } } // append `CompletionType` at the end of the returned array
}
// TODO: don't add anything to `Array`, redefine as `FlickType.something()`
public extension Array {
var completionType: FlickType.CompletionType {
return compactMap { $0 as? FlickType.CompletionType }.first!
}
}