@bubblsdk/react-native-sdk 4.1.7 → 5.0.0-alpha.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 (176) hide show
  1. package/BubblReactNativeSdk.podspec +29 -9
  2. package/CHANGELOG.md +52 -1
  3. package/LICENSE +202 -1
  4. package/README.md +172 -83
  5. package/android/build.gradle +33 -30
  6. package/android/src/main/AndroidManifest.xml +1 -0
  7. package/android/src/main/java/tech/bubbl/reactnative/BubblJson.kt +135 -0
  8. package/android/src/main/java/tech/bubbl/reactnative/BubblSdkModule.kt +277 -0
  9. package/android/src/main/java/tech/bubbl/reactnative/BubblSdkPackage.kt +26 -0
  10. package/app.plugin.js +2 -0
  11. package/ios/BubblPush.swift +44 -0
  12. package/ios/BubblSdk.h +7 -0
  13. package/ios/BubblSdk.mm +252 -0
  14. package/ios/BubblSdkImpl.swift +335 -0
  15. package/ios-extension/BubblReactNativeNotificationService.podspec +44 -0
  16. package/ios-extension/Sources/BubblCore/API/ApiResponse.swift +90 -0
  17. package/ios-extension/Sources/BubblCore/API/CredentialStore.swift +116 -0
  18. package/ios-extension/Sources/BubblCore/API/DeviceApiClient.swift +239 -0
  19. package/ios-extension/Sources/BubblCore/API/ErrorActions.swift +68 -0
  20. package/ios-extension/Sources/BubblCore/API/Http.swift +39 -0
  21. package/ios-extension/Sources/BubblCore/API/ServerClock.swift +69 -0
  22. package/ios-extension/Sources/BubblCore/BubblVersion.swift +5 -0
  23. package/ios-extension/Sources/BubblCore/Device/ConfigSync.swift +216 -0
  24. package/ios-extension/Sources/BubblCore/Device/DeviceSync.swift +160 -0
  25. package/ios-extension/Sources/BubblCore/Device/ProvisioningProfile.swift +24 -0
  26. package/ios-extension/Sources/BubblCore/Engine/EngineCore.swift +523 -0
  27. package/ios-extension/Sources/BubblCore/Engine/Geofencing.swift +170 -0
  28. package/ios-extension/Sources/BubblCore/Engine/Permissions.swift +90 -0
  29. package/ios-extension/Sources/BubblCore/Engine/Privacy.swift +64 -0
  30. package/ios-extension/Sources/BubblCore/Engine/PrivacyControls.swift +184 -0
  31. package/ios-extension/Sources/BubblCore/Engine/Pushes.swift +61 -0
  32. package/ios-extension/Sources/BubblCore/Engine/Sandbox.swift +149 -0
  33. package/ios-extension/Sources/BubblCore/Events/EventQueue.swift +207 -0
  34. package/ios-extension/Sources/BubblCore/Events/EventStore.swift +159 -0
  35. package/ios-extension/Sources/BubblCore/Geofence/Geofence.swift +160 -0
  36. package/ios-extension/Sources/BubblCore/Geofence/GeofenceEngine.swift +488 -0
  37. package/ios-extension/Sources/BubblCore/Geofence/GeofenceState.swift +103 -0
  38. package/ios-extension/Sources/BubblCore/Geofence/RegionBudget.swift +62 -0
  39. package/ios-extension/Sources/BubblCore/Notification/BubblNotification.swift +137 -0
  40. package/ios-extension/Sources/BubblCore/Notification/NotificationEvents.swift +160 -0
  41. package/ios-extension/Sources/BubblCore/Notification/Push.swift +134 -0
  42. package/ios-extension/Sources/BubblCore/Notification/SurveyForm.swift +185 -0
  43. package/ios-extension/Sources/BubblCore/RequestSigner.swift +56 -0
  44. package/ios-extension/Sources/BubblCore/Support/AsyncMutex.swift +39 -0
  45. package/ios-extension/Sources/BubblCore/Support/BubblLog.swift +52 -0
  46. package/ios-extension/Sources/BubblCore/Support/JSON.swift +31 -0
  47. package/ios-extension/Sources/BubblCore/Support/JSONValue.swift +94 -0
  48. package/ios-extension/Sources/BubblCore/Support/Locking.swift +11 -0
  49. package/ios-extension/Sources/BubblCore/Support/ValueStore.swift +109 -0
  50. package/ios-extension/Sources/BubblNotificationService/BubblNotificationService.swift +124 -0
  51. package/ios-sdk/BubblCore/API/ApiResponse.swift +90 -0
  52. package/ios-sdk/BubblCore/API/CredentialStore.swift +116 -0
  53. package/ios-sdk/BubblCore/API/DeviceApiClient.swift +239 -0
  54. package/ios-sdk/BubblCore/API/ErrorActions.swift +68 -0
  55. package/ios-sdk/BubblCore/API/Http.swift +39 -0
  56. package/ios-sdk/BubblCore/API/ServerClock.swift +69 -0
  57. package/ios-sdk/BubblCore/BubblVersion.swift +5 -0
  58. package/ios-sdk/BubblCore/Device/ConfigSync.swift +216 -0
  59. package/ios-sdk/BubblCore/Device/DeviceSync.swift +160 -0
  60. package/ios-sdk/BubblCore/Device/ProvisioningProfile.swift +24 -0
  61. package/ios-sdk/BubblCore/Engine/EngineCore.swift +523 -0
  62. package/ios-sdk/BubblCore/Engine/Geofencing.swift +170 -0
  63. package/ios-sdk/BubblCore/Engine/Permissions.swift +90 -0
  64. package/ios-sdk/BubblCore/Engine/Privacy.swift +64 -0
  65. package/ios-sdk/BubblCore/Engine/PrivacyControls.swift +184 -0
  66. package/ios-sdk/BubblCore/Engine/Pushes.swift +61 -0
  67. package/ios-sdk/BubblCore/Engine/Sandbox.swift +149 -0
  68. package/ios-sdk/BubblCore/Events/EventQueue.swift +207 -0
  69. package/ios-sdk/BubblCore/Events/EventStore.swift +159 -0
  70. package/ios-sdk/BubblCore/Geofence/Geofence.swift +160 -0
  71. package/ios-sdk/BubblCore/Geofence/GeofenceEngine.swift +488 -0
  72. package/ios-sdk/BubblCore/Geofence/GeofenceState.swift +103 -0
  73. package/ios-sdk/BubblCore/Geofence/RegionBudget.swift +62 -0
  74. package/ios-sdk/BubblCore/Notification/BubblNotification.swift +137 -0
  75. package/ios-sdk/BubblCore/Notification/NotificationEvents.swift +160 -0
  76. package/ios-sdk/BubblCore/Notification/Push.swift +134 -0
  77. package/ios-sdk/BubblCore/Notification/SurveyForm.swift +185 -0
  78. package/ios-sdk/BubblCore/RequestSigner.swift +56 -0
  79. package/ios-sdk/BubblCore/Support/AsyncMutex.swift +39 -0
  80. package/ios-sdk/BubblCore/Support/BubblLog.swift +52 -0
  81. package/ios-sdk/BubblCore/Support/JSON.swift +31 -0
  82. package/ios-sdk/BubblCore/Support/JSONValue.swift +94 -0
  83. package/ios-sdk/BubblCore/Support/Locking.swift +11 -0
  84. package/ios-sdk/BubblCore/Support/ValueStore.swift +109 -0
  85. package/ios-sdk/BubblLaunch/BubblLaunch.m +45 -0
  86. package/ios-sdk/BubblLaunch/include/BubblLaunch.h +5 -0
  87. package/ios-sdk/BubblSDK/Bubbl.swift +308 -0
  88. package/ios-sdk/BubblSDK/BubblBackend.swift +118 -0
  89. package/ios-sdk/BubblSDK/BubblCredential.swift +30 -0
  90. package/ios-sdk/BubblSDK/BubblMessage.swift +151 -0
  91. package/ios-sdk/BubblSDK/BubblOptions.swift +39 -0
  92. package/ios-sdk/BubblSDK/BubblPermissions.swift +79 -0
  93. package/ios-sdk/BubblSDK/BubblTestDeviceResult.swift +10 -0
  94. package/ios-sdk/BubblSDK/Engine/BackgroundRefresh.swift +92 -0
  95. package/ios-sdk/BubblSDK/Engine/EngineHost+Geofences.swift +177 -0
  96. package/ios-sdk/BubblSDK/Engine/EngineHost+Privacy.swift +65 -0
  97. package/ios-sdk/BubblSDK/Engine/EngineHost+Push.swift +154 -0
  98. package/ios-sdk/BubblSDK/Engine/EngineHost.swift +356 -0
  99. package/ios-sdk/BubblSDK/Engine/SupportedBubbl.swift +320 -0
  100. package/ios-sdk/BubblSDK/Engine/SystemLog.swift +27 -0
  101. package/ios-sdk/BubblSDK/Engine/WorkRunner.swift +107 -0
  102. package/ios-sdk/BubblSDK/Notification/Look.swift +78 -0
  103. package/ios-sdk/BubblSDK/Notification/NotificationMedia.swift +329 -0
  104. package/ios-sdk/BubblSDK/Notification/NotificationModel.swift +172 -0
  105. package/ios-sdk/BubblSDK/Notification/NotificationScreen.swift +428 -0
  106. package/ios-sdk/BubblSDK/Notification/ScreenPresenter.swift +148 -0
  107. package/ios-sdk/BubblSDK/Permissions/PermissionFlow.swift +169 -0
  108. package/ios-sdk/BubblSDK/Permissions/PrivacyView.swift +110 -0
  109. package/ios-sdk/BubblSDK/Platform/AppleDevicePlatform.swift +95 -0
  110. package/ios-sdk/BubblSDK/Platform/KeychainCredentialStore.swift +114 -0
  111. package/ios-sdk/BubblSDK/Platform/LocationService.swift +418 -0
  112. package/ios-sdk/BubblSDK/Platform/PushIntegration.swift +288 -0
  113. package/ios-sdk/BubblSDK/Platform/URLSessionHttpClient.swift +69 -0
  114. package/ios-sdk/BubblSDK/PrivacyInfo.xcprivacy +76 -0
  115. package/jest/index.js +75 -0
  116. package/lib/commonjs/BubblModule.js +14 -0
  117. package/lib/commonjs/BubblModule.js.map +1 -0
  118. package/lib/commonjs/BubblModule.web.js +13 -0
  119. package/lib/commonjs/BubblModule.web.js.map +1 -0
  120. package/lib/commonjs/NativeBubblSdk.js +20 -0
  121. package/lib/commonjs/NativeBubblSdk.js.map +1 -0
  122. package/lib/commonjs/index.js +336 -0
  123. package/lib/commonjs/index.js.map +1 -0
  124. package/lib/commonjs/package.json +1 -0
  125. package/lib/commonjs/types.js +2 -0
  126. package/lib/commonjs/types.js.map +1 -0
  127. package/lib/module/BubblModule.js +5 -0
  128. package/lib/module/BubblModule.js.map +1 -0
  129. package/lib/module/BubblModule.web.js +9 -0
  130. package/lib/module/BubblModule.web.js.map +1 -0
  131. package/lib/module/NativeBubblSdk.js +18 -0
  132. package/lib/module/NativeBubblSdk.js.map +1 -0
  133. package/lib/module/index.js +331 -0
  134. package/lib/module/index.js.map +1 -0
  135. package/lib/module/package.json +1 -0
  136. package/lib/module/types.js +2 -0
  137. package/lib/module/types.js.map +1 -0
  138. package/lib/typescript/commonjs/package.json +1 -0
  139. package/lib/typescript/commonjs/src/BubblModule.d.ts +3 -0
  140. package/lib/typescript/commonjs/src/BubblModule.d.ts.map +1 -0
  141. package/lib/typescript/commonjs/src/BubblModule.web.d.ts +8 -0
  142. package/lib/typescript/commonjs/src/BubblModule.web.d.ts.map +1 -0
  143. package/lib/typescript/commonjs/src/NativeBubblSdk.d.ts +59 -0
  144. package/lib/typescript/commonjs/src/NativeBubblSdk.d.ts.map +1 -0
  145. package/lib/typescript/commonjs/src/index.d.ts +111 -0
  146. package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
  147. package/lib/typescript/commonjs/src/types.d.ts +192 -0
  148. package/lib/typescript/commonjs/src/types.d.ts.map +1 -0
  149. package/lib/typescript/module/package.json +1 -0
  150. package/lib/typescript/module/src/BubblModule.d.ts +3 -0
  151. package/lib/typescript/module/src/BubblModule.d.ts.map +1 -0
  152. package/lib/typescript/module/src/BubblModule.web.d.ts +8 -0
  153. package/lib/typescript/module/src/BubblModule.web.d.ts.map +1 -0
  154. package/lib/typescript/module/src/NativeBubblSdk.d.ts +59 -0
  155. package/lib/typescript/module/src/NativeBubblSdk.d.ts.map +1 -0
  156. package/lib/typescript/module/src/index.d.ts +111 -0
  157. package/lib/typescript/module/src/index.d.ts.map +1 -0
  158. package/lib/typescript/module/src/types.d.ts +192 -0
  159. package/lib/typescript/module/src/types.d.ts.map +1 -0
  160. package/package.json +105 -24
  161. package/plugin/build/imageNotifications.d.ts +35 -0
  162. package/plugin/build/imageNotifications.js +198 -0
  163. package/plugin/build/index.d.ts +84 -0
  164. package/plugin/build/index.js +176 -0
  165. package/src/BubblModule.ts +2 -0
  166. package/src/BubblModule.web.ts +9 -0
  167. package/src/NativeBubblSdk.ts +67 -0
  168. package/src/index.ts +338 -282
  169. package/src/types.ts +182 -0
  170. package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkModule.kt +0 -680
  171. package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkNotificationIntents.kt +0 -32
  172. package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkPackage.kt +0 -14
  173. package/ios/BubblSdk.swift +0 -802
  174. package/ios/BubblSdkBridge.m +0 -86
  175. package/react-native.config.js +0 -11
  176. package/src/specs/NativeBubblSdk.ts +0 -54
