@tokamakdev/plugin-location 0.1.0-beta.101 → 0.1.0-beta.103
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 +198 -18
- package/android/LocationEvents.kt +82 -0
- package/android/LocationPlugin.kt +216 -34
- package/android/LocationReceiver.kt +27 -0
- package/android/LocationRegions.kt +73 -0
- package/android/LocationService.kt +64 -0
- package/android/LocationUpdates.kt +120 -0
- package/apple/LocationEvents.swift +149 -0
- package/apple/LocationPlugin.swift +347 -56
- package/apple/LocationRegions.swift +154 -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 +122 -25
- package/src/options.ts +54 -0
- package/src/types.ts +163 -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
|
@@ -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,34 @@ 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
|
+
func locationManager(_ manager: CLLocationManager, didVisit visit: CLVisit) {
|
|
243
|
+
events.visit(visit)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/// Starts the significant changes and visits `config` names, and stops the
|
|
247
|
+
/// others. After a launch for one of their events, starting them again
|
|
248
|
+
/// delivers it.
|
|
249
|
+
private func apply(_ config: [String: Any]) {
|
|
250
|
+
if config["significantChanges"] as? Bool == true {
|
|
251
|
+
monitoring.startMonitoringSignificantLocationChanges()
|
|
252
|
+
} else {
|
|
253
|
+
monitoring.stopMonitoringSignificantLocationChanges()
|
|
254
|
+
}
|
|
255
|
+
if config["visits"] as? Bool == true {
|
|
256
|
+
monitoring.startMonitoringVisits()
|
|
257
|
+
} else {
|
|
258
|
+
monitoring.stopMonitoringVisits()
|
|
108
259
|
}
|
|
109
260
|
}
|
|
110
261
|
|
|
@@ -118,17 +269,17 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
|
|
|
118
269
|
|
|
119
270
|
private func start(servicesEnabled: Bool) {
|
|
120
271
|
guard servicesEnabled else {
|
|
121
|
-
fail(
|
|
272
|
+
fail(.locationUnavailable("Location services are disabled"))
|
|
122
273
|
return
|
|
123
274
|
}
|
|
124
|
-
let status =
|
|
275
|
+
let status = watching.authorizationStatus
|
|
125
276
|
guard status == .notDetermined else {
|
|
126
277
|
serve(status)
|
|
127
278
|
return
|
|
128
279
|
}
|
|
129
280
|
do throws(TokamakPluginError) {
|
|
130
281
|
try host.requireUI()
|
|
131
|
-
|
|
282
|
+
watching.requestWhenInUseAuthorization()
|
|
132
283
|
} catch {
|
|
133
284
|
fail(error)
|
|
134
285
|
}
|
|
@@ -140,7 +291,7 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
|
|
|
140
291
|
switch status {
|
|
141
292
|
case .authorizedAlways, .authorizedWhenInUse:
|
|
142
293
|
if !watchers.isEmpty {
|
|
143
|
-
|
|
294
|
+
watching.startUpdatingLocation()
|
|
144
295
|
}
|
|
145
296
|
locate()
|
|
146
297
|
case .denied, .restricted:
|
|
@@ -157,7 +308,7 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
|
|
|
157
308
|
private func locate() {
|
|
158
309
|
for (key, request) in current where request.timer == nil {
|
|
159
310
|
if let fix = fixes.location, -fix.timestamp.timeIntervalSinceNow <= request.maximumAge {
|
|
160
|
-
finish(key, .success(position(fix)))
|
|
311
|
+
finish(key, .success(TokamakLocationEvents.position(fix)))
|
|
161
312
|
continue
|
|
162
313
|
}
|
|
163
314
|
let timer = DispatchWorkItem { [weak self] in self?.finish(key, .failure(Self.timedOut)) }
|
|
@@ -185,7 +336,7 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
|
|
|
185
336
|
|
|
186
337
|
private func fail(_ error: TokamakPluginError) {
|
|
187
338
|
deliver(.failure(error), from: fixes)
|
|
188
|
-
deliver(.failure(error), from:
|
|
339
|
+
deliver(.failure(error), from: watching)
|
|
189
340
|
}
|
|
190
341
|
|
|
191
342
|
private func finish(_ key: UUID, _ result: Result<Any?, TokamakPluginError>) {
|
|
@@ -197,40 +348,180 @@ final class TokamakLocationPlugin: NSObject, TokamakPlugin,
|
|
|
197
348
|
}
|
|
198
349
|
}
|
|
199
350
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
351
|
+
/// The app's in-use and background permission, and the precision it gets.
|
|
352
|
+
private func permission() -> [String: Any] {
|
|
353
|
+
let status = watching.authorizationStatus
|
|
354
|
+
return [
|
|
355
|
+
"inUse": Self.state(status),
|
|
356
|
+
"background": backgroundPermission(status),
|
|
357
|
+
"precision": watching.accuracyAuthorization == .reducedAccuracy ? "approximate" : "precise",
|
|
358
|
+
]
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private func backgroundPermission(_ status: CLAuthorizationStatus) -> String {
|
|
362
|
+
if status == .authorizedAlways { return "granted" }
|
|
363
|
+
let asks = status == .notDetermined ? Self.declaresAlways : canUpgradeToAlways(status)
|
|
364
|
+
return asks ? "prompt" : "denied"
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/// Whether asking for Always shows a prompt: once, from When In Use, on iOS.
|
|
368
|
+
private func canUpgradeToAlways(_ status: CLAuthorizationStatus) -> Bool {
|
|
369
|
+
#if os(iOS)
|
|
370
|
+
status == .authorizedWhenInUse && Self.declaresAlways && !askedForAlways
|
|
371
|
+
#else
|
|
372
|
+
false
|
|
373
|
+
#endif
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private var hasPermission: Bool {
|
|
377
|
+
![.notDetermined, .denied, .restricted].contains(watching.authorizationStatus)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/// Throws `NotAllowedError` unless the app has location permission.
|
|
381
|
+
private func requirePermission() throws(TokamakPluginError) {
|
|
382
|
+
guard hasPermission else {
|
|
383
|
+
throw TokamakPluginError(
|
|
384
|
+
name: "NotAllowedError", message: "Location permission has not been granted")
|
|
203
385
|
}
|
|
204
|
-
return unavailable(error.localizedDescription)
|
|
205
386
|
}
|
|
206
387
|
|
|
207
|
-
private func
|
|
208
|
-
|
|
388
|
+
private func holdAlwaysSession() {
|
|
389
|
+
#if os(iOS)
|
|
390
|
+
guard #available(iOS 18, *) else { return }
|
|
391
|
+
let always = watching.authorizationStatus == .authorizedAlways
|
|
392
|
+
if always && alwaysSession == nil {
|
|
393
|
+
alwaysSession = CLServiceSession(authorization: .always)
|
|
394
|
+
} else if !always {
|
|
395
|
+
(alwaysSession as? CLServiceSession)?.invalidate()
|
|
396
|
+
alwaysSession = nil
|
|
397
|
+
}
|
|
398
|
+
#endif
|
|
209
399
|
}
|
|
210
400
|
|
|
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
|
-
]
|
|
401
|
+
private static func state(_ status: CLAuthorizationStatus) -> String {
|
|
402
|
+
switch status {
|
|
403
|
+
case .notDetermined: "prompt"
|
|
404
|
+
case .denied, .restricted: "denied"
|
|
405
|
+
default: "granted"
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/// Whether the Info.plist declares Always permission, as the `background`
|
|
410
|
+
/// build setting does on iOS and every macOS build does.
|
|
411
|
+
private static let declaresAlways =
|
|
412
|
+
Bundle.main.object(forInfoDictionaryKey: "NSLocationAlwaysAndWhenInUseUsageDescription") != nil
|
|
413
|
+
|
|
414
|
+
private var askedForAlways: Bool {
|
|
415
|
+
defaults.bool(forKey: askedForAlwaysKey)
|
|
231
416
|
}
|
|
232
417
|
|
|
233
|
-
|
|
234
|
-
|
|
418
|
+
/// Asks for in-use permission while the user has not answered, or for
|
|
419
|
+
/// Always permission once from When In Use, then for temporary precise
|
|
420
|
+
/// location when `options` name a purpose.
|
|
421
|
+
private func requestPermission(
|
|
422
|
+
_ options: [String: Any], _ reply: @escaping TokamakPluginReply
|
|
423
|
+
) throws(TokamakPluginError) {
|
|
424
|
+
let always = options["background"] as? Bool == true
|
|
425
|
+
if always && !Self.declaresAlways {
|
|
426
|
+
throw .notSupported(
|
|
427
|
+
"Background location needs the location plugin's background build setting")
|
|
428
|
+
}
|
|
429
|
+
let prompt = Prompt(purpose: try Self.purpose(options), reply: reply)
|
|
430
|
+
let status = watching.authorizationStatus
|
|
431
|
+
let upgrades = always && canUpgradeToAlways(status)
|
|
432
|
+
guard status == .notDetermined || upgrades else {
|
|
433
|
+
respond(to: prompt)
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
try host.requireUI()
|
|
437
|
+
prompts[UUID()] = prompt
|
|
438
|
+
if always {
|
|
439
|
+
defaults.set(true, forKey: askedForAlwaysKey)
|
|
440
|
+
watching.requestAlwaysAuthorization()
|
|
441
|
+
} else {
|
|
442
|
+
watching.requestWhenInUseAuthorization()
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/// The purpose key of the temporary precise location `options` ask for.
|
|
447
|
+
private static func purpose(_ options: [String: Any]) throws(TokamakPluginError) -> String? {
|
|
448
|
+
guard options["precision"] as? String == "precise" else { return nil }
|
|
449
|
+
let purposes =
|
|
450
|
+
Bundle.main.object(forInfoDictionaryKey: "NSLocationTemporaryUsageDescriptionDictionary")
|
|
451
|
+
as? [String: Any]
|
|
452
|
+
guard let purpose = options["purpose"] as? String, purposes?[purpose] != nil else {
|
|
453
|
+
throw TokamakPluginError(
|
|
454
|
+
name: "TypeError",
|
|
455
|
+
message: "precise location needs a purpose from the accuracy-purposes build setting")
|
|
456
|
+
}
|
|
457
|
+
return purpose
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
#if os(iOS)
|
|
461
|
+
@objc private func willResignActive() {
|
|
462
|
+
shown = Array(prompts.keys)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/// Answers the prompts that were showing, as the system reports no change
|
|
466
|
+
/// when the user keeps When In Use.
|
|
467
|
+
@objc private func didBecomeActive() {
|
|
468
|
+
let answered = shown
|
|
469
|
+
shown = []
|
|
470
|
+
guard !answered.isEmpty else { return }
|
|
471
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + authorizationChangeDelay) {
|
|
472
|
+
self.answer(answered)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
#endif
|
|
476
|
+
|
|
477
|
+
/// Answers the prompts `keys` that still wait.
|
|
478
|
+
private func answer(_ keys: [UUID]) {
|
|
479
|
+
for key in keys {
|
|
480
|
+
if let prompt = prompts.removeValue(forKey: key) {
|
|
481
|
+
respond(to: prompt)
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/// Replies to `prompt` with the app's permission, first asking for temporary
|
|
487
|
+
/// precise location for its purpose when the app has approximate location.
|
|
488
|
+
private func respond(to prompt: Prompt) {
|
|
489
|
+
guard let purpose = prompt.purpose, hasPermission,
|
|
490
|
+
watching.accuracyAuthorization == .reducedAccuracy
|
|
491
|
+
else {
|
|
492
|
+
prompt.reply(.success(permission()))
|
|
493
|
+
return
|
|
494
|
+
}
|
|
495
|
+
do throws(TokamakPluginError) {
|
|
496
|
+
try host.requireUI()
|
|
497
|
+
} catch {
|
|
498
|
+
prompt.reply(.failure(error))
|
|
499
|
+
return
|
|
500
|
+
}
|
|
501
|
+
watching.requestTemporaryFullAccuracyAuthorization(withPurposeKey: purpose) { _ in
|
|
502
|
+
DispatchQueue.main.async { prompt.reply(.success(self.permission())) }
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/// Opens the app's page of the system settings on iOS, and Location Services on macOS.
|
|
507
|
+
private func openSettings(_ reply: @escaping TokamakPluginReply) {
|
|
508
|
+
let notOpened = TokamakPluginError(
|
|
509
|
+
name: "OperationError", message: "The location settings could not be opened")
|
|
510
|
+
#if os(iOS)
|
|
511
|
+
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) { opened in
|
|
512
|
+
reply(opened ? .success(nil) : .failure(notOpened))
|
|
513
|
+
}
|
|
514
|
+
#else
|
|
515
|
+
let page = "com.apple.preference.security?Privacy_LocationServices"
|
|
516
|
+
let opened = URL(string: "x-apple.systempreferences:\(page)").map(NSWorkspace.shared.open)
|
|
517
|
+
reply(opened == true ? .success(nil) : .failure(notOpened))
|
|
518
|
+
#endif
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
private func locationError(_ error: Error) -> TokamakPluginError {
|
|
522
|
+
if (error as NSError).code == CLError.Code.denied.rawValue {
|
|
523
|
+
return Self.permissionDenied
|
|
524
|
+
}
|
|
525
|
+
return .locationUnavailable(error.localizedDescription)
|
|
235
526
|
}
|
|
236
527
|
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import CoreLocation
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
#if os(iOS)
|
|
5
|
+
import UIKit
|
|
6
|
+
#endif
|
|
7
|
+
|
|
8
|
+
/// The JSON of each monitored region, as `addRegion` received it, by id.
|
|
9
|
+
private let regionsKey = "tokamak.location.regions"
|
|
10
|
+
/// `CLMonitor` monitors at most 20 conditions for an app, and reports no
|
|
11
|
+
/// error beyond them.
|
|
12
|
+
private let regionLimit = 20
|
|
13
|
+
/// The monitor's name, which must be alphanumeric.
|
|
14
|
+
private let monitorName = "TokamakLocation"
|
|
15
|
+
|
|
16
|
+
/// Circular regions in one `CLMonitor`, which the plugin creates at launch
|
|
17
|
+
/// and reads events from for the life of the process. The system launches the
|
|
18
|
+
/// app for their events on iOS. Use it on the main thread.
|
|
19
|
+
final class TokamakLocationRegions {
|
|
20
|
+
private let events: TokamakLocationEvents
|
|
21
|
+
private let defaults = UserDefaults.standard
|
|
22
|
+
private let monitor: Task<CLMonitor, Never>
|
|
23
|
+
|
|
24
|
+
init(events: TokamakLocationEvents) {
|
|
25
|
+
self.events = events
|
|
26
|
+
let monitor = Task { @MainActor in
|
|
27
|
+
await Self.protectedData()
|
|
28
|
+
return await CLMonitor(monitorName)
|
|
29
|
+
}
|
|
30
|
+
self.monitor = monitor
|
|
31
|
+
Task { @MainActor [weak self] in
|
|
32
|
+
do {
|
|
33
|
+
for try await event in await monitor.value.events {
|
|
34
|
+
self?.receive(event)
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
self?.events.fail(
|
|
38
|
+
.locationUnavailable(error.localizedDescription))
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/// The regions, by id.
|
|
44
|
+
private var regions: [String: [String: Any]] {
|
|
45
|
+
get {
|
|
46
|
+
defaults.data(forKey: regionsKey)
|
|
47
|
+
.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: [String: Any]] } ?? [:]
|
|
48
|
+
}
|
|
49
|
+
set {
|
|
50
|
+
defaults.set(try? JSONSerialization.data(withJSONObject: newValue), forKey: regionsKey)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// Monitors the region `region` describes, replacing one with the same id.
|
|
55
|
+
/// It starts outside, so a device already inside reports an entry, as on
|
|
56
|
+
/// Android.
|
|
57
|
+
func add(_ region: [String: Any]) async throws(TokamakPluginError) {
|
|
58
|
+
guard let id = region["id"] as? String, let radius = region["radius"] as? Double,
|
|
59
|
+
let latitude = region["latitude"] as? Double, let longitude = region["longitude"] as? Double
|
|
60
|
+
else {
|
|
61
|
+
throw TokamakPluginError(
|
|
62
|
+
name: "TypeError", message: "region must have an id, latitude, longitude and radius")
|
|
63
|
+
}
|
|
64
|
+
let monitor = await monitor.value
|
|
65
|
+
let monitored = await monitor.identifiers
|
|
66
|
+
guard monitored.count < regionLimit || monitored.contains(id) else {
|
|
67
|
+
throw TokamakPluginError(
|
|
68
|
+
name: "QuotaExceededError",
|
|
69
|
+
message: "The app monitors \(regionLimit) regions, the most the system allows")
|
|
70
|
+
}
|
|
71
|
+
let center = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
|
|
72
|
+
let condition = CLMonitor.CircularGeographicCondition(center: center, radius: radius)
|
|
73
|
+
regions[id] = region
|
|
74
|
+
await monitor.add(condition, identifier: id, assuming: .unsatisfied)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
func remove(_ id: String) async {
|
|
78
|
+
regions[id] = nil
|
|
79
|
+
events.report([], region: id)
|
|
80
|
+
await monitor.value.remove(id)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/// The monitored regions, each with whether the device is inside it.
|
|
84
|
+
func all() async -> [[String: Any]] {
|
|
85
|
+
let monitor = await monitor.value
|
|
86
|
+
let regions = regions
|
|
87
|
+
var all: [[String: Any]] = []
|
|
88
|
+
for id in await monitor.identifiers {
|
|
89
|
+
guard var region = regions[id] else { continue }
|
|
90
|
+
region["state"] =
|
|
91
|
+
switch await monitor.record(for: id)?.lastEvent.state {
|
|
92
|
+
case .satisfied: "inside"
|
|
93
|
+
case .unsatisfied: "outside"
|
|
94
|
+
default: "unknown"
|
|
95
|
+
}
|
|
96
|
+
all.append(region)
|
|
97
|
+
}
|
|
98
|
+
return all
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private func receive(_ event: CLMonitor.Event) {
|
|
102
|
+
let id = event.identifier
|
|
103
|
+
if #available(iOS 18, macOS 15, *) {
|
|
104
|
+
events.report(Self.problems(event), region: id)
|
|
105
|
+
}
|
|
106
|
+
guard let region = regions[id] else { return }
|
|
107
|
+
switch event.state {
|
|
108
|
+
case .satisfied where region["onEntry"] as? Bool != false:
|
|
109
|
+
events.transition(id, entered: true, at: event.date)
|
|
110
|
+
case .unsatisfied where region["onExit"] as? Bool != false:
|
|
111
|
+
events.transition(id, entered: false, at: event.date)
|
|
112
|
+
default:
|
|
113
|
+
break
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/// The problems that stop monitoring the region, which `event` names.
|
|
118
|
+
@available(iOS 18, macOS 15, *)
|
|
119
|
+
private static func problems(_ event: CLMonitor.Event) -> [TokamakPluginError] {
|
|
120
|
+
let flags = [
|
|
121
|
+
(event.authorizationDenied, "NotAllowedError", "Location permission was denied"),
|
|
122
|
+
(event.authorizationDeniedGlobally, "NotAllowedError", "Location services are off"),
|
|
123
|
+
(event.authorizationRestricted, "NotAllowedError", "Location is restricted on this device"),
|
|
124
|
+
(
|
|
125
|
+
event.insufficientlyInUse, "NotAllowedError",
|
|
126
|
+
"Regions need the app in use, or Always permission"
|
|
127
|
+
),
|
|
128
|
+
(event.accuracyLimited, "NotAllowedError", "Regions need precise location"),
|
|
129
|
+
(event.conditionUnsupported, "NotSupportedError", "The device cannot monitor the region"),
|
|
130
|
+
(
|
|
131
|
+
event.conditionLimitExceeded, "QuotaExceededError",
|
|
132
|
+
"The app monitors the most regions the system allows"
|
|
133
|
+
),
|
|
134
|
+
(
|
|
135
|
+
event.persistenceUnavailable, "NotReadableError",
|
|
136
|
+
"Regions are unavailable until the device is first unlocked"
|
|
137
|
+
),
|
|
138
|
+
]
|
|
139
|
+
return flags.filter(\.0).map { TokamakPluginError(name: $0.1, message: $0.2) }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/// Returns once protected data is available, which `CLMonitor` needs.
|
|
143
|
+
@MainActor
|
|
144
|
+
private static func protectedData() async {
|
|
145
|
+
#if os(iOS)
|
|
146
|
+
guard !UIApplication.shared.isProtectedDataAvailable else { return }
|
|
147
|
+
let available = NotificationCenter.default.notifications(
|
|
148
|
+
named: UIApplication.protectedDataDidBecomeAvailableNotification)
|
|
149
|
+
for await _ in available {
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
#endif
|
|
153
|
+
}
|
|
154
|
+
}
|