@tokamakdev/plugin-location 0.1.0-beta.102 → 0.1.0-beta.104
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +217 -18
- package/android/LocationEvents.kt +82 -0
- package/android/LocationPlugin.kt +264 -36
- package/android/LocationReceiver.kt +27 -0
- package/android/LocationRegions.kt +73 -0
- package/android/LocationService.kt +75 -0
- package/android/LocationUpdates.kt +120 -0
- package/apple/LocationEvents.swift +149 -0
- package/apple/LocationPlugin.swift +350 -56
- package/apple/LocationRegions.swift +175 -0
- package/apple/LocationUpdates.swift +137 -0
- package/build/declarations.d.mts +19 -0
- package/build/declarations.mjs +105 -0
- package/build/index.mjs +17 -0
- package/events/index.ts +18 -0
- package/package.json +6 -3
- package/src/checks.ts +65 -0
- package/src/index.ts +124 -25
- package/src/options.ts +54 -0
- package/src/types.ts +173 -0
- package/tokamak-plugin.json +52 -6
- package/web/index.ts +15 -1
- package/android/AndroidManifest.xml +0 -4
- package/apple/ios/Info.plist +0 -8
- package/apple/macos/Info.plist +0 -8
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import CoreLocation
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// How long the Worker's listeners have for an event, within the few seconds
|
|
5
|
+
/// the system gives an app it launches for one.
|
|
6
|
+
private let eventDeadline: TimeInterval = 8
|
|
7
|
+
|
|
8
|
+
/// The listener methods, which receive the Worker's events.
|
|
9
|
+
private let listenerMethods: Set = [
|
|
10
|
+
"onLocationUpdate", "onRegionTransition", "onVisit", "onLocationError",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
/// Delivers the plugin's events to the Worker's listeners and the page's. Use
|
|
14
|
+
/// it on the main thread.
|
|
15
|
+
final class TokamakLocationEvents {
|
|
16
|
+
private let host: TokamakHost
|
|
17
|
+
private var listeners: [String: [UUID: TokamakPluginReply]] = [:]
|
|
18
|
+
/// Positions that arrived while the Worker handled the previous update.
|
|
19
|
+
private var positions: [[String: Any]] = []
|
|
20
|
+
private var stationary: Bool?
|
|
21
|
+
private var updating = false
|
|
22
|
+
/// The messages of the problems last reported, by region id, or nil for
|
|
23
|
+
/// updates.
|
|
24
|
+
private var problems: [String?: Set<String>] = [:]
|
|
25
|
+
|
|
26
|
+
init(host: TokamakHost) {
|
|
27
|
+
self.host = host
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// Adds a page's or Worker's listener of `method`, returning its removal.
|
|
31
|
+
func listen(_ method: String, _ reply: @escaping TokamakPluginReply) -> (() -> Void)? {
|
|
32
|
+
guard listenerMethods.contains(method) else { return nil }
|
|
33
|
+
let key = UUID()
|
|
34
|
+
listeners[method, default: [:]][key] = reply
|
|
35
|
+
return { [weak self] in
|
|
36
|
+
self?.listeners[method]?.removeValue(forKey: key)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// Delivers `location`, with Apple's `stationary` flag when the system
|
|
41
|
+
/// reports it. Positions that arrive while the Worker handles an update
|
|
42
|
+
/// reach it together as the next.
|
|
43
|
+
func update(_ location: CLLocation?, stationary: Bool? = nil) {
|
|
44
|
+
positions += location.map { [Self.position($0)] } ?? []
|
|
45
|
+
if let stationary {
|
|
46
|
+
self.stationary = stationary
|
|
47
|
+
}
|
|
48
|
+
if !updating {
|
|
49
|
+
sendUpdate()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private func sendUpdate() {
|
|
54
|
+
guard !positions.isEmpty || stationary == true else {
|
|
55
|
+
updating = false
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
var event: [String: Any] = ["positions": positions]
|
|
59
|
+
event["stationary"] = stationary
|
|
60
|
+
positions = []
|
|
61
|
+
stationary = nil
|
|
62
|
+
updating = true
|
|
63
|
+
send("onLocationUpdate", "update", event) { self.sendUpdate() }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func transition(_ id: String, entered: Bool, at date: Date) {
|
|
67
|
+
let event: [String: Any] = [
|
|
68
|
+
"id": id, "transition": entered ? "enter" : "exit", "timestamp": Self.milliseconds(date),
|
|
69
|
+
]
|
|
70
|
+
send("onRegionTransition", "region", event)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
func visit(_ visit: CLVisit) {
|
|
74
|
+
let event: [String: Any] = [
|
|
75
|
+
"latitude": visit.coordinate.latitude,
|
|
76
|
+
"longitude": visit.coordinate.longitude,
|
|
77
|
+
"accuracy": visit.horizontalAccuracy,
|
|
78
|
+
"arrival": visit.arrivalDate == .distantPast
|
|
79
|
+
? NSNull() : Self.milliseconds(visit.arrivalDate),
|
|
80
|
+
"departure": visit.departureDate == .distantFuture
|
|
81
|
+
? NSNull() : Self.milliseconds(visit.departureDate),
|
|
82
|
+
]
|
|
83
|
+
send("onVisit", "visit", event)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/// Reports each of `problems` that updates, or the region `id`, did not
|
|
87
|
+
/// report last time.
|
|
88
|
+
func report(_ problems: [TokamakPluginError], region id: String? = nil) {
|
|
89
|
+
let previous = self.problems[id] ?? []
|
|
90
|
+
for problem in problems where !previous.contains(problem.message) {
|
|
91
|
+
fail(problem, region: id)
|
|
92
|
+
}
|
|
93
|
+
self.problems[id] = Set(problems.map(\.message))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// Reports a background service's failure, which concerns the region `id`
|
|
97
|
+
/// when given.
|
|
98
|
+
func fail(_ error: TokamakPluginError, region id: String? = nil) {
|
|
99
|
+
var event: [String: Any] = ["name": error.name, "message": error.message]
|
|
100
|
+
event["id"] = id
|
|
101
|
+
send("onLocationError", "error", event)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private func send(
|
|
105
|
+
_ method: String, _ name: String, _ event: [String: Any], completion: @escaping () -> Void = {}
|
|
106
|
+
) {
|
|
107
|
+
for reply in (listeners[method] ?? [:]).values {
|
|
108
|
+
reply(.success(event))
|
|
109
|
+
}
|
|
110
|
+
host.emit(name, event: event, timeout: eventDeadline) { result in
|
|
111
|
+
if case .failure(let error) = result {
|
|
112
|
+
print("location \(name) failed: \(error)")
|
|
113
|
+
}
|
|
114
|
+
completion()
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/// `location` as the page's `Position`.
|
|
119
|
+
static func position(_ location: CLLocation) -> [String: Any] {
|
|
120
|
+
let coordinates = location.coordinate
|
|
121
|
+
return [
|
|
122
|
+
"coords": [
|
|
123
|
+
"latitude": coordinates.latitude,
|
|
124
|
+
"longitude": coordinates.longitude,
|
|
125
|
+
"accuracy": location.horizontalAccuracy,
|
|
126
|
+
"altitude": nullable(location.verticalAccuracy >= 0, location.altitude),
|
|
127
|
+
"altitudeAccuracy": nullable(location.verticalAccuracy >= 0, location.verticalAccuracy),
|
|
128
|
+
"heading": nullable(location.course >= 0, location.course),
|
|
129
|
+
"speed": nullable(location.speed >= 0, location.speed),
|
|
130
|
+
],
|
|
131
|
+
"timestamp": milliseconds(location.timestamp),
|
|
132
|
+
]
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private static func nullable(_ available: Bool, _ value: Double) -> Any {
|
|
136
|
+
available ? value : NSNull()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private static func milliseconds(_ date: Date) -> Double {
|
|
140
|
+
date.timeIntervalSince1970 * 1000
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
extension TokamakPluginError {
|
|
145
|
+
/// Location could not be found, as `message` says.
|
|
146
|
+
static func locationUnavailable(_ message: String) -> Self {
|
|
147
|
+
Self(name: "NotReadableError", message: message)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import CoreLocation
|
|
2
2
|
import Foundation
|
|
3
3
|
|
|
4
|
+
#if os(iOS)
|
|
5
|
+
import UIKit
|
|
6
|
+
#else
|
|
7
|
+
import AppKit
|
|
8
|
+
#endif
|
|
9
|
+
|
|
10
|
+
/// The JSON of the configuration `setup` last applied.
|
|
11
|
+
private let setupKey = "tokamak.location.setup"
|
|
12
|
+
/// Whether the plugin asked for Always permission, which iOS asks once.
|
|
13
|
+
private let askedForAlwaysKey = "tokamak.location.askedForAlways"
|
|
14
|
+
/// How long an authorization change may take to arrive after the app is active again.
|
|
15
|
+
private let authorizationChangeDelay: TimeInterval = 1
|
|
16
|
+
|
|
4
17
|
final class TokamakLocationPlugin: NSObject, TokamakPlugin,
|
|
5
18
|
CLLocationManagerDelegate
|
|
6
19
|
{
|
|
@@ -23,21 +36,62 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
|
|
|
23
36
|
var timer: DispatchWorkItem?
|
|
24
37
|
}
|
|
25
38
|
|
|
39
|
+
/// A `requestPermission` waiting for the user to answer a prompt.
|
|
40
|
+
private struct Prompt {
|
|
41
|
+
/// The purpose key of the temporary precise location to ask for next.
|
|
42
|
+
let purpose: String?
|
|
43
|
+
let reply: TokamakPluginReply
|
|
44
|
+
}
|
|
45
|
+
|
|
26
46
|
private let host: TokamakHost
|
|
27
|
-
|
|
28
|
-
|
|
47
|
+
private let defaults = UserDefaults.standard
|
|
48
|
+
/// Locates for watchers, and asks for permission.
|
|
49
|
+
private let watching = CLLocationManager()
|
|
29
50
|
/// Takes single fixes for current position requests, apart from watching.
|
|
30
51
|
private let fixes = CLLocationManager()
|
|
52
|
+
/// Delivers the significant changes and visits `setup` names.
|
|
53
|
+
private let monitoring = CLLocationManager()
|
|
31
54
|
private var current: [UUID: Request] = [:]
|
|
32
55
|
private var watchers: [UUID: TokamakPluginReply] = [:]
|
|
56
|
+
private var prompts: [UUID: Prompt] = [:]
|
|
57
|
+
/// The prompts waiting when the app resigned active, as a prompt showing
|
|
58
|
+
/// makes it.
|
|
59
|
+
private var shown: [UUID] = []
|
|
60
|
+
private let events: TokamakLocationEvents
|
|
61
|
+
private let updates: TokamakLocationUpdates
|
|
62
|
+
private let regions: TokamakLocationRegions
|
|
63
|
+
#if os(iOS)
|
|
64
|
+
/// The `CLServiceSession` iOS 18 needs for updates and regions to reach an
|
|
65
|
+
/// app that is not in use, held while the app has Always permission.
|
|
66
|
+
private var alwaysSession: AnyObject?
|
|
67
|
+
#endif
|
|
33
68
|
|
|
69
|
+
/// Resumes what ran when the app last ended, before launch finishes.
|
|
34
70
|
init(host: TokamakHost) {
|
|
35
71
|
self.host = host
|
|
72
|
+
events = TokamakLocationEvents(host: host)
|
|
73
|
+
updates = TokamakLocationUpdates(events: events)
|
|
74
|
+
regions = TokamakLocationRegions(events: events)
|
|
36
75
|
super.init()
|
|
37
|
-
for manager in [
|
|
76
|
+
for manager in [watching, fixes] {
|
|
38
77
|
manager.delegate = self
|
|
39
78
|
manager.desiredAccuracy = kCLLocationAccuracyBest
|
|
40
79
|
}
|
|
80
|
+
monitoring.delegate = self
|
|
81
|
+
if let stored = defaults.data(forKey: setupKey),
|
|
82
|
+
let config = try? JSONSerialization.jsonObject(with: stored) as? [String: Any]
|
|
83
|
+
{
|
|
84
|
+
apply(config)
|
|
85
|
+
}
|
|
86
|
+
#if os(iOS)
|
|
87
|
+
NotificationCenter.default.addObserver(
|
|
88
|
+
self, selector: #selector(willResignActive),
|
|
89
|
+
name: UIApplication.willResignActiveNotification, object: nil)
|
|
90
|
+
NotificationCenter.default.addObserver(
|
|
91
|
+
self, selector: #selector(didBecomeActive),
|
|
92
|
+
name: UIApplication.didBecomeActiveNotification, object: nil)
|
|
93
|
+
#endif
|
|
94
|
+
updates.resume()
|
|
41
95
|
}
|
|
42
96
|
|
|
43
97
|
func call(
|
|
@@ -45,17 +99,77 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
|
|
|
45
99
|
arguments: Any,
|
|
46
100
|
reply: @escaping TokamakPluginReply
|
|
47
101
|
) {
|
|
48
|
-
guard method == "getCurrentPosition" else {
|
|
49
|
-
reply(.failure(.notSupported("\(id).\(method) is not supported")))
|
|
50
|
-
return
|
|
51
|
-
}
|
|
52
102
|
let options = arguments as? [String: Any] ?? [:]
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
103
|
+
do throws(TokamakPluginError) {
|
|
104
|
+
switch method {
|
|
105
|
+
case "setup":
|
|
106
|
+
if options["significantChanges"] as? Bool == true
|
|
107
|
+
&& !CLLocationManager.significantLocationChangeMonitoringAvailable()
|
|
108
|
+
{
|
|
109
|
+
throw .notSupported("The device cannot monitor significant changes of location")
|
|
110
|
+
}
|
|
111
|
+
apply(options)
|
|
112
|
+
defaults.set(try? JSONSerialization.data(withJSONObject: options), forKey: setupKey)
|
|
113
|
+
reply(.success(nil))
|
|
114
|
+
case "getCurrentPosition":
|
|
115
|
+
current[UUID()] = Request(
|
|
116
|
+
reply: reply,
|
|
117
|
+
maximumAge: (options["maximumAge"] as? Double ?? 0) / 1000,
|
|
118
|
+
timeout: (options["timeout"] as? Double ?? Double(UInt32.max)) / 1000
|
|
119
|
+
)
|
|
120
|
+
start()
|
|
121
|
+
case "permission":
|
|
122
|
+
reply(.success(permission()))
|
|
123
|
+
case "requestPermission":
|
|
124
|
+
try requestPermission(options, reply)
|
|
125
|
+
case "servicesEnabled":
|
|
126
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
127
|
+
reply(.success(CLLocationManager.locationServicesEnabled()))
|
|
128
|
+
}
|
|
129
|
+
case "openSettings":
|
|
130
|
+
try host.requireUI()
|
|
131
|
+
openSettings(reply)
|
|
132
|
+
case "startUpdates":
|
|
133
|
+
// A new background activity session starts only on screen.
|
|
134
|
+
try host.requireUI()
|
|
135
|
+
try requirePermission()
|
|
136
|
+
try updates.start(options)
|
|
137
|
+
reply(.success(nil))
|
|
138
|
+
case "stopUpdates":
|
|
139
|
+
updates.stop()
|
|
140
|
+
reply(.success(nil))
|
|
141
|
+
case "getUpdates":
|
|
142
|
+
reply(.success(updates.options))
|
|
143
|
+
case "addRegion":
|
|
144
|
+
try requirePermission()
|
|
145
|
+
guard watching.accuracyAuthorization == .fullAccuracy else {
|
|
146
|
+
throw TokamakPluginError(
|
|
147
|
+
name: "NotAllowedError", message: "Regions need precise location")
|
|
148
|
+
}
|
|
149
|
+
Task { @MainActor in
|
|
150
|
+
do throws(TokamakPluginError) {
|
|
151
|
+
try await regions.add(options)
|
|
152
|
+
reply(.success(nil))
|
|
153
|
+
} catch {
|
|
154
|
+
reply(.failure(error))
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
case "getRegions":
|
|
158
|
+
Task { @MainActor in reply(.success(await regions.all())) }
|
|
159
|
+
case "removeRegion":
|
|
160
|
+
guard let id = options["id"] as? String else {
|
|
161
|
+
throw TokamakPluginError(name: "TypeError", message: "id must be a string")
|
|
162
|
+
}
|
|
163
|
+
Task { @MainActor in
|
|
164
|
+
await regions.remove(id)
|
|
165
|
+
reply(.success(nil))
|
|
166
|
+
}
|
|
167
|
+
default:
|
|
168
|
+
throw .notSupported("\(id).\(method) is not supported")
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
reply(.failure(error))
|
|
172
|
+
}
|
|
59
173
|
}
|
|
60
174
|
|
|
61
175
|
func subscribe(
|
|
@@ -63,6 +177,9 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
|
|
|
63
177
|
arguments: Any,
|
|
64
178
|
reply: @escaping TokamakPluginReply
|
|
65
179
|
) throws(TokamakPluginError) -> (() -> Void) {
|
|
180
|
+
if let remove = events.listen(method, reply) {
|
|
181
|
+
return remove
|
|
182
|
+
}
|
|
66
183
|
guard method == "watchPosition" else {
|
|
67
184
|
throw .notSupported("\(id).\(method) is not supported")
|
|
68
185
|
}
|
|
@@ -72,26 +189,38 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
|
|
|
72
189
|
return { [weak self] in
|
|
73
190
|
self?.watchers.removeValue(forKey: subscription)
|
|
74
191
|
if self?.watchers.isEmpty == true {
|
|
75
|
-
self?.
|
|
192
|
+
self?.watching.stopUpdatingLocation()
|
|
76
193
|
}
|
|
77
194
|
}
|
|
78
195
|
}
|
|
79
196
|
|
|
80
197
|
/// Also called when each manager is created, before anything asks for a
|
|
81
|
-
/// position.
|
|
198
|
+
/// position. Every manager reports the app's one authorization, which
|
|
199
|
+
/// `watching`'s calls handle.
|
|
82
200
|
func locationManagerDidChangeAuthorization(
|
|
83
201
|
_ manager: CLLocationManager
|
|
84
202
|
) {
|
|
85
|
-
guard manager ===
|
|
86
|
-
|
|
203
|
+
guard manager === watching else { return }
|
|
204
|
+
holdAlwaysSession()
|
|
205
|
+
guard manager.authorizationStatus != .notDetermined else { return }
|
|
206
|
+
answer(Array(prompts.keys))
|
|
207
|
+
if !current.isEmpty || !watchers.isEmpty {
|
|
208
|
+
serve(manager.authorizationStatus)
|
|
209
|
+
}
|
|
87
210
|
}
|
|
88
211
|
|
|
89
212
|
func locationManager(
|
|
90
213
|
_ manager: CLLocationManager,
|
|
91
214
|
didUpdateLocations locations: [CLLocation]
|
|
92
215
|
) {
|
|
216
|
+
if manager === monitoring {
|
|
217
|
+
for location in locations {
|
|
218
|
+
events.update(location)
|
|
219
|
+
}
|
|
220
|
+
return
|
|
221
|
+
}
|
|
93
222
|
guard let location = locations.last else { return }
|
|
94
|
-
deliver(.success(position(location)), from: manager)
|
|
223
|
+
deliver(.success(TokamakLocationEvents.position(location)), from: manager)
|
|
95
224
|
}
|
|
96
225
|
|
|
97
226
|
/// A fix that is not yet available is taken again; updates continue.
|
|
@@ -99,12 +228,37 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
|
|
|
99
228
|
_ manager: CLLocationManager,
|
|
100
229
|
didFailWithError error: Error
|
|
101
230
|
) {
|
|
102
|
-
|
|
231
|
+
if (error as NSError).code == CLError.Code.locationUnknown.rawValue {
|
|
232
|
+
if manager === fixes && !current.isEmpty {
|
|
233
|
+
fixes.requestLocation()
|
|
234
|
+
}
|
|
235
|
+
} else if manager === monitoring {
|
|
236
|
+
events.fail(locationError(error))
|
|
237
|
+
} else {
|
|
103
238
|
deliver(.failure(locationError(error)), from: manager)
|
|
104
|
-
return
|
|
105
239
|
}
|
|
106
|
-
|
|
107
|
-
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/// Core Location delivers each visit to every manager's delegate.
|
|
243
|
+
func locationManager(_ manager: CLLocationManager, didVisit visit: CLVisit) {
|
|
244
|
+
if manager === monitoring {
|
|
245
|
+
events.visit(visit)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/// Starts the significant changes and visits `config` names, and stops the
|
|
250
|
+
/// others. After a launch for one of their events, starting them again
|
|
251
|
+
/// delivers it.
|
|
252
|
+
private func apply(_ config: [String: Any]) {
|
|
253
|
+
if config["significantChanges"] as? Bool == true {
|
|
254
|
+
monitoring.startMonitoringSignificantLocationChanges()
|
|
255
|
+
} else {
|
|
256
|
+
monitoring.stopMonitoringSignificantLocationChanges()
|
|
257
|
+
}
|
|
258
|
+
if config["visits"] as? Bool == true {
|
|
259
|
+
monitoring.startMonitoringVisits()
|
|
260
|
+
} else {
|
|
261
|
+
monitoring.stopMonitoringVisits()
|
|
108
262
|
}
|
|
109
263
|
}
|
|
110
264
|
|
|
@@ -118,17 +272,17 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
|
|
|
118
272
|
|
|
119
273
|
private func start(servicesEnabled: Bool) {
|
|
120
274
|
guard servicesEnabled else {
|
|
121
|
-
fail(
|
|
275
|
+
fail(.locationUnavailable("Location services are disabled"))
|
|
122
276
|
return
|
|
123
277
|
}
|
|
124
|
-
let status =
|
|
278
|
+
let status = watching.authorizationStatus
|
|
125
279
|
guard status == .notDetermined else {
|
|
126
280
|
serve(status)
|
|
127
281
|
return
|
|
128
282
|
}
|
|
129
283
|
do throws(TokamakPluginError) {
|
|
130
284
|
try host.requireUI()
|
|
131
|
-
|
|
285
|
+
watching.requestWhenInUseAuthorization()
|
|
132
286
|
} catch {
|
|
133
287
|
fail(error)
|
|
134
288
|
}
|
|
@@ -140,7 +294,7 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
|
|
|
140
294
|
switch status {
|
|
141
295
|
case .authorizedAlways, .authorizedWhenInUse:
|
|
142
296
|
if !watchers.isEmpty {
|
|
143
|
-
|
|
297
|
+
watching.startUpdatingLocation()
|
|
144
298
|
}
|
|
145
299
|
locate()
|
|
146
300
|
case .denied, .restricted:
|
|
@@ -157,7 +311,7 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
|
|
|
157
311
|
private func locate() {
|
|
158
312
|
for (key, request) in current where request.timer == nil {
|
|
159
313
|
if let fix = fixes.location, -fix.timestamp.timeIntervalSinceNow <= request.maximumAge {
|
|
160
|
-
finish(key, .success(position(fix)))
|
|
314
|
+
finish(key, .success(TokamakLocationEvents.position(fix)))
|
|
161
315
|
continue
|
|
162
316
|
}
|
|
163
317
|
let timer = DispatchWorkItem { [weak self] in self?.finish(key, .failure(Self.timedOut)) }
|
|
@@ -185,7 +339,7 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
|
|
|
185
339
|
|
|
186
340
|
private func fail(_ error: TokamakPluginError) {
|
|
187
341
|
deliver(.failure(error), from: fixes)
|
|
188
|
-
deliver(.failure(error), from:
|
|
342
|
+
deliver(.failure(error), from: watching)
|
|
189
343
|
}
|
|
190
344
|
|
|
191
345
|
private func finish(_ key: UUID, _ result: Result<Any?, TokamakPluginError>) {
|
|
@@ -197,40 +351,180 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
|
|
|
197
351
|
}
|
|
198
352
|
}
|
|
199
353
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
354
|
+
/// The app's in-use and background permission, and the precision it gets.
|
|
355
|
+
private func permission() -> [String: Any] {
|
|
356
|
+
let status = watching.authorizationStatus
|
|
357
|
+
return [
|
|
358
|
+
"inUse": Self.state(status),
|
|
359
|
+
"background": backgroundPermission(status),
|
|
360
|
+
"precision": watching.accuracyAuthorization == .reducedAccuracy ? "approximate" : "precise",
|
|
361
|
+
]
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private func backgroundPermission(_ status: CLAuthorizationStatus) -> String {
|
|
365
|
+
if status == .authorizedAlways { return "granted" }
|
|
366
|
+
let asks = status == .notDetermined ? Self.declaresAlways : canUpgradeToAlways(status)
|
|
367
|
+
return asks ? "prompt" : "denied"
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/// Whether asking for Always shows a prompt: once, from When In Use, on iOS.
|
|
371
|
+
private func canUpgradeToAlways(_ status: CLAuthorizationStatus) -> Bool {
|
|
372
|
+
#if os(iOS)
|
|
373
|
+
status == .authorizedWhenInUse && Self.declaresAlways && !askedForAlways
|
|
374
|
+
#else
|
|
375
|
+
false
|
|
376
|
+
#endif
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private var hasPermission: Bool {
|
|
380
|
+
![.notDetermined, .denied, .restricted].contains(watching.authorizationStatus)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/// Throws `NotAllowedError` unless the app has location permission.
|
|
384
|
+
private func requirePermission() throws(TokamakPluginError) {
|
|
385
|
+
guard hasPermission else {
|
|
386
|
+
throw TokamakPluginError(
|
|
387
|
+
name: "NotAllowedError", message: "Location permission has not been granted")
|
|
203
388
|
}
|
|
204
|
-
return unavailable(error.localizedDescription)
|
|
205
389
|
}
|
|
206
390
|
|
|
207
|
-
private func
|
|
208
|
-
|
|
391
|
+
private func holdAlwaysSession() {
|
|
392
|
+
#if os(iOS)
|
|
393
|
+
guard #available(iOS 18, *) else { return }
|
|
394
|
+
let always = watching.authorizationStatus == .authorizedAlways
|
|
395
|
+
if always && alwaysSession == nil {
|
|
396
|
+
alwaysSession = CLServiceSession(authorization: .always)
|
|
397
|
+
} else if !always {
|
|
398
|
+
(alwaysSession as? CLServiceSession)?.invalidate()
|
|
399
|
+
alwaysSession = nil
|
|
400
|
+
}
|
|
401
|
+
#endif
|
|
209
402
|
}
|
|
210
403
|
|
|
211
|
-
private func
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
"heading": nullable(location.course >= 0, location.course),
|
|
227
|
-
"speed": nullable(location.speed >= 0, location.speed),
|
|
228
|
-
],
|
|
229
|
-
"timestamp": location.timestamp.timeIntervalSince1970 * 1000,
|
|
230
|
-
]
|
|
404
|
+
private static func state(_ status: CLAuthorizationStatus) -> String {
|
|
405
|
+
switch status {
|
|
406
|
+
case .notDetermined: "prompt"
|
|
407
|
+
case .denied, .restricted: "denied"
|
|
408
|
+
default: "granted"
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/// Whether the Info.plist declares Always permission, as the `background`
|
|
413
|
+
/// build setting does on iOS and every macOS build does.
|
|
414
|
+
private static let declaresAlways =
|
|
415
|
+
Bundle.main.object(forInfoDictionaryKey: "NSLocationAlwaysAndWhenInUseUsageDescription") != nil
|
|
416
|
+
|
|
417
|
+
private var askedForAlways: Bool {
|
|
418
|
+
defaults.bool(forKey: askedForAlwaysKey)
|
|
231
419
|
}
|
|
232
420
|
|
|
233
|
-
|
|
234
|
-
|
|
421
|
+
/// Asks for in-use permission while the user has not answered, or for
|
|
422
|
+
/// Always permission once from When In Use, then for temporary precise
|
|
423
|
+
/// location when `options` name a purpose.
|
|
424
|
+
private func requestPermission(
|
|
425
|
+
_ options: [String: Any], _ reply: @escaping TokamakPluginReply
|
|
426
|
+
) throws(TokamakPluginError) {
|
|
427
|
+
let always = options["background"] as? Bool == true
|
|
428
|
+
if always && !Self.declaresAlways {
|
|
429
|
+
throw .notSupported(
|
|
430
|
+
"Background location needs the location plugin's background build setting")
|
|
431
|
+
}
|
|
432
|
+
let prompt = Prompt(purpose: try Self.purpose(options), reply: reply)
|
|
433
|
+
let status = watching.authorizationStatus
|
|
434
|
+
let upgrades = always && canUpgradeToAlways(status)
|
|
435
|
+
guard status == .notDetermined || upgrades else {
|
|
436
|
+
respond(to: prompt)
|
|
437
|
+
return
|
|
438
|
+
}
|
|
439
|
+
try host.requireUI()
|
|
440
|
+
prompts[UUID()] = prompt
|
|
441
|
+
if always {
|
|
442
|
+
defaults.set(true, forKey: askedForAlwaysKey)
|
|
443
|
+
watching.requestAlwaysAuthorization()
|
|
444
|
+
} else {
|
|
445
|
+
watching.requestWhenInUseAuthorization()
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/// The purpose key of the temporary precise location `options` ask for.
|
|
450
|
+
private static func purpose(_ options: [String: Any]) throws(TokamakPluginError) -> String? {
|
|
451
|
+
guard options["precision"] as? String == "precise" else { return nil }
|
|
452
|
+
let purposes =
|
|
453
|
+
Bundle.main.object(forInfoDictionaryKey: "NSLocationTemporaryUsageDescriptionDictionary")
|
|
454
|
+
as? [String: Any]
|
|
455
|
+
guard let purpose = options["purpose"] as? String, purposes?[purpose] != nil else {
|
|
456
|
+
throw TokamakPluginError(
|
|
457
|
+
name: "TypeError",
|
|
458
|
+
message: "precise location needs a purpose from the accuracy-purposes build setting")
|
|
459
|
+
}
|
|
460
|
+
return purpose
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
#if os(iOS)
|
|
464
|
+
@objc private func willResignActive() {
|
|
465
|
+
shown = Array(prompts.keys)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/// Answers the prompts that were showing, as the system reports no change
|
|
469
|
+
/// when the user keeps When In Use.
|
|
470
|
+
@objc private func didBecomeActive() {
|
|
471
|
+
let answered = shown
|
|
472
|
+
shown = []
|
|
473
|
+
guard !answered.isEmpty else { return }
|
|
474
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + authorizationChangeDelay) {
|
|
475
|
+
self.answer(answered)
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
#endif
|
|
479
|
+
|
|
480
|
+
/// Answers the prompts `keys` that still wait.
|
|
481
|
+
private func answer(_ keys: [UUID]) {
|
|
482
|
+
for key in keys {
|
|
483
|
+
if let prompt = prompts.removeValue(forKey: key) {
|
|
484
|
+
respond(to: prompt)
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/// Replies to `prompt` with the app's permission, first asking for temporary
|
|
490
|
+
/// precise location for its purpose when the app has approximate location.
|
|
491
|
+
private func respond(to prompt: Prompt) {
|
|
492
|
+
guard let purpose = prompt.purpose, hasPermission,
|
|
493
|
+
watching.accuracyAuthorization == .reducedAccuracy
|
|
494
|
+
else {
|
|
495
|
+
prompt.reply(.success(permission()))
|
|
496
|
+
return
|
|
497
|
+
}
|
|
498
|
+
do throws(TokamakPluginError) {
|
|
499
|
+
try host.requireUI()
|
|
500
|
+
} catch {
|
|
501
|
+
prompt.reply(.failure(error))
|
|
502
|
+
return
|
|
503
|
+
}
|
|
504
|
+
watching.requestTemporaryFullAccuracyAuthorization(withPurposeKey: purpose) { _ in
|
|
505
|
+
DispatchQueue.main.async { prompt.reply(.success(self.permission())) }
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/// Opens the app's page of the system settings on iOS, and Location Services on macOS.
|
|
510
|
+
private func openSettings(_ reply: @escaping TokamakPluginReply) {
|
|
511
|
+
let notOpened = TokamakPluginError(
|
|
512
|
+
name: "OperationError", message: "The location settings could not be opened")
|
|
513
|
+
#if os(iOS)
|
|
514
|
+
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) { opened in
|
|
515
|
+
reply(opened ? .success(nil) : .failure(notOpened))
|
|
516
|
+
}
|
|
517
|
+
#else
|
|
518
|
+
let page = "com.apple.preference.security?Privacy_LocationServices"
|
|
519
|
+
let opened = URL(string: "x-apple.systempreferences:\(page)").map(NSWorkspace.shared.open)
|
|
520
|
+
reply(opened == true ? .success(nil) : .failure(notOpened))
|
|
521
|
+
#endif
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
private func locationError(_ error: Error) -> TokamakPluginError {
|
|
525
|
+
if (error as NSError).code == CLError.Code.denied.rawValue {
|
|
526
|
+
return Self.permissionDenied
|
|
527
|
+
}
|
|
528
|
+
return .locationUnavailable(error.localizedDescription)
|
|
235
529
|
}
|
|
236
530
|
}
|