@bglocation/capacitor 1.1.0

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 (61) hide show
  1. package/CapacitorBackgroundLocation.podspec +19 -0
  2. package/LICENSE.md +97 -0
  3. package/Package.swift +44 -0
  4. package/README.md +264 -0
  5. package/android/build.gradle +74 -0
  6. package/android/src/main/AndroidManifest.xml +37 -0
  7. package/android/src/main/kotlin/dev/bglocation/BackgroundLocationPlugin.kt +684 -0
  8. package/android/src/main/kotlin/dev/bglocation/core/Models.kt +76 -0
  9. package/android/src/main/kotlin/dev/bglocation/core/battery/BGLBatteryHelper.kt +127 -0
  10. package/android/src/main/kotlin/dev/bglocation/core/boot/BGLBootCompletedReceiver.kt +32 -0
  11. package/android/src/main/kotlin/dev/bglocation/core/config/BGLConfigParser.kt +114 -0
  12. package/android/src/main/kotlin/dev/bglocation/core/config/BGLVersion.kt +6 -0
  13. package/android/src/main/kotlin/dev/bglocation/core/debug/BGLDebugLogger.kt +174 -0
  14. package/android/src/main/kotlin/dev/bglocation/core/geofence/BGLGeofenceBroadcastReceiver.kt +93 -0
  15. package/android/src/main/kotlin/dev/bglocation/core/geofence/BGLGeofenceManager.kt +310 -0
  16. package/android/src/main/kotlin/dev/bglocation/core/http/BGLHttpSender.kt +187 -0
  17. package/android/src/main/kotlin/dev/bglocation/core/http/BGLLocationBuffer.kt +152 -0
  18. package/android/src/main/kotlin/dev/bglocation/core/license/BGLBuildConfig.kt +16 -0
  19. package/android/src/main/kotlin/dev/bglocation/core/license/BGLLicenseEnforcer.kt +137 -0
  20. package/android/src/main/kotlin/dev/bglocation/core/license/BGLLicenseValidator.kt +134 -0
  21. package/android/src/main/kotlin/dev/bglocation/core/license/BGLTrialTimer.kt +176 -0
  22. package/android/src/main/kotlin/dev/bglocation/core/location/BGLAdaptiveFilter.kt +94 -0
  23. package/android/src/main/kotlin/dev/bglocation/core/location/BGLHeartbeatTimer.kt +38 -0
  24. package/android/src/main/kotlin/dev/bglocation/core/location/BGLLocationForegroundService.kt +289 -0
  25. package/android/src/main/kotlin/dev/bglocation/core/location/BGLLocationHelpers.kt +72 -0
  26. package/android/src/main/kotlin/dev/bglocation/core/location/BGLPermissionManager.kt +99 -0
  27. package/android/src/main/kotlin/dev/bglocation/core/notification/BGLNotificationHelper.kt +77 -0
  28. package/dist/esm/definitions.d.ts +390 -0
  29. package/dist/esm/definitions.js +3 -0
  30. package/dist/esm/definitions.js.map +1 -0
  31. package/dist/esm/index.d.ts +4 -0
  32. package/dist/esm/index.js +26 -0
  33. package/dist/esm/index.js.map +1 -0
  34. package/dist/esm/web.d.ts +47 -0
  35. package/dist/esm/web.js +231 -0
  36. package/dist/esm/web.js.map +1 -0
  37. package/dist/esm/web.test.d.ts +1 -0
  38. package/dist/esm/web.test.js +940 -0
  39. package/dist/esm/web.test.js.map +1 -0
  40. package/dist/plugin.cjs.js +267 -0
  41. package/dist/plugin.cjs.js.map +1 -0
  42. package/dist/plugin.js +270 -0
  43. package/dist/plugin.js.map +1 -0
  44. package/ios/Sources/BGLocationCore/Config/BGLConfigParser.swift +88 -0
  45. package/ios/Sources/BGLocationCore/Config/BGLVersion.swift +6 -0
  46. package/ios/Sources/BGLocationCore/Debug/BGLDebugLogger.swift +201 -0
  47. package/ios/Sources/BGLocationCore/Geofence/BGLGeofenceManager.swift +538 -0
  48. package/ios/Sources/BGLocationCore/Http/BGLHttpSender.swift +227 -0
  49. package/ios/Sources/BGLocationCore/Http/BGLLocationBuffer.swift +198 -0
  50. package/ios/Sources/BGLocationCore/License/BGLBuildConfig.swift +11 -0
  51. package/ios/Sources/BGLocationCore/License/BGLLicenseEnforcer.swift +134 -0
  52. package/ios/Sources/BGLocationCore/License/BGLLicenseValidator.swift +163 -0
  53. package/ios/Sources/BGLocationCore/License/BGLTrialTimer.swift +168 -0
  54. package/ios/Sources/BGLocationCore/Location/BGLAdaptiveFilter.swift +91 -0
  55. package/ios/Sources/BGLocationCore/Location/BGLHeartbeatTimer.swift +50 -0
  56. package/ios/Sources/BGLocationCore/Location/BGLLocationData.swift +48 -0
  57. package/ios/Sources/BGLocationCore/Location/BGLLocationHelpers.swift +42 -0
  58. package/ios/Sources/BGLocationCore/Location/BGLLocationManager.swift +268 -0
  59. package/ios/Sources/BGLocationCore/Location/BGLPermissionManager.swift +33 -0
  60. package/ios/Sources/BackgroundLocationPlugin/BackgroundLocationPlugin.swift +657 -0
  61. package/package.json +75 -0