@@ -0,0 +1,523 @@
1
+ import Foundation
2
+
3
+ /// What the app started the engine with, kept so the engine can start itself when the OS wakes the
4
+ /// app (a geofence, a push) before the app's own code has run.
5
+ package struct EngineConfig: Codable, Sendable, Equatable {
6
+ /// Nil for a device started with a credential issued outside the app.
7
+ package let apiKey: String?
8
+ package let baseUrl: String
9
+
10
+ package init(apiKey: String?, baseUrl: String) {
11
+ self.apiKey = apiKey
12
+ self.baseUrl = baseUrl
13
+ }
14
+ }
15
+
16
+ /// A device credential issued outside the app (installs provisioned ahead of time, an app's own
17
+ /// pairing flow), which a device can be started with instead of an API key.
18
+ package struct IssuedCredential: Sendable, Equatable {
19
+ package let keyId: String
20
+ package let secret: String
21
+ /// The install the credential was issued for.
22
+ package let installId: String
23
+
24
+ package init(keyId: String, secret: String, installId: String) {
25
+ self.keyId = keyId
26
+ self.secret = secret
27
+ self.installId = installId
28
+ }
29
+ }
30
+
31
+ /// What starting Bubbl means for the device's identity (Android's EngineStart). A device is either
32
+ /// an install that registers itself with an API key, or one started with a credential issued
33
+ /// outside the app. Which one, and which credential, is kept (it outlives `stop`), so the next
34
+ /// start knows whether it's the same device.
35
+ package enum EngineStart {
36
+ package static let apiKey = "api_key"
37
+
38
+ /// With an API key, the key itself: a different key is another workspace (Sandbox and
39
+ /// Production have their own), where this device has to register as new. The address isn't
40
+ /// part of it: the same key at another address is the same workspace.
41
+ package static func identity(_ config: EngineConfig, _ credential: IssuedCredential?) -> String {
42
+ guard let credential else { return config.apiKey.map { "\(apiKey) \($0)" } ?? apiKey }
43
+ return "credential \(credential.keyId) \(credential.installId) \(config.baseUrl)"
44
+ }
45
+
46
+ /// Whether starting as `next` makes this a new device, so what's kept is wiped first (consent
47
+ /// stays): from an API key to a credential or back, from one credential to another, or from
48
+ /// one API key to another (an app update going live from Sandbox to Production, or back; the
49
+ /// old credential and install id belong to the other workspace). Not on the first start
50
+ /// (nothing to wipe), nor when it's the same again, nor from an API key an older SDK kept
51
+ /// without saying which (it can't tell).
52
+ package static func startsAfresh(previous: String?, next: String) -> Bool {
53
+ guard let previous, previous != next else { return false }
54
+ return !(previous == apiKey && next.hasPrefix("\(apiKey) "))
55
+ }
56
+ }
57
+
58
+ /// This install's push token, as PUT /device takes it.
59
+ package struct PushToken: Sendable, Equatable {
60
+ package let token: String
61
+ /// "apns" on iOS.
62
+ package let type: String
63
+ /// "production" or "sandbox" for APNs.
64
+ package let environment: String?
65
+
66
+ package init(token: String, type: String, environment: String?) {
67
+ self.token = token
68
+ self.type = type
69
+ self.environment = environment
70
+ }
71
+ }
72
+
73
+ /// What only the platform knows about the device.
74
+ package protocol DevicePlatform: Sendable {
75
+ /// The contract's DeviceAttributes the device knows by itself: platform, os_version,
76
+ /// device_model, app_id, app_version, locale, country, timezone.
77
+ func attributes() -> [String: JSONValue]
78
+ /// The permissions object PUT /device takes; nil until the platform can say.
79
+ func permissions() -> JSONValue?
80
+ func pushToken() -> PushToken?
81
+ }
82
+
83
+ /// Where each piece of the engine's state is kept: files on a device, memory in tests.
84
+ package struct EngineStores: Sendable {
85
+ package let credentials: any CredentialStore
86
+ package let events: any EventStore
87
+ package let geofenceState: any GeofenceStateStore
88
+ package let config: any ValueStore<ConfigSync.Saved>
89
+ package let deviceAcknowledged: any ValueStore<[String: JSONValue]>
90
+ package let segments: any ValueStore<Segments.Saved>
91
+ package let privacy: any ValueStore<PrivacyState>
92
+ package let recentNotifications: any ValueStore<[String]>
93
+ package let installId: any ValueStore<String>
94
+ package let clockOffset: any ValueStore<Int64>
95
+ /// Unix seconds (device clock) until which the server asked not to be called.
96
+ package let pausedUntil: any ValueStore<Int64>
97
+ /// Geofence transitions not yet sent, so one survives the app being suspended or killed.
98
+ package let transitions: any ValueStore<[Transition]>
99
+ /// Region events that came before the first unlock after a reboot, when nothing could be read.
100
+ package let lockedDrops: any ValueStore<Int>
101
+
102
+ package init(
103
+ credentials: any CredentialStore, events: any EventStore, geofenceState: any GeofenceStateStore,
104
+ config: any ValueStore<ConfigSync.Saved>, deviceAcknowledged: any ValueStore<[String: JSONValue]>,
105
+ segments: any ValueStore<Segments.Saved>, privacy: any ValueStore<PrivacyState>,
106
+ recentNotifications: any ValueStore<[String]>, installId: any ValueStore<String>,
107
+ clockOffset: any ValueStore<Int64>, pausedUntil: any ValueStore<Int64>,
108
+ transitions: any ValueStore<[Transition]>, lockedDrops: any ValueStore<Int>
109
+ ) {
110
+ self.credentials = credentials
111
+ self.events = events
112
+ self.geofenceState = geofenceState
113
+ self.config = config
114
+ self.deviceAcknowledged = deviceAcknowledged
115
+ self.segments = segments
116
+ self.privacy = privacy
117
+ self.recentNotifications = recentNotifications
118
+ self.installId = installId
119
+ self.clockOffset = clockOffset
120
+ self.pausedUntil = pausedUntil
121
+ self.transitions = transitions
122
+ self.lockedDrops = lockedDrops
123
+ }
124
+
125
+ /// Everything in memory: for tests.
126
+ package static func inMemory(credentials: any CredentialStore = InMemoryCredentialStore()) -> EngineStores {
127
+ EngineStores(
128
+ credentials: credentials, events: InMemoryEventStore(), geofenceState: InMemoryGeofenceStateStore(),
129
+ config: InMemoryValueStore(), deviceAcknowledged: InMemoryValueStore(), segments: InMemoryValueStore(),
130
+ privacy: InMemoryValueStore(), recentNotifications: InMemoryValueStore(), installId: InMemoryValueStore(),
131
+ clockOffset: InMemoryValueStore(), pausedUntil: InMemoryValueStore(),
132
+ transitions: InMemoryValueStore(), lockedDrops: InMemoryValueStore()
133
+ )
134
+ }
135
+
136
+ /// One file each in `directory`, written with `writeOptions` (on iOS, the file protection class).
137
+ /// The credential counts only alongside the install id it was registered with.
138
+ package static func files(in directory: URL, writeOptions: Data.WritingOptions, credentials: any CredentialStore) -> EngineStores {
139
+ func url(_ name: String) -> URL { directory.appendingPathComponent(name) }
140
+ let installId = FileValueStore<String>(url: url("install_id.json"), writeOptions: writeOptions)
141
+ return EngineStores(
142
+ credentials: InstallBoundCredentialStore(credentials, installId: installId),
143
+ events: FileEventStore(url: url("events.json"), writeOptions: writeOptions),
144
+ geofenceState: FileGeofenceStateStore(url: url("geofences.json"), writeOptions: writeOptions),
145
+ config: FileValueStore(url: url("config.json"), writeOptions: writeOptions),
146
+ deviceAcknowledged: FileValueStore(url: url("device.json"), writeOptions: writeOptions),
147
+ segments: FileValueStore(url: url("segments.json"), writeOptions: writeOptions),
148
+ privacy: FileValueStore(url: url("privacy.json"), writeOptions: writeOptions),
149
+ recentNotifications: FileValueStore(url: url("recent_notifications.json"), writeOptions: writeOptions),
150
+ installId: installId,
151
+ clockOffset: FileValueStore(url: url("clock_offset.json"), writeOptions: writeOptions),
152
+ pausedUntil: FileValueStore(url: url("paused_until.json"), writeOptions: writeOptions),
153
+ transitions: FileValueStore(url: url("transitions.json"), writeOptions: writeOptions),
154
+ lockedDrops: FileValueStore(url: url("locked_drops.json"), writeOptions: writeOptions)
155
+ )
156
+ }
157
+ }
158
+
159
+ /// What a piece of background work asks of whoever runs it.
160
+ package enum WorkOutcome: Sendable, Equatable {
161
+ case done
162
+ /// Not done yet: run it again after at least this long.
163
+ case retry(afterSeconds: Int)
164
+ }
165
+
166
+ /// The engine, free of iOS: its parts wired together from the saved EngineConfig, what it may do
167
+ /// now (consent, a server pause, the minimum SDK version), and each piece of work it runs, as
168
+ /// Android's EngineGraph and EngineWork. The iOS layer supplies the platform (Keychain, URLSession,
169
+ /// files, device details, region monitoring) and runs the work: now, on retry, and in the
170
+ /// background.
171
+ package final class EngineCore: Sendable {
172
+ package static let maxProperties = 50
173
+
174
+ /// A custom event's name: 1–100 of A–Z a–z 0–9 _ . : -.
175
+ package static func isEventName(_ name: String) -> Bool {
176
+ (1...100).contains(name.unicodeScalars.count) && name.unicodeScalars.allSatisfy {
177
+ $0.isASCII && (CharacterSet.alphanumerics.contains($0) || "_.:-".unicodeScalars.contains($0))
178
+ }
179
+ }
180
+
181
+ package let config: EngineConfig
182
+ package let clock: ServerClock
183
+ package let api: DeviceApiClient
184
+ package let configSync: ConfigSync
185
+ package let deviceSync: DeviceSync
186
+ package let segments: Segments
187
+ package let events: EventQueue
188
+ package let geofences: GeofenceEngine
189
+ package let notifications: NotificationSource
190
+ package let recentNotifications: RecentNotifications
191
+ package let privacy: PrivacyStore
192
+ let stores: EngineStores
193
+ private let platform: any DevicePlatform
194
+ let deviceNowSeconds: @Sendable () -> Int64
195
+ let hooks: Hooks
196
+ /// Guards the pending transitions' load-change-save.
197
+ let transitionsLock = NSLock()
198
+ /// One sending of the transitions at a time (a region event's and a retry's), so none is sent twice.
199
+ let sendingTransitions = AsyncMutex()
200
+ /// Region events dropped before the first unlock, not yet added to the saved count.
201
+ let unsavedLockedDrops = Counter()
202
+ /// DELETE /device attempts refused for good, this process (deleteMyData gives up after a few).
203
+ let eraseAttempts = AttemptCounter()
204
+ /// What the log last said about this device in Sandbox (announceSandbox).
205
+ let sandboxAnnounced = LastSaid()
206
+
207
+ final class Counter: @unchecked Sendable {
208
+ private let lock = NSLock()
209
+ private var count = 0
210
+ func add(_ n: Int) { lock.sync { count += n } }
211
+ /// The count, and whether `save` kept it (then it starts again from 0).
212
+ func drain(_ save: (Int) throws -> Void) rethrows {
213
+ try lock.sync {
214
+ guard count > 0 else { return }
215
+ try save(count)
216
+ count = 0
217
+ }
218
+ }
219
+ var value: Int { lock.sync { count } }
220
+ }
221
+
222
+ /// What the last POST /installs sent about the device, to count as acknowledged once it succeeds.
223
+ final class Hooks: @unchecked Sendable {
224
+ private let lock = NSLock()
225
+ private weak var _core: EngineCore?
226
+ private var _installAttributes: [String: JSONValue]?
227
+ private var _credentialRejected: (@Sendable () -> Void)?
228
+ var credentialRejected: (@Sendable () -> Void)? {
229
+ get { lock.sync { _credentialRejected } }
230
+ set { lock.sync { _credentialRejected = newValue } }
231
+ }
232
+ var core: EngineCore? {
233
+ get { lock.sync { _core } }
234
+ set { lock.sync { _core = newValue } }
235
+ }
236
+ var installAttributes: [String: JSONValue]? {
237
+ get { lock.sync { _installAttributes } }
238
+ set { lock.sync { _installAttributes = newValue } }
239
+ }
240
+ }
241
+
242
+ package init(
243
+ config: EngineConfig,
244
+ stores: EngineStores,
245
+ platform: any DevicePlatform,
246
+ http: any HttpClient,
247
+ monitor: any RegionMonitor,
248
+ deviceNowSeconds: @escaping @Sendable () -> Int64 = { Int64(Date().timeIntervalSince1970) }
249
+ ) {
250
+ self.config = config
251
+ self.stores = stores
252
+ self.platform = platform
253
+ self.deviceNowSeconds = deviceNowSeconds
254
+
255
+ let offsetStore = stores.clockOffset
256
+ let clock = ServerClock(
257
+ initialOffsetSeconds: (try? offsetStore.load()) ?? 0,
258
+ deviceNowSeconds: deviceNowSeconds,
259
+ onChange: { try? offsetStore.save($0) }
260
+ )
261
+
262
+ let hooks = Hooks()
263
+ let api = DeviceApiClient(
264
+ baseUrl: config.baseUrl,
265
+ apiKey: config.apiKey,
266
+ sdkVersion: BubblVersion.sdk,
267
+ http: http,
268
+ credentials: stores.credentials,
269
+ clock: clock,
270
+ installBody: {
271
+ guard let core = hooks.core else { throw DeviceApiError.credentialsLocked }
272
+ return try core.installBody()
273
+ },
274
+ onRegistered: { data in hooks.core?.registered(data) },
275
+ onCredentialRejected: { hooks.credentialRejected?() }
276
+ )
277
+ let configSync = ConfigSync(api: api, clock: clock, store: stores.config)
278
+
279
+ self.clock = clock
280
+ self.api = api
281
+ self.configSync = configSync
282
+ self.hooks = hooks
283
+ deviceSync = DeviceSync(api: api, acknowledged: stores.deviceAcknowledged)
284
+ segments = Segments(store: stores.segments)
285
+ events = EventQueue(store: stores.events, api: api, clock: clock, batchSize: { configSync.current?.maxEventsPerRequest ?? EventQueue.batchSize })
286
+ geofences = GeofenceEngine(api: api, clock: clock, store: stores.geofenceState, monitor: monitor)
287
+ notifications = NotificationSource(api: api)
288
+ recentNotifications = RecentNotifications(store: stores.recentNotifications)
289
+ privacy = PrivacyStore(store: stores.privacy)
290
+ hooks.core = self
291
+ }
292
+
293
+ // MARK: - What the engine may do
294
+
295
+ /// The server asked for a pause (workspace_paused, own_app_not_in_plan) that hasn't run out.
296
+ package var isPaused: Bool { deviceNowSeconds() < pausedUntil }
297
+
298
+ package var pausedUntilSeconds: Int64? { isPaused ? pausedUntil : nil }
299
+
300
+ private var pausedUntil: Int64 {
301
+ let saved: Int64?? = try? stores.pausedUntil.load()
302
+ return (saved ?? nil) ?? 0
303
+ }
304
+
305
+ /// This SDK is at least the workspace's minimum version (GET /config); an older one stops.
306
+ package var sdkSupported: Bool { configSync.current?.supports(BubblVersion.sdk) != false }
307
+
308
+ /// The engine may call the API and show notifications: consent allows it, not paused, supported.
309
+ package var isActive: Bool { privacy.state?.active == true && !isPaused && sdkSupported }
310
+
311
+ /// …and may use location as well.
312
+ package var locationActive: Bool { isActive && privacy.state?.locationActive == true }
313
+
314
+ /// Whether this install has ever registered (so there may be a device on the server).
315
+ package var everRegistered: Bool {
316
+ if case .present = stores.credentials.read() { return true }
317
+ return deviceSync.known
318
+ }
319
+
320
+ package func pause(hours: Int, code: String?) {
321
+ try? stores.pausedUntil.save(deviceNowSeconds() + Int64(hours) * 3_600)
322
+ BubblLog.warning("Bubbl paused for \(hours)h (\(code ?? "no code"))")
323
+ }
324
+
325
+ // MARK: - The device
326
+
327
+ /// This installation's id, made on first use and kept until the user erases their data (then
328
+ /// the next one is a new device). Throws while it can't be read.
329
+ package func installId() throws -> String {
330
+ if let id = try stores.installId.load(), !id.isEmpty { return id }
331
+ let id = UUID().uuidString.lowercased()
332
+ try stores.installId.save(id)
333
+ return id
334
+ }
335
+
336
+ /// What the device says about itself now, for POST /installs and PUT /device.
337
+ package func deviceAttributes() -> [String: JSONValue] {
338
+ var attributes = platform.attributes()
339
+ attributes["sdk_version"] = .string(BubblVersion.sdk)
340
+ if let token = platform.pushToken() {
341
+ attributes["push_token"] = .string(token.token)
342
+ attributes["push_token_type"] = .string(token.type)
343
+ if let environment = token.environment { attributes["apns_environment"] = .string(environment) }
344
+ }
345
+ if let permissions = platform.permissions() { attributes["permissions"] = permissions }
346
+ if let consent = privacy.state?.consent { attributes["consent"] = .bool(consent) }
347
+ return attributes
348
+ }
349
+
350
+ private func installBody() throws -> [String: Any] {
351
+ let id = try installId()
352
+ let attributes = deviceAttributes()
353
+ hooks.installAttributes = attributes
354
+ var body = JSON.any(.object(attributes)) as? [String: Any] ?? [:]
355
+ body["install_id"] = id
356
+ if let segments = segments.current { body["segments"] = segments }
357
+ return body
358
+ }
359
+
360
+ private func registered(_ data: [String: Any]) {
361
+ if let attributes = hooks.installAttributes { deviceSync.registered(attributes) }
362
+ if let current = segments.current { segments.sent(current) }
363
+ if let config = data["config"].flatMap(JSON.value) { try? configSync.registered(config) }
364
+ announceSandbox()
365
+ }
366
+
367
+ // MARK: - Work
368
+
369
+ /// PUT /device with whatever changed (`force`: everything).
370
+ package func syncDevice(force: Bool = false) async -> WorkOutcome {
371
+ guard isActive else { return .done }
372
+ if case .failed(let failure) = await deviceSync.sync(deviceAttributes(), force: force) {
373
+ return await handle(failure, "Syncing the device")
374
+ }
375
+ return .done
376
+ }
377
+
378
+ /// Send the event queue.
379
+ package func flushEvents() async -> WorkOutcome {
380
+ guard isActive else { return .done }
381
+ if case .failed(let failure) = await events.flush() {
382
+ return await handle(failure, "Sending events")
383
+ }
384
+ return .done
385
+ }
386
+
387
+ /// PUT /device/segments, if the app changed them since.
388
+ package func pushSegments() async -> WorkOutcome {
389
+ guard isActive else { return .done }
390
+ if case .failed(let failure) = await segments.push(api) {
391
+ return await handle(failure, "Setting segments")
392
+ }
393
+ return .done
394
+ }
395
+
396
+ /// Every hour or so, and in the background: the config, then the device, events and segments.
397
+ /// An SDK below the workspace's minimum does nothing but ask for the config, so it starts again
398
+ /// when the minimum is lowered (without an app update).
399
+ /// How fresh the config must be when the app comes to the front (at most one GET /config a
400
+ /// minute, a 304 when unchanged): a change made in the dashboard, such as approving a Sandbox
401
+ /// test device, is seen on the next open rather than at the hourly refresh.
402
+ package static let openConfigMaxAgeSeconds: Int64 = 60
403
+
404
+ package func maintenance(configMaxAgeSeconds: Int64? = nil) async -> WorkOutcome {
405
+ guard privacy.state?.active == true, !isPaused else { return .done }
406
+ let wasSupported = sdkSupported
407
+ let refreshed = await configSync.refresh(maxAgeSeconds: configMaxAgeSeconds)
408
+ if case .failed(let failure) = refreshed {
409
+ _ = await handle(failure, "Fetching the config")
410
+ }
411
+ announceSandbox()
412
+ guard sdkSupported else {
413
+ if wasSupported { BubblLog.error("This SDK (\(BubblVersion.sdk)) is older than the workspace's minimum: update it to use Bubbl") }
414
+ return .done
415
+ }
416
+ // Only on the server's answer just now: a saved config that still says "dropped" after the
417
+ // token went again would otherwise send it at every maintenance.
418
+ if refreshed == .updated || refreshed == .unchanged { await resendDroppedPushToken() }
419
+ _ = await syncDevice()
420
+ _ = await flushEvents()
421
+ _ = await pushSegments()
422
+ return .done
423
+ }
424
+
425
+ /// The push fields PUT /device sends, forgotten together when the server dropped the token.
426
+ package static let pushTokenFields: Set<String> = ["push_token", "push_token_type", "apns_environment"]
427
+
428
+ /// GET /config says the server has no push token for this device while the app holds one: it
429
+ /// was dropped (Apple or FCM called it dead, or it came with the wrong APNs environment), so the
430
+ /// next PUT /device sends it again.
431
+ func resendDroppedPushToken() async {
432
+ guard configSync.current?.pushTokenRegistered == false, platform.pushToken() != nil else { return }
433
+ BubblLog.info("The server has no push token for this device: sending it again")
434
+ await deviceSync.forget(Self.pushTokenFields)
435
+ }
436
+
437
+ /// The app came to the front: app.opened, and (with `pull`) the pushes the OS may have
438
+ /// dropped. Returns those notifications, for the app to show. Pulling claims them on the
439
+ /// server, so only a caller that will show them pulls.
440
+ package func appOpened(pull: Bool = true) async -> [JSONValue] {
441
+ guard isActive else { return [] }
442
+ let platform = platform.attributes()
443
+ var data: [String: JSONValue] = ["sdk_version": .string(BubblVersion.sdk)]
444
+ data["app_version"] = platform["app_version"] ?? .null
445
+ data["os_version"] = platform["os_version"] ?? .null
446
+ _ = try? await events.enqueue("app.opened", data: data)
447
+
448
+ guard pull, case .found(let pulled) = await notifications.pull() else { return [] }
449
+ // Each recorded as received and returned to show only the first time it arrives (a push
450
+ // may have brought it already).
451
+ var toShow: [JSONValue] = []
452
+ for notification in pulled {
453
+ if case .show(let json, _) = await notificationArrived(notification, opened: false) { toShow.append(json) }
454
+ }
455
+ return toShow
456
+ }
457
+
458
+ /// An event of the app's own (Bubbl.track): `name` of letters, digits and . _ : - (at most
459
+ /// 100), up to 50 flat properties. False, and a warning, when it can't be recorded.
460
+ package func track(_ name: String, properties: [String: JSONValue]) async -> Bool {
461
+ guard isActive else { return false }
462
+ guard Self.isEventName(name) else {
463
+ BubblLog.warning("Bubbl.track: that isn't a valid event name (letters, digits and . _ : -, at most 100)")
464
+ return false
465
+ }
466
+ var flat: [String: JSONValue] = [:]
467
+ for (key, value) in properties.sorted(by: { $0.key < $1.key }).prefix(Self.maxProperties) {
468
+ switch value {
469
+ case .array, .object: BubblLog.warning("Bubbl.track: a property that isn't text, a number or true/false was left out")
470
+ default: flat[key] = value
471
+ }
472
+ }
473
+ do {
474
+ try await events.enqueue("custom", data: ["name": .string(name), "properties": .object(flat)])
475
+ return true
476
+ } catch {
477
+ return false
478
+ }
479
+ }
480
+
481
+ /// What a failure means for the work that met it (Android's EngineWork.result).
482
+ package func handle(_ failure: ApiFailure, _ work: String) async -> WorkOutcome {
483
+ switch failure {
484
+ case .backoff:
485
+ return .retry(afterSeconds: 30)
486
+ case .retryAfter(let seconds):
487
+ return .retry(afterSeconds: seconds)
488
+ case .describeDevice:
489
+ // The server doesn't know the device: tell it everything, then this work tries again.
490
+ deviceSync.forget()
491
+ _ = await syncDevice(force: true)
492
+ return .retry(afterSeconds: 30)
493
+ case .pause(let hours, let code):
494
+ // Nothing is shown in a paused workspace, so there's nothing to retry for.
495
+ if code == "sandbox_pending_full" {
496
+ BubblLog.warning("Bubbl sandbox: the workspace has as many devices waiting for approval as it can take; approve or remove some under Test devices in the dashboard")
497
+ }
498
+ pause(hours: hours, code: code)
499
+ return .done
500
+ case .drop:
501
+ return .done
502
+ case .stopped(let status, let code):
503
+ BubblLog.error("\(work) stopped: HTTP \(status) \(code ?? "")")
504
+ return .done
505
+ }
506
+ }
507
+ }
508
+
509
+ extension JSON {
510
+ /// A JSONValue as JSONSerialization's objects (for APIs that take [String: Any]).
511
+ package static func any(_ value: JSONValue) -> Any? {
512
+ guard let data = try? JSONEncoder().encode(value) else { return nil }
513
+ return try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
514
+ }
515
+
516
+ /// JSONSerialization's objects as a JSONValue, read by JSONDecoder so it's the same everywhere.
517
+ package static func value(_ any: Any) -> JSONValue? {
518
+ guard JSONSerialization.isValidJSONObject(any) || any is String || any is NSNumber,
519
+ let data = try? JSONSerialization.data(withJSONObject: any, options: [.fragmentsAllowed])
520
+ else { return nil }
521
+ return try? JSONDecoder().decode(JSONValue.self, from: data)
522
+ }
523
+ }