@npaw/forge-react-native 9.7.0 → 9.7.2

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.
Files changed (40) hide show
  1. package/NPAWForgeReactNative.podspec +1 -1
  2. package/android/build.gradle +1 -1
  3. package/android/src/main/java/com/npaw/forge/reactnative/ForgeDtoMapper.java +10 -0
  4. package/android/src/main/java/com/npaw/forge/reactnative/ForgeInstanceRegistry.java +59 -1
  5. package/android/src/main/java/com/npaw/forge/reactnative/NPAWForgeModule.java +33 -0
  6. package/android/src/test/java/com/npaw/forge/reactnative/ForgeInstanceRegistryTest.java +65 -0
  7. package/app.plugin.js +17 -0
  8. package/ios/ForgeApple/ForgeKernelFFI.xcframework/Info.plist +9 -9
  9. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/forge_kernelFFI.h +71 -0
  10. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/libForgeKernelFFI.a +0 -0
  11. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/forge_kernelFFI.h +71 -0
  12. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/libForgeKernelFFI.a +0 -0
  13. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/forge_kernelFFI.h +71 -0
  14. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/libForgeKernelFFI.a +0 -0
  15. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/forge_kernelFFI.h +71 -0
  16. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/libForgeKernelFFI.a +0 -0
  17. package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernel.swift +198 -1
  18. package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernelFFI.h +71 -0
  19. package/ios/ForgeApple/Sources/NPAWForgeAVPlayerAdapter/AVPlayerAdapter.swift +12 -0
  20. package/ios/ForgeApple/Sources/NPAWForgeCore/ConnectionType.swift +125 -0
  21. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeApple.swift +67 -1
  22. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeCapturePlan.swift +4 -0
  23. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeLogging.swift +80 -3
  24. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeRuntime.swift +44 -0
  25. package/ios/ForgeApple/Sources/NPAWForgeCore/LocationObserver.swift +183 -0
  26. package/ios/ForgeApple/Sources/NPAWForgeCore/NPAWForge.swift +123 -2
  27. package/ios/ForgeApple/Sources/NPAWForgeCore/NativeUniFFIRuntime.swift +59 -0
  28. package/ios/ForgeApple/Sources/NPAWForgeCore/PlayerAdapter.swift +17 -1
  29. package/ios/ForgeApple/Sources/NPAWForgeCore/VideoAdapter.swift +13 -4
  30. package/ios/ForgeInstanceRegistry.swift +69 -0
  31. package/ios/ForgeModuleImpl.swift +53 -6
  32. package/ios/NPAWForgeTurboModule.mm +6 -1
  33. package/package.json +3 -1
  34. package/plugin/index.js +82 -0
  35. package/plugin/mutations.js +140 -0
  36. package/src/NPAWForge.ts +12 -1
  37. package/src/events.ts +21 -1
  38. package/src/index.ts +11 -1
  39. package/src/sections.ts +292 -0
  40. package/src/types.ts +23 -2