@@ -0,0 +1,227 @@
1
+ import Foundation
2
+
3
+ public struct BGLHttpResult {
4
+ public let statusCode: Int
5
+ public let success: Bool
6
+ public let responseText: String
7
+ public let error: String?
8
+ public let bufferedCount: Int
9
+
10
+ public init(
11
+ statusCode: Int, success: Bool, responseText: String, error: String?, bufferedCount: Int = 0
12
+ ) {
13
+ self.statusCode = statusCode
14
+ self.success = success
15
+ self.responseText = responseText
16
+ self.error = error
17
+ self.bufferedCount = bufferedCount
18
+ }
19
+ }
20
+
21
+ public class BGLHttpSender {
22
+
23
+ private var url: URL?
24
+ private var headers: [String: String] = [:]
25
+ private let session: URLSession
26
+ private var buffer: BGLLocationBuffer?
27
+ private var isFlushing = false
28
+ private let flushLock = NSLock()
29
+ private static let flushBatchSize = 50
30
+
31
+ /// Called for each HTTP result during buffer flush. Set this to receive onHttp events for flushed items.
32
+ public var onFlushProgress: ((BGLHttpResult) -> Void)?
33
+
34
+ public init(session: URLSession = .shared) {
35
+ self.session = session
36
+ }
37
+
38
+ public func configure(url: String?, headers: [String: String]) {
39
+ if let url = url {
40
+ self.url = URL(string: url)
41
+ } else {
42
+ self.url = nil
43
+ }
44
+ self.headers = headers
45
+ }
46
+
47
+ public func setBuffer(_ locationBuffer: BGLLocationBuffer?) {
48
+ buffer = locationBuffer
49
+ }
50
+
51
+ public var isConfigured: Bool {
52
+ url != nil
53
+ }
54
+
55
+ public func sendLocation(
56
+ _ location: BGLLocationData, completion: @escaping (BGLHttpResult) -> Void
57
+ ) {
58
+ guard let url = url else { return }
59
+
60
+ let locationDict = location.toDict()
61
+ executePost(url: url, locationDict: locationDict) { [weak self] result in
62
+ guard let self = self else { return }
63
+
64
+ if !result.success, let buf = self.buffer {
65
+ buf.add(location)
66
+ let count = buf.count()
67
+ print("BGLocation: HTTP failed, location buffered (buffer=\(count))")
68
+ completion(
69
+ BGLHttpResult(
70
+ statusCode: result.statusCode,
71
+ success: false,
72
+ responseText: result.responseText,
73
+ error: result.error,
74
+ bufferedCount: count
75
+ ))
76
+ } else {
77
+ let count = self.buffer?.count() ?? 0
78
+ completion(
79
+ BGLHttpResult(
80
+ statusCode: result.statusCode,
81
+ success: result.success,
82
+ responseText: result.responseText,
83
+ error: result.error,
84
+ bufferedCount: count
85
+ ))
86
+ // After a successful send, try to flush buffered items
87
+ if result.success && count > 0 {
88
+ self.flushBuffer(onProgress: self.onFlushProgress)
89
+ }
90
+ }
91
+ }
92
+ }
93
+
94
+ /// Flush buffered locations to the server.
95
+ public func flushBuffer(onProgress: ((BGLHttpResult) -> Void)?) {
96
+ guard let url = url, let buf = buffer else { return }
97
+ flushLock.lock()
98
+ guard !isFlushing else {
99
+ flushLock.unlock()
100
+ return
101
+ }
102
+ isFlushing = true
103
+ flushLock.unlock()
104
+
105
+ DispatchQueue.global(qos: .utility).async { [weak self] in
106
+ guard let self = self else { return }
107
+ defer {
108
+ self.flushLock.lock()
109
+ self.isFlushing = false
110
+ self.flushLock.unlock()
111
+ }
112
+
113
+ while true {
114
+ let batch = buf.peek(limit: BGLHttpSender.flushBatchSize)
115
+ if batch.isEmpty { break }
116
+
117
+ var successIds: [Int64] = []
118
+ let group = DispatchGroup()
119
+
120
+ for (id, location) in batch {
121
+ var sendResult: BGLHttpResult?
122
+ group.enter()
123
+ self.executePost(url: url, locationDict: location.toDict()) { result in
124
+ sendResult = result
125
+ group.leave()
126
+ }
127
+ group.wait()
128
+
129
+ if let result = sendResult, result.success {
130
+ successIds.append(id)
131
+ } else {
132
+ // Stop flushing on first failure
133
+ if !successIds.isEmpty {
134
+ buf.remove(ids: successIds)
135
+ }
136
+ let count = buf.count()
137
+ if let progress = onProgress {
138
+ DispatchQueue.main.async {
139
+ progress(
140
+ BGLHttpResult(
141
+ statusCode: sendResult?.statusCode ?? 0,
142
+ success: false,
143
+ responseText: sendResult?.responseText ?? "",
144
+ error: sendResult?.error,
145
+ bufferedCount: count
146
+ ))
147
+ }
148
+ }
149
+ return
150
+ }
151
+ }
152
+ if !successIds.isEmpty {
153
+ buf.remove(ids: successIds)
154
+ }
155
+ }
156
+
157
+ let count = buf.count()
158
+ if count == 0 {
159
+ print("BGLocation: Buffer flush complete — all locations sent")
160
+ }
161
+ if let progress = onProgress {
162
+ DispatchQueue.main.async {
163
+ progress(
164
+ BGLHttpResult(
165
+ statusCode: 200,
166
+ success: true,
167
+ responseText: "",
168
+ error: nil,
169
+ bufferedCount: count
170
+ ))
171
+ }
172
+ }
173
+ }
174
+ }
175
+
176
+ // MARK: - Private
177
+
178
+ private func executePost(
179
+ url: URL, locationDict: [String: Any], completion: @escaping (BGLHttpResult) -> Void
180
+ ) {
181
+ var request = URLRequest(url: url)
182
+ request.httpMethod = "POST"
183
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
184
+ request.timeoutInterval = 10
185
+
186
+ for (key, value) in headers {
187
+ request.setValue(value, forHTTPHeaderField: key)
188
+ }
189
+
190
+ let body: [String: Any] = ["location": locationDict]
191
+
192
+ do {
193
+ request.httpBody = try JSONSerialization.data(withJSONObject: body)
194
+ } catch {
195
+ completion(
196
+ BGLHttpResult(
197
+ statusCode: 0, success: false, responseText: "",
198
+ error: error.localizedDescription))
199
+ return
200
+ }
201
+
202
+ session.dataTask(with: request) { data, response, error in
203
+ if let error = error {
204
+ print("BGLocation: HTTP POST failed — \(error.localizedDescription)")
205
+ completion(
206
+ BGLHttpResult(
207
+ statusCode: 0, success: false, responseText: "",
208
+ error: error.localizedDescription))
209
+ return
210
+ }
211
+
212
+ let httpResponse = response as? HTTPURLResponse
213
+ let statusCode = httpResponse?.statusCode ?? 0
214
+ let responseText = data.flatMap { String(data: $0, encoding: .utf8) } ?? ""
215
+
216
+ print("BGLocation: HTTP POST → \(url) → \(statusCode)")
217
+
218
+ completion(
219
+ BGLHttpResult(
220
+ statusCode: statusCode,
221
+ success: (200...299).contains(statusCode),
222
+ responseText: responseText,
223
+ error: nil
224
+ ))
225
+ }.resume()
226
+ }
227
+ }
@@ -0,0 +1,198 @@
1
+ import Foundation
2
+ import SQLite3
3
+
4
+ /// SQLite-backed offline buffer for location data.
5
+ ///
6
+ /// When HTTP upload fails, locations are stored locally and retried later.
7
+ /// The buffer enforces a configurable maximum size — oldest entries are dropped
8
+ /// when the limit is exceeded.
9
+ public class BGLLocationBuffer {
10
+
11
+ public static let dbName = "bgl_location_buffer.db"
12
+
13
+ private var db: OpaquePointer?
14
+ private var maxSize: Int = 1000
15
+ private let dbPath: String
16
+
17
+ /// Called when buffer overflow triggers auto-trim. Parameter is the number of dropped entries.
18
+ public var onOverflow: ((Int) -> Void)?
19
+
20
+ public init(dbPath: String? = nil, maxSize: Int = 1000) {
21
+ if let dbPath = dbPath {
22
+ self.dbPath = dbPath
23
+ } else {
24
+ let docsUrl = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
25
+ .first!
26
+ self.dbPath = docsUrl.appendingPathComponent(BGLLocationBuffer.dbName).path
27
+ }
28
+ self.maxSize = maxSize
29
+ openDatabase()
30
+ createTable()
31
+ }
32
+
33
+ deinit {
34
+ close()
35
+ }
36
+
37
+ public func close() {
38
+ if let db = db {
39
+ sqlite3_close(db)
40
+ self.db = nil
41
+ }
42
+ }
43
+
44
+ public func configure(maxSize: Int) {
45
+ self.maxSize = maxSize
46
+ }
47
+
48
+ // MARK: - Public API
49
+
50
+ /// Store a failed location in the buffer.
51
+ public func add(_ location: BGLLocationData) {
52
+ guard let db = db else { return }
53
+
54
+ let sql = """
55
+ INSERT INTO buffered_locations
56
+ (latitude, longitude, accuracy, speed, heading, altitude, timestamp, is_moving, is_mock)
57
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
58
+ """
59
+
60
+ var stmt: OpaquePointer?
61
+ guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return }
62
+ defer { sqlite3_finalize(stmt) }
63
+
64
+ sqlite3_bind_double(stmt, 1, location.latitude)
65
+ sqlite3_bind_double(stmt, 2, location.longitude)
66
+ sqlite3_bind_double(stmt, 3, location.accuracy)
67
+ sqlite3_bind_double(stmt, 4, location.speed)
68
+ sqlite3_bind_double(stmt, 5, location.heading)
69
+ sqlite3_bind_double(stmt, 6, location.altitude)
70
+ sqlite3_bind_int64(stmt, 7, Int64(location.timestamp))
71
+ sqlite3_bind_int(stmt, 8, location.isMoving ? 1 : 0)
72
+ sqlite3_bind_int(stmt, 9, location.isMock ? 1 : 0)
73
+
74
+ sqlite3_step(stmt)
75
+ trimBuffer()
76
+ }
77
+
78
+ /// Retrieve the oldest `limit` buffered locations (FIFO order).
79
+ /// Returns pairs of (rowId, BGLLocationData) for deletion after successful send.
80
+ public func peek(limit: Int = 50) -> [(Int64, BGLLocationData)] {
81
+ guard let db = db else { return [] }
82
+
83
+ let sql =
84
+ "SELECT id, latitude, longitude, accuracy, speed, heading, altitude, timestamp, is_moving, is_mock FROM buffered_locations ORDER BY id ASC LIMIT ?"
85
+
86
+ var stmt: OpaquePointer?
87
+ guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
88
+ defer { sqlite3_finalize(stmt) }
89
+
90
+ sqlite3_bind_int(stmt, 1, Int32(limit))
91
+
92
+ var results: [(Int64, BGLLocationData)] = []
93
+ while sqlite3_step(stmt) == SQLITE_ROW {
94
+ let id = sqlite3_column_int64(stmt, 0)
95
+ let data = BGLLocationData(
96
+ latitude: sqlite3_column_double(stmt, 1),
97
+ longitude: sqlite3_column_double(stmt, 2),
98
+ accuracy: sqlite3_column_double(stmt, 3),
99
+ speed: sqlite3_column_double(stmt, 4),
100
+ heading: sqlite3_column_double(stmt, 5),
101
+ altitude: sqlite3_column_double(stmt, 6),
102
+ timestamp: Double(sqlite3_column_int64(stmt, 7)),
103
+ isMoving: sqlite3_column_int(stmt, 8) == 1,
104
+ isMock: sqlite3_column_int(stmt, 9) == 1
105
+ )
106
+ results.append((id, data))
107
+ }
108
+ return results
109
+ }
110
+
111
+ /// Remove specific entries by their row IDs after successful send.
112
+ public func remove(ids: [Int64]) {
113
+ guard let db = db, !ids.isEmpty else { return }
114
+
115
+ let placeholders = ids.map { _ in "?" }.joined(separator: ",")
116
+ let sql = "DELETE FROM buffered_locations WHERE id IN (\(placeholders))"
117
+
118
+ var stmt: OpaquePointer?
119
+ guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return }
120
+ defer { sqlite3_finalize(stmt) }
121
+
122
+ for (index, id) in ids.enumerated() {
123
+ sqlite3_bind_int64(stmt, Int32(index + 1), id)
124
+ }
125
+ sqlite3_step(stmt)
126
+ }
127
+
128
+ /// Current number of buffered locations.
129
+ public func count() -> Int {
130
+ guard let db = db else { return 0 }
131
+
132
+ let sql = "SELECT COUNT(*) FROM buffered_locations"
133
+ var stmt: OpaquePointer?
134
+ guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return 0 }
135
+ defer { sqlite3_finalize(stmt) }
136
+
137
+ if sqlite3_step(stmt) == SQLITE_ROW {
138
+ return Int(sqlite3_column_int(stmt, 0))
139
+ }
140
+ return 0
141
+ }
142
+
143
+ /// Remove all buffered locations.
144
+ public func clear() {
145
+ guard let db = db else { return }
146
+ sqlite3_exec(db, "DELETE FROM buffered_locations", nil, nil, nil)
147
+ }
148
+
149
+ // MARK: - Private
150
+
151
+ private func openDatabase() {
152
+ let flags = SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX
153
+ if sqlite3_open_v2(dbPath, &db, flags, nil) != SQLITE_OK {
154
+ print("BGLocation: Failed to open BGLLocationBuffer database")
155
+ db = nil
156
+ }
157
+ }
158
+
159
+ private func createTable() {
160
+ guard let db = db else { return }
161
+
162
+ let sql = """
163
+ CREATE TABLE IF NOT EXISTS buffered_locations (
164
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
165
+ latitude REAL NOT NULL,
166
+ longitude REAL NOT NULL,
167
+ accuracy REAL NOT NULL,
168
+ speed REAL NOT NULL,
169
+ heading REAL NOT NULL,
170
+ altitude REAL NOT NULL,
171
+ timestamp INTEGER NOT NULL,
172
+ is_moving INTEGER NOT NULL,
173
+ is_mock INTEGER NOT NULL DEFAULT 0,
174
+ created_at INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000)
175
+ )
176
+ """
177
+ sqlite3_exec(db, sql, nil, nil, nil)
178
+ }
179
+
180
+ private func trimBuffer() {
181
+ let currentCount = count()
182
+ let overflow = currentCount - maxSize
183
+ if overflow <= 0 { return }
184
+
185
+ guard let db = db else { return }
186
+ let sql =
187
+ "DELETE FROM buffered_locations WHERE id IN (SELECT id FROM buffered_locations ORDER BY id ASC LIMIT ?)"
188
+ var stmt: OpaquePointer?
189
+ guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return }
190
+ defer { sqlite3_finalize(stmt) }
191
+
192
+ sqlite3_bind_int(stmt, 1, Int32(overflow))
193
+ sqlite3_step(stmt)
194
+ print(
195
+ "BGLocation: BGLLocationBuffer trimmed \(overflow) oldest entries (maxSize=\(maxSize))")
196
+ onOverflow?(overflow)
197
+ }
198
+ }
@@ -0,0 +1,11 @@
1
+ import Foundation
2
+
3
+ /// Build-time configuration injected by the consuming plugin bridge.
4
+ /// The bridge sets `buildEpoch` during plugin initialization.
5
+ /// Used for update gating: licenses with `exp < buildEpoch` degrade to trial mode.
6
+ public enum BGLBuildConfig {
7
+ /// Unix epoch seconds when the consuming plugin was built.
8
+ /// Set by the bridge during initialization (e.g. from Xcode build-phase script).
9
+ /// Default 0 means update gating is disabled.
10
+ public static var buildEpoch: Int = 0
11
+ }
@@ -0,0 +1,134 @@
1
+ import Foundation
2
+
3
+ /// Manages license validation, trial timer lifecycle, and cooldown enforcement.
4
+ /// Extracted from BackgroundLocationPlugin to reduce bridge complexity.
5
+ public class BGLLicenseEnforcer {
6
+
7
+ private let licenseDefaults: UserDefaults
8
+ public let licenseValidator: BGLLicenseValidator
9
+
10
+ public var licenseMode: LicenseMode = .trial
11
+ public var trialTimer: BGLTrialTimer?
12
+
13
+ public init(defaults: UserDefaults) {
14
+ self.licenseDefaults = defaults
15
+ self.licenseValidator = BGLLicenseValidator(defaults: defaults)
16
+ }
17
+
18
+ /// Validate license key and populate configure result.
19
+ /// - Parameters:
20
+ /// - licenseKey: the license key from plugin config
21
+ /// - isDebug: user's original debug setting (restored in full mode)
22
+ /// - debugSounds: user's original debugSounds setting
23
+ /// - debug: the debug logger to reconfigure
24
+ /// - configureResult: dictionary to populate with license status
25
+ /// - onTrialExpired: callback for when trial expires
26
+ public func validateLicense(
27
+ licenseKey: String?,
28
+ isDebug: Bool,
29
+ debugSounds: Bool,
30
+ debug: BGLDebugLogger,
31
+ configureResult: inout [String: Any],
32
+ onTrialExpired: @escaping () -> Void,
33
+ bundleId: String = Bundle.main.bundleIdentifier ?? ""
34
+ ) {
35
+ let licenseResult = licenseValidator.validate(
36
+ licenseKey: licenseKey, bundleId: bundleId, buildEpoch: BGLBuildConfig.buildEpoch)
37
+
38
+ switch licenseResult {
39
+ case .valid(let updatesUntil):
40
+ licenseMode = .full
41
+ trialTimer?.stop()
42
+ trialTimer = nil
43
+ debug.configure(debug: isDebug, debugSounds: debugSounds)
44
+ configureResult["licenseMode"] = "full"
45
+ if let updatesUntil = updatesUntil {
46
+ let updatesDate = ISO8601DateFormatter().string(
47
+ from: Date(timeIntervalSince1970: TimeInterval(updatesUntil)))
48
+ configureResult["licenseUpdatesUntil"] = updatesDate
49
+ print("BGLocation: License valid — full mode, updates until \(updatesDate)")
50
+ } else {
51
+ print("BGLocation: License valid — full mode, no update expiry")
52
+ }
53
+
54
+ case .updatesExpired(let updatesUntil):
55
+ licenseMode = .trial
56
+ debug.configure(debug: true, debugSounds: debugSounds)
57
+ let updatesDate = ISO8601DateFormatter().string(
58
+ from: Date(timeIntervalSince1970: TimeInterval(updatesUntil)))
59
+ let buildDate = ISO8601DateFormatter().string(
60
+ from: Date(timeIntervalSince1970: TimeInterval(BGLBuildConfig.buildEpoch)))
61
+ configureResult["licenseMode"] = "trial"
62
+ configureResult["licenseUpdateExpired"] = true
63
+ configureResult["licenseUpdatesUntil"] = updatesDate
64
+ configureResult["licenseError"] =
65
+ "Updates expired. This plugin version requires a license with updates valid until \(buildDate). Please renew at bglocation.dev/portal"
66
+
67
+ if trialTimer == nil {
68
+ trialTimer = BGLTrialTimer(defaults: licenseDefaults, onExpired: onTrialExpired)
69
+ }
70
+
71
+ print(
72
+ "BGLocation: License updates expired (until \(updatesDate)). Running in TRIAL mode."
73
+ )
74
+
75
+ case .invalid(let reason):
76
+ licenseMode = .trial
77
+ debug.configure(debug: true, debugSounds: debugSounds)
78
+ configureResult["licenseMode"] = "trial"
79
+ configureResult["licenseError"] = reason
80
+
81
+ if trialTimer == nil {
82
+ trialTimer = BGLTrialTimer(defaults: licenseDefaults, onExpired: onTrialExpired)
83
+ }
84
+
85
+ print(
86
+ "BGLocation: License: \(reason). Running in TRIAL mode (30 min limit, 1h cooldown)."
87
+ )
88
+ }
89
+ }
90
+
91
+ /// Result of a cooldown check.
92
+ public struct CooldownResult {
93
+ public let remainingSeconds: Int
94
+ public let message: String
95
+ }
96
+
97
+ /// Check if trial cooldown is active.
98
+ /// Returns CooldownResult if cooldown is active, or nil if the operation can proceed.
99
+ public func checkCooldown() -> CooldownResult? {
100
+ guard licenseMode == .trial,
101
+ let timer = trialTimer,
102
+ timer.isInCooldown()
103
+ else {
104
+ return nil
105
+ }
106
+ let remaining = timer.remainingCooldownSeconds()
107
+ return CooldownResult(
108
+ remainingSeconds: remaining,
109
+ message:
110
+ "Trial cooldown active. Please wait \(remaining) seconds or provide a license key."
111
+ )
112
+ }
113
+
114
+ /// Start trial timer if in trial mode.
115
+ public func startTrialIfNeeded() {
116
+ if licenseMode == .trial {
117
+ trialTimer?.start()
118
+ }
119
+ }
120
+
121
+ /// Stop trial timer if no geofences are active and tracking is stopped.
122
+ public func stopTrialIfIdle(hasGeofences: Bool, isTracking: Bool) {
123
+ if licenseMode == .trial && !hasGeofences && !isTracking {
124
+ trialTimer?.stop()
125
+ }
126
+ }
127
+
128
+ /// Stop trial timer if no geofences are active.
129
+ public func stopTrialIfNoGeofences(hasGeofences: Bool) {
130
+ if !hasGeofences {
131
+ trialTimer?.stop()
132
+ }
133
+ }
134
+ }