@@ -30,6 +30,10 @@ public struct ForgeAppleConfig: Equatable {
30
30
  public var logLevel: ForgeLogLevel
31
31
  public var deviceUuid: String?
32
32
  public var deviceInfo: ForgeDeviceInfo?
33
+ /// Framework layered on top of this facade, when one applies. Set by
34
+ /// first-party bindings only - React Native declares itself here so its
35
+ /// traffic is distinguishable in `pluginInfo`. The platform stays `apple`.
36
+ public var framework: String?
33
37
 
34
38
  public init(
35
39
  accountCode: String,
@@ -46,7 +50,8 @@ public struct ForgeAppleConfig: Equatable {
46
50
  preSessionEventQueueMaxAgeMs: Int64 = 30_000,
47
51
  logLevel: ForgeLogLevel = .warning,
48
52
  deviceUuid: String? = nil,
49
- deviceInfo: ForgeDeviceInfo? = nil
53
+ deviceInfo: ForgeDeviceInfo? = nil,
54
+ framework: String? = nil
50
55
  ) {
51
56
  self.accountCode = accountCode
52
57
  self.pluginVersion = pluginVersion
@@ -63,6 +68,7 @@ public struct ForgeAppleConfig: Equatable {
63
68
  self.logLevel = logLevel
64
69
  self.deviceUuid = deviceUuid
65
70
  self.deviceInfo = deviceInfo
71
+ self.framework = framework
66
72
  }
67
73
  }
68
74
 
@@ -140,6 +146,7 @@ public final class ForgeApple {
140
146
  private var onSendRequestListeners: [UUID: (String, inout [String: String]) -> Void] = [:]
141
147
  private var capturePlanObservers: [UUID: (ForgeCapturePlan) -> Void] = [:]
142
148
  private var latestCapturePlan: ForgeCapturePlan?
149
+ private var logObservers: [UUID: (ForgeRenderedLog) -> Void] = [:]
143
150
 
144
151
  public init(config: ForgeAppleConfig, services: ForgeAppleServices) {
145
152
  let safeConfig = config.withPluginVersion(NPAWForge.defaultPluginVersion)
@@ -150,6 +157,11 @@ public final class ForgeApple {
150
157
  _ = runtime.setCapturePlanListener { [weak self] plan in
151
158
  self?.dispatchCapturePlan(plan)
152
159
  }
160
+ // Registered before start() so the kernel's own start-up decisions are
161
+ // the first thing a `logLevel: trace` host sees.
162
+ _ = runtime.setLogListener { [weak self] log in
163
+ self?.dispatchRenderedLog(log)
164
+ }
153
165
  _ = runtime.start()
154
166
  if safeConfig.startSessionOnInitialize {
155
167
  _ = startSession()
@@ -372,6 +384,20 @@ public final class ForgeApple {
372
384
  return runtime.setLogLevel(level)
373
385
  }
374
386
 
387
+ /// Forwards the observed device connection class to the kernel, which
388
+ /// applies it only when the integrator supplied no `connectionType` of
389
+ /// their own.
390
+ @discardableResult
391
+ public func setAutoConnectionType(_ connectionType: String?) -> ForgeOutcome {
392
+ runtime.setAutoConnectionType(connectionType)
393
+ }
394
+
395
+ /// Forwards a location fix to the kernel, which owns every threshold.
396
+ @discardableResult
397
+ public func recordLocationFix(_ fix: ForgeLocationFix) -> ForgeOutcome {
398
+ runtime.recordLocationFix(fix)
399
+ }
400
+
375
401
  public func effectiveLogLevel() -> ForgeLogLevel {
376
402
  logger.effectiveLevel()
377
403
  }
@@ -483,6 +509,44 @@ public final class ForgeApple {
483
509
  return latest ?? runtime.capturePlan()
484
510
  }
485
511
 
512
+ /// Observes the kernel's rendered decision log. Console output happens
513
+ /// regardless of whether anything is registered here; listeners are for
514
+ /// hosts that want to forward the stream (the React Native binding does).
515
+ public func addLogListener(_ listener: @escaping (ForgeRenderedLog) -> Void) -> ForgeEventSubscription {
516
+ let id = UUID()
517
+ lock.lock()
518
+ logObservers[id] = listener
519
+ lock.unlock()
520
+ return ForgeEventSubscription { [weak self] in
521
+ self?.lock.lock()
522
+ self?.logObservers.removeValue(forKey: id)
523
+ self?.lock.unlock()
524
+ }
525
+ }
526
+
527
+ /// Sends the kernel line to the platform console through the logger, then
528
+ /// to every registered observer. The logger owns formatting, so observers
529
+ /// and the console always agree on what a line says.
530
+ private func dispatchRenderedLog(_ log: ForgeRenderedLog) {
531
+ lock.lock()
532
+ guard !destroyed else {
533
+ lock.unlock()
534
+ return
535
+ }
536
+ lock.unlock()
537
+ guard let emitted = logger.emitRendered(
538
+ tag: log.tag,
539
+ levelName: log.level.wireName,
540
+ message: log.message
541
+ ) else {
542
+ return
543
+ }
544
+ lock.lock()
545
+ let observers = Array(logObservers.values)
546
+ lock.unlock()
547
+ observers.forEach { $0(emitted) }
548
+ }
549
+
486
550
  private func dispatchCapturePlan(_ plan: ForgeCapturePlan) {
487
551
  lock.lock()
488
552
  guard !destroyed else {
@@ -515,9 +579,11 @@ public final class ForgeApple {
515
579
  eventObservers.removeAll()
516
580
  onSendRequestListeners.removeAll()
517
581
  capturePlanObservers.removeAll()
582
+ logObservers.removeAll()
518
583
  recordedEvents.removeAll()
519
584
  _ = runtime.registerSessionPreSendHook(nil)
520
585
  _ = runtime.setCapturePlanListener(nil)
586
+ _ = runtime.setLogListener(nil)
521
587
  return runtime.destroy()
522
588
  }
523
589
 
@@ -207,6 +207,10 @@ public struct ForgeCapturePlan: Equatable {
207
207
  // positions stay unassigned so existing bits never renumber.
208
208
  public static let sections: UInt32 = 1 << 13
209
209
  public static let attribution: UInt32 = 1 << 14
210
+ /// The operator enabled at least one `mo.*` switch, so the host may
211
+ /// observe location. Without this bit Forge reads no location at all,
212
+ /// not even the authorization status.
213
+ public static let location: UInt32 = 1 << 15
210
214
  }
211
215
  }
212
216
 
@@ -38,9 +38,21 @@ public enum ForgeLogLevel: Int {
38
38
  if let level = value as? ForgeLogLevel {
39
39
  return level
40
40
  }
41
- guard let raw = value.map({ String(describing: $0).lowercased() }) else {
41
+ guard let raw = value.map({ String(describing: $0) }) else {
42
42
  return defaultLevel
43
43
  }
44
+ return parse(raw) ?? defaultLevel
45
+ }
46
+
47
+ /// Strict counterpart to `from(_:default:)`: an unknown or blank name
48
+ /// yields `nil` instead of a default. Kernel-rendered lines carry their
49
+ /// level as a wire string, and a level Forge does not recognize must be
50
+ /// dropped rather than silently relabelled.
51
+ public static func parse(_ value: String?) -> ForgeLogLevel? {
52
+ guard let raw = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
53
+ !raw.isEmpty else {
54
+ return nil
55
+ }
44
56
  switch raw {
45
57
  case "off": return .off
46
58
  case "error": return .error
@@ -48,11 +60,26 @@ public enum ForgeLogLevel: Int {
48
60
  case "info": return .info
49
61
  case "debug": return .debug
50
62
  case "trace": return .trace
51
- default: return defaultLevel
63
+ default: return nil
52
64
  }
53
65
  }
54
66
  }
55
67
 
68
+ /// A log line the kernel already rendered, level-gated and redacted. Mirrors
69
+ /// the Android binding's `ForgeRenderedLog` so both platforms hand hosts the
70
+ /// same shape.
71
+ public struct ForgeRenderedLog: Equatable {
72
+ public let level: ForgeLogLevel
73
+ public let tag: String
74
+ public let message: String
75
+
76
+ public init(level: ForgeLogLevel, tag: String, message: String) {
77
+ self.level = level
78
+ self.tag = tag
79
+ self.message = message
80
+ }
81
+ }
82
+
56
83
  public enum ForgeLogFamily: String {
57
84
  case video = "Video"
58
85
  case ad = "Ad"
@@ -68,15 +95,40 @@ public enum ForgeLogFamily: String {
68
95
 
69
96
  public protocol ForgeLogSink: AnyObject {
70
97
  func log(level: ForgeLogLevel, family: ForgeLogFamily, message: String, attributes: [String: String])
98
+ /// Emits a line the kernel already rendered. The kernel owns the family
99
+ /// label, the attribute rendering and the level gate for this stream, so
100
+ /// the sink receives finished text under the kernel's own tag.
101
+ func log(level: ForgeLogLevel, tag: String, message: String)
102
+ }
103
+
104
+ /// Additive default so sinks written before the kernel log stream existed keep
105
+ /// compiling and still receive it, through the entry point they already
106
+ /// implement.
107
+ public extension ForgeLogSink {
108
+ func log(level: ForgeLogLevel, tag: String, message: String) {
109
+ log(level: level, family: .diagnostics, message: message, attributes: ["tag": tag])
110
+ }
71
111
  }
72
112
 
73
113
  public final class OSLogForgeLogSink: ForgeLogSink {
114
+ /// Prefix on every Forge Apple line, facade and kernel alike, so one grep
115
+ /// token finds the whole stream.
116
+ public static let platformTag = "NPAW-Forge-Apple"
117
+
74
118
  public init() {}
75
119
 
76
120
  public func log(level: ForgeLogLevel, family: ForgeLogFamily, message: String, attributes: [String: String]) {
77
121
  let rendered = "[\(family.rawValue)] \(message) \(attributes)"
78
122
  guard level != .off else { return }
79
- NSLog("[NPAW-Forge-Apple] %@", rendered)
123
+ NSLog("[\(Self.platformTag)] %@", rendered)
124
+ }
125
+
126
+ public func log(level: ForgeLogLevel, tag: String, message: String) {
127
+ guard level != .off else { return }
128
+ // The kernel's own tag is kept, except when it would simply repeat the
129
+ // platform prefix.
130
+ let scope = tag.isEmpty || tag == Self.platformTag ? "" : "[\(tag)] "
131
+ NSLog("[\(Self.platformTag)] %@", "\(scope)\(message)")
80
132
  }
81
133
  }
82
134
 
@@ -106,4 +158,29 @@ public final class ForgeLogger {
106
158
  guard level.rawValue >= ForgeLogLevel.error.rawValue else { return }
107
159
  sink?.log(level: .error, family: family, message: action, attributes: details)
108
160
  }
161
+
162
+ /// Emits a line the kernel rendered, and returns it so the facade can fan
163
+ /// it out to host listeners. Malformed input (unknown level, blank
164
+ /// message) is dropped and reported as `nil`.
165
+ ///
166
+ /// Deliberately not re-gated against `level`: the kernel already applied
167
+ /// the effective level *and* the remote `lg.lvl` floor, which exists so
168
+ /// support can raise verbosity remotely. Re-checking the host level here
169
+ /// would silently defeat that floor on Apple.
170
+ @discardableResult
171
+ public func emitRendered(tag: String?, levelName: String?, message: String?) -> ForgeRenderedLog? {
172
+ guard let level = ForgeLogLevel.parse(levelName), level != .off else { return nil }
173
+ guard let message = message?.trimmingCharacters(in: .whitespacesAndNewlines),
174
+ !message.isEmpty else {
175
+ return nil
176
+ }
177
+ let resolvedTag = tag?.trimmingCharacters(in: .whitespacesAndNewlines)
178
+ .nonEmpty ?? OSLogForgeLogSink.platformTag
179
+ sink?.log(level: level, tag: resolvedTag, message: message)
180
+ return ForgeRenderedLog(level: level, tag: resolvedTag, message: message)
181
+ }
182
+ }
183
+
184
+ private extension String {
185
+ var nonEmpty: String? { isEmpty ? nil : self }
109
186
  }
@@ -64,6 +64,13 @@ public protocol RuntimeSessionPreSendHook: AnyObject {
64
64
  public protocol ForgeRuntime: AnyObject {
65
65
  func start() -> ForgeOutcome
66
66
  func setLogLevel(_ level: ForgeLogLevel) -> ForgeOutcome
67
+ /// Publishes the connection class the device is currently on, or `nil` to
68
+ /// clear it. The kernel keeps the last value and puts it on the wire only
69
+ /// when the integrator supplied no `connectionType` of their own.
70
+ func setAutoConnectionType(_ connectionType: String?) -> ForgeOutcome
71
+ /// Forwards a location fix. Fixes only - the kernel owns every threshold,
72
+ /// so the three platforms cannot drift into different definitions of moving.
73
+ func recordLocationFix(_ fix: ForgeLocationFix) -> ForgeOutcome
67
74
  func createVideoView(timestampMs: Int64, options: VideoOptions) -> RuntimeVideoView
68
75
  func recordEvent(_ event: ForgeRecordedEvent) -> ForgeOutcome
69
76
  /// Records a typed identity transition (`loginSuccess`, `loginFailed`,
@@ -102,6 +109,9 @@ public protocol ForgeRuntime: AnyObject {
102
109
  func setCapturePlanListener(_ listener: ((ForgeCapturePlan) -> Void)?) -> ForgeOutcome
103
110
  /// Latest kernel-emitted capture plan, or `nil` before the first emission.
104
111
  func capturePlan() -> ForgeCapturePlan?
112
+ /// Registers (or clears with `nil`) the kernel rendered-log observer. Lines
113
+ /// the kernel rendered before registration are replayed on registration.
114
+ func setLogListener(_ listener: ((ForgeRenderedLog) -> Void)?) -> ForgeOutcome
105
115
  }
106
116
 
107
117
  /// Additive defaults so existing `ForgeRuntime` conformances (including the
@@ -110,9 +120,17 @@ public protocol ForgeRuntime: AnyObject {
110
120
  public extension ForgeRuntime {
111
121
  func setCapturePlanListener(_ listener: ((ForgeCapturePlan) -> Void)?) -> ForgeOutcome { .accepted }
112
122
  func capturePlan() -> ForgeCapturePlan? { nil }
123
+ /// Additive default: runtimes with no kernel behind them render no logs,
124
+ /// so registration succeeds and simply never delivers.
125
+ func setLogListener(_ listener: ((ForgeRenderedLog) -> Void)?) -> ForgeOutcome { .accepted }
113
126
  /// Additive default: runtimes without kernel identity support report the
114
127
  /// capability gap instead of silently accepting the transition.
115
128
  func recordIdentityEvent(kind: String, timemarkMs: Int64, fields: [String: String]) -> ForgeOutcome { .unsupportedCapability }
129
+ /// Additive default: a runtime with no kernel behind it has nowhere to keep
130
+ /// the observed connection class, so it accepts and forgets.
131
+ func setAutoConnectionType(_ connectionType: String?) -> ForgeOutcome { .accepted }
132
+ /// Additive default: no kernel behind it means nothing to derive motion with.
133
+ func recordLocationFix(_ fix: ForgeLocationFix) -> ForgeOutcome { .accepted }
116
134
  }
117
135
 
118
136
  public protocol ForgeRuntimeFactory: AnyObject {
@@ -128,6 +146,8 @@ public final class UnavailableForgeRuntime: ForgeRuntime {
128
146
 
129
147
  public func start() -> ForgeOutcome { failed() }
130
148
  public func setLogLevel(_ level: ForgeLogLevel) -> ForgeOutcome { .accepted }
149
+ public func setAutoConnectionType(_ connectionType: String?) -> ForgeOutcome { .accepted }
150
+ public func recordLocationFix(_ fix: ForgeLocationFix) -> ForgeOutcome { .accepted }
131
151
  public func createVideoView(timestampMs: Int64, options: VideoOptions) -> RuntimeVideoView { RuntimeVideoView(id: -1) }
132
152
  public func recordEvent(_ event: ForgeRecordedEvent) -> ForgeOutcome { failed() }
133
153
  public func recordIdentityEvent(kind: String, timemarkMs: Int64, fields: [String: String]) -> ForgeOutcome { failed() }
@@ -189,12 +209,26 @@ public final class RecordingForgeRuntime: ForgeRuntime {
189
209
  public private(set) var destroyedViewIds: [Int64] = []
190
210
  public private(set) var capturePlanListener: ((ForgeCapturePlan) -> Void)?
191
211
  public private(set) var latestCapturePlan: ForgeCapturePlan?
212
+ public private(set) var logListener: ((ForgeRenderedLog) -> Void)?
213
+ /// Every connection class the observer published, in order, including the
214
+ /// `nil` that clears it on teardown.
215
+ public private(set) var publishedConnectionTypes: [String?] = []
216
+ /// Every location fix forwarded, in order.
217
+ public private(set) var recordedLocationFixes: [ForgeLocationFix] = []
192
218
  private var nextViewId: Int64 = 1
193
219
 
194
220
  public init() {}
195
221
 
196
222
  public func start() -> ForgeOutcome { .accepted }
197
223
  public func setLogLevel(_ level: ForgeLogLevel) -> ForgeOutcome { .accepted }
224
+ public func setAutoConnectionType(_ connectionType: String?) -> ForgeOutcome {
225
+ publishedConnectionTypes.append(connectionType)
226
+ return .accepted
227
+ }
228
+ public func recordLocationFix(_ fix: ForgeLocationFix) -> ForgeOutcome {
229
+ recordedLocationFixes.append(fix)
230
+ return .accepted
231
+ }
198
232
  public func createVideoView(timestampMs: Int64, options: VideoOptions) -> RuntimeVideoView {
199
233
  createdVideoTimestamps.append(timestampMs)
200
234
  defer { nextViewId += 1 }
@@ -288,4 +322,14 @@ public final class RecordingForgeRuntime: ForgeRuntime {
288
322
  latestCapturePlan = plan
289
323
  capturePlanListener?(plan)
290
324
  }
325
+
326
+ public func setLogListener(_ listener: ((ForgeRenderedLog) -> Void)?) -> ForgeOutcome {
327
+ logListener = listener
328
+ return .accepted
329
+ }
330
+
331
+ /// Test hook mirroring the kernel's rendered-log push path.
332
+ public func emitRenderedLog(_ log: ForgeRenderedLog) {
333
+ logListener?(log)
334
+ }
291
335
  }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Copyright (C) NPAW - All Rights Reserved
3
+ *
4
+ * This source code is protected under international copyright law. All rights
5
+ * reserved and protected by the copyright holders.
6
+ * This file is confidential and only available to authorized individuals with the
7
+ * permission of the copyright holders. If you encounter this file and do not have
8
+ * permission, please contact the copyright holders and delete this file.
9
+ *
10
+ *
11
+ * @author: Victor Soto <victor.soto@nicepeopleatwork.com>
12
+ * @description: Apple location observation for viewer motion, reading only an authorization the host app already holds.
13
+ */
14
+ import CoreLocation
15
+ import Foundation
16
+
17
+ /// A location fix forwarded to the kernel.
18
+ ///
19
+ /// The binding forwards fixes and never verdicts: the thresholds, hysteresis
20
+ /// and staleness rules all live in the kernel so Apple, Android and Web cannot
21
+ /// drift into three different definitions of "moving".
22
+ public struct ForgeLocationFix: Equatable {
23
+ public let latitude: Double
24
+ public let longitude: Double
25
+ /// Horizontal accuracy radius in metres. Negative means unknown, which the
26
+ /// kernel discards rather than guessing.
27
+ public let accuracyMetres: Double
28
+ public let timestampMs: Int64
29
+
30
+ public init(latitude: Double, longitude: Double, accuracyMetres: Double, timestampMs: Int64) {
31
+ self.latitude = latitude
32
+ self.longitude = longitude
33
+ self.accuracyMetres = accuracyMetres
34
+ self.timestampMs = timestampMs
35
+ }
36
+ }
37
+
38
+ /// Observes location while the host app already holds an authorization.
39
+ ///
40
+ /// **Forge never requests location authorization.** It reads
41
+ /// `CLLocationManager.authorizationStatus` and acts only on a grant the app
42
+ /// obtained for its own reasons. `requestWhenInUseAuthorization` and
43
+ /// `requestAlwaysAuthorization` appear nowhere in Forge, which is what keeps a
44
+ /// customer's permission dialogs and App Store disclosure unchanged by
45
+ /// integrating the SDK.
46
+ public final class ForgeLocationObserver: NSObject {
47
+ private let manager: CLLocationManager
48
+ private let publish: (ForgeLocationFix) -> Void
49
+ private let lock = NSLock()
50
+ private var started = false
51
+
52
+ public init(
53
+ manager: CLLocationManager = CLLocationManager(),
54
+ publish: @escaping (ForgeLocationFix) -> Void
55
+ ) {
56
+ self.manager = manager
57
+ self.publish = publish
58
+ super.init()
59
+ }
60
+
61
+ /// Whether the host app already holds a usable authorization.
62
+ ///
63
+ /// Both `authorizedWhenInUse` and `authorizedAlways` qualify. Reduced
64
+ /// accuracy is deliberately accepted: the kernel's accuracy gate is what
65
+ /// prevents a coarse fix inventing movement, so refusing coarse grants
66
+ /// would lose real coverage for no privacy gain.
67
+ public var isAuthorized: Bool {
68
+ // The instance property is iOS 14 / macOS 11; the package still
69
+ // supports iOS 13, so the deprecated class accessor is the fallback.
70
+ // Both only READ the status - neither prompts.
71
+ let status: CLAuthorizationStatus
72
+ if #available(iOS 14.0, tvOS 14.0, macOS 11.0, watchOS 7.0, *) {
73
+ status = manager.authorizationStatus
74
+ } else {
75
+ status = CLLocationManager.authorizationStatus()
76
+ }
77
+ switch status {
78
+ case .authorizedAlways, .authorizedWhenInUse:
79
+ return true
80
+ case .notDetermined, .restricted, .denied:
81
+ return false
82
+ @unknown default:
83
+ // A status Forge does not recognise is treated as "no grant".
84
+ return false
85
+ }
86
+ }
87
+
88
+ /// Starts observing if an authorization is already held. Starting twice is
89
+ /// a no-op, so a facade that rebuilds does not stack delegates.
90
+ ///
91
+ /// Inert on tvOS. Apple does not allow continuous location updates there —
92
+ /// `startUpdatingLocation()` is unavailable and only the one-shot
93
+ /// `requestLocation()` exists — and a set-top box does not move anyway, so
94
+ /// deriving viewer motion on it would answer a question nobody asked. The
95
+ /// dimension reports `unknown` on tvOS, which is the honest value.
96
+ public func start() {
97
+ #if os(tvOS)
98
+ return
99
+ #else
100
+ lock.lock()
101
+ if started {
102
+ lock.unlock()
103
+ return
104
+ }
105
+ lock.unlock()
106
+
107
+ guard isAuthorized else {
108
+ // No grant: stay completely inert. Not an error - the common case
109
+ // is an app that never asked for location. `started` deliberately
110
+ // stays false so a later grant, with the plan re-firing, can still
111
+ // start the observer; marking it started here would wedge the
112
+ // feature off for the rest of the session.
113
+ return
114
+ }
115
+
116
+ lock.lock()
117
+ started = true
118
+ lock.unlock()
119
+ manager.delegate = self
120
+ // Coarse cadence by design: this feature answers "moving or not",
121
+ // which needs neither navigation-grade accuracy nor its battery cost.
122
+ manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
123
+ manager.distanceFilter = 50
124
+ #if os(iOS) || os(watchOS)
125
+ manager.pausesLocationUpdatesAutomatically = true
126
+ #endif
127
+ manager.startUpdatingLocation()
128
+ #endif
129
+ }
130
+
131
+ /// Stops observing. Safe to call when never started.
132
+ public func stop() {
133
+ lock.lock()
134
+ let wasStarted = started
135
+ started = false
136
+ lock.unlock()
137
+
138
+ guard wasStarted else { return }
139
+ #if !os(tvOS)
140
+ manager.stopUpdatingLocation()
141
+ #endif
142
+ manager.delegate = nil
143
+ }
144
+
145
+ /// Converts and forwards a fix. Separate from the delegate callback
146
+ /// because a `CLLocation` is constructible in a test while a delegate
147
+ /// invocation from CoreLocation is not.
148
+ func report(_ location: CLLocation) {
149
+ guard location.horizontalAccuracy >= 0 else {
150
+ // CoreLocation reports a negative radius when the fix is invalid.
151
+ return
152
+ }
153
+ publish(
154
+ ForgeLocationFix(
155
+ latitude: location.coordinate.latitude,
156
+ longitude: location.coordinate.longitude,
157
+ accuracyMetres: location.horizontalAccuracy,
158
+ timestampMs: Int64(location.timestamp.timeIntervalSince1970 * 1000)
159
+ )
160
+ )
161
+ }
162
+ }
163
+
164
+ extension ForgeLocationObserver: CLLocationManagerDelegate {
165
+ public func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
166
+ guard let latest = locations.last else { return }
167
+ report(latest)
168
+ }
169
+
170
+ public func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
171
+ // A failed fix is not evidence of anything; the kernel's staleness rule
172
+ // decides what happens if they keep failing.
173
+ }
174
+
175
+ public func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
176
+ // Authorization revoked mid-session: stop observing rather than let
177
+ // CoreLocation keep a delegate on a manager that can no longer report.
178
+ // The kernel decays the dimension to `unknown` on its own.
179
+ if !isAuthorized {
180
+ stop()
181
+ }
182
+ }
183
+ }