@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,356 @@
1
+ import Foundation
2
+ #if canImport(UIKit)
3
+ import UIKit
4
+ #endif
5
+ #if !COCOAPODS
6
+ import BubblCore
7
+ import BubblLaunch
8
+ #endif
9
+
10
+ /// Found by name by BubblLaunch's +load (which can't import this module): the end of launch.
11
+ @available(iOS 17, *)
12
+ @objc(BubblLaunchHook)
13
+ final class BubblLaunchHook: NSObject {
14
+ @objc static func applicationDidFinishLaunching() {
15
+ EngineHost.shared.applicationDidFinishLaunching()
16
+ }
17
+
18
+ /// From +load, before main(): iOS wants background task handlers registered before launch ends.
19
+ @objc static func registerBackgroundTasks() {
20
+ #if os(iOS)
21
+ BackgroundRefresh.register()
22
+ #endif
23
+ }
24
+ }
25
+
26
+ /// Holds the engine for the life of the process and runs its work: on start, when the app comes to
27
+ /// the front, and when it goes to the back (with the few seconds iOS gives). Work waits while the
28
+ /// engine's data can't be read (before the first unlock after a reboot).
29
+ @available(iOS 17, *)
30
+ final class EngineHost: @unchecked Sendable {
31
+ static let shared = EngineHost()
32
+
33
+ let runner = WorkRunner(dataReadable: { EngineHost.dataReadable() })
34
+ let platform = AppleDevicePlatform()
35
+ /// Shows notifications: Bubbl's screen in front, a system notification in the background.
36
+ #if os(iOS)
37
+ let presenter: any NotificationPresenting = ScreenPresenter()
38
+ #else
39
+ let presenter: any NotificationPresenting = SystemAlertPresenter()
40
+ #endif
41
+ private let lock = NSLock()
42
+ private var core: EngineCore?
43
+ private var observing = false
44
+ private var listener: (@MainActor @Sendable (BubblMessage) -> Bool)?
45
+
46
+ var current: EngineCore? { lock.sync { core } }
47
+
48
+ /// The app's say over each notification before Bubbl shows it (Bubbl.setNotificationListener).
49
+ var notificationListener: (@MainActor @Sendable (BubblMessage) -> Bool)? {
50
+ get { lock.sync { listener } }
51
+ set { lock.sync { listener = newValue } }
52
+ }
53
+
54
+ /// What `start` was called with, kept so Bubbl can start itself at a later launch.
55
+ struct Started: Codable, Equatable {
56
+ let config: EngineConfig
57
+ let logLevel: BubblOptions.LogLevel
58
+ }
59
+
60
+ /// Starts (or keeps) the engine for `config`, with an API key or a credential issued outside the
61
+ /// app (Android's Bubbl.begin): the same again changes nothing; a different identity (another
62
+ /// credential, or switching between an API key and a credential) wipes what's kept here first
63
+ /// and starts as a new device.
64
+ func start(_ config: EngineConfig, credential: IssuedCredential? = nil, options: BubblOptions) -> EngineCore? {
65
+ SystemLog.use(options.logLevel)
66
+ let identity = EngineStart.identity(config, credential)
67
+ let afresh = EngineStart.startsAfresh(previous: previousIdentity(), next: identity)
68
+ guard let core = engine(for: config, fresh: afresh) else { return nil }
69
+ try? identityStore()?.save(identity)
70
+ try? rejectedStore()?.delete()
71
+ try? startedStore()?.save(Started(config: config, logLevel: options.logLevel))
72
+
73
+ guard afresh || credential != nil else {
74
+ proceed(core, options)
75
+ return core
76
+ }
77
+ // A new identity (wiped first) or a credential to keep, before anything of the engine's runs.
78
+ Task {
79
+ if afresh {
80
+ BubblLog.info("Bubbl starts afresh as a new device")
81
+ await runner.cancelAll()
82
+ do { try await core.wipeLocalData() } catch { BubblLog.warning("What an earlier device kept here couldn't all be removed yet") }
83
+ }
84
+ if let credential {
85
+ do { try core.useCredential(credential) } catch { BubblLog.warning("The credential can't be kept until the device is unlocked") }
86
+ }
87
+ proceed(core, options)
88
+ }
89
+ return core
90
+ }
91
+
92
+ /// What every start does once the engine is set up: the app's options, then whatever may run.
93
+ private func proceed(_ core: EngineCore, _ options: BubblOptions) {
94
+ do {
95
+ try core.privacy.update { $0.requireConsent = options.requireConsent }
96
+ } catch {
97
+ BubblLog.info("Bubbl's settings can't be read until the device is unlocked: it starts then")
98
+ }
99
+ if let segments = options.segments { _ = try? core.segments.set(segments) }
100
+ run(core, launchFinished: false)
101
+ }
102
+
103
+ /// What the device was started as before; an install from before identities were kept, started
104
+ /// with an API key, counts as one.
105
+ /// An identity kept before it named the key ("api_key" alone) is read with the key the last
106
+ /// start saved, so a key change is seen from the first update too.
107
+ private func previousIdentity() -> String? {
108
+ let started = (try? startedStore()?.load()) ?? nil
109
+ let lastKey = started?.config.apiKey.map { EngineStart.identity(EngineConfig(apiKey: $0, baseUrl: ""), nil) }
110
+ if let kept = (try? identityStore()?.load()) ?? nil { return kept == EngineStart.apiKey ? lastKey ?? kept : kept }
111
+ return lastKey
112
+ }
113
+
114
+ /// The server refused the credential Bubbl was started with: stopped, and staying stopped (no
115
+ /// self-restart) until the app starts Bubbl with a new one. Nothing is wiped: that start decides.
116
+ /// The app is told (BubblEvent.credentialRejected) and diagnostics say so.
117
+ func credentialRejected(_ core: EngineCore) {
118
+ guard current === core else { return }
119
+ try? rejectedStore()?.save(true)
120
+ BubblLog.error("The device's credential was refused: Bubbl has stopped until it's started with a new one")
121
+ stop()
122
+ BubblEvents.shared.emit(.credentialRejected)
123
+ }
124
+
125
+ /// Started with a credential the server has refused.
126
+ var isCredentialRejected: Bool { ((try? rejectedStore()?.load()) ?? nil) == true }
127
+
128
+ /// The end of every launch (BubblLaunch's +load watches for it): when an earlier launch started
129
+ /// Bubbl and it wasn't stopped since, it starts again now, as it was, so an app launched for a
130
+ /// geofence, a push or a tap has it before the app's own code (a wrapper's JavaScript or Dart)
131
+ /// runs. The app's own `start` later changes nothing, or applies its new options.
132
+ func applicationDidFinishLaunching() {
133
+ guard current == nil else { return }
134
+ // Before the first unlock after a reboot it can't be read: nothing starts (such events are lost).
135
+ guard let saved = (try? startedStore()?.load()) ?? nil else { return }
136
+ SystemLog.use(saved.logLevel)
137
+ guard let core = engine(for: saved.config) else { return }
138
+ BubblLog.info("Bubbl started itself for this launch, as the app last started it")
139
+ run(core, launchFinished: true)
140
+ }
141
+
142
+ /// The engine for `config`: the running one if it's for the same (and not `fresh`), a new one
143
+ /// otherwise.
144
+ private func engine(for config: EngineConfig, fresh: Bool = false) -> EngineCore? {
145
+ BubblLaunchLinked()
146
+ guard let directory = Self.directory() else {
147
+ BubblLog.error("Bubbl couldn't create its folder in Application Support")
148
+ return nil
149
+ }
150
+ return lock.sync {
151
+ if !fresh, let existing = self.core, existing.config == config { return existing }
152
+ let stores = EngineStores.files(in: directory, writeOptions: Self.writeOptions, credentials: KeychainCredentialStore())
153
+ let created = EngineCore(config: config, stores: stores, platform: platform, http: URLSessionHttpClient(), monitor: Self.regionMonitor())
154
+ created.onCredentialRejected { [weak created] in
155
+ guard let created else { return }
156
+ EngineHost.shared.credentialRejected(created)
157
+ }
158
+ self.core = created
159
+ return created
160
+ }
161
+ }
162
+
163
+ /// Location, push and the app's comings and goings, then whatever work the engine may do.
164
+ private func run(_ core: EngineCore, launchFinished: Bool) {
165
+ startLocation()
166
+ #if os(iOS)
167
+ PushIntegration.installWhenLaunched(launchFinished: launchFinished)
168
+ refreshPermissions()
169
+ #endif
170
+ observeTheApp()
171
+ resume(core)
172
+ }
173
+
174
+ private func startedStore() -> FileValueStore<Started>? {
175
+ Self.directory().map { FileValueStore(url: $0.appendingPathComponent("started.json"), writeOptions: Self.writeOptions) }
176
+ }
177
+
178
+ /// What the device was last started as (EngineStart.identity); outlives `stop`.
179
+ private func identityStore() -> FileValueStore<String>? {
180
+ Self.directory().map { FileValueStore(url: $0.appendingPathComponent("identity.json"), writeOptions: Self.writeOptions) }
181
+ }
182
+
183
+ private func rejectedStore() -> FileValueStore<Bool>? {
184
+ Self.directory().map { FileValueStore(url: $0.appendingPathComponent("credential_rejected.json"), writeOptions: Self.writeOptions) }
185
+ }
186
+
187
+ /// Stop until start is called again: nothing more runs, no geofences are watched, and Bubbl
188
+ /// doesn't start itself at the next launch (what it was started as is kept, so the next start
189
+ /// still knows whether it's the same device); nothing is dropped or told to the server.
190
+ func stop() {
191
+ try? startedStore()?.delete()
192
+ let stopped: EngineCore? = lock.sync {
193
+ defer { core = nil }
194
+ return core
195
+ }
196
+ Task {
197
+ await runner.cancelAll()
198
+ try? await stopped?.geofences.stop()
199
+ }
200
+ }
201
+
202
+ /// Whatever the engine may do now: the device, the queue, segments, the config.
203
+ func resume(_ core: EngineCore) {
204
+ if !core.sdkSupported {
205
+ BubblLog.error("This SDK (\(BubblVersion.sdk)) is older than the workspace's minimum: update it to use Bubbl")
206
+ } else if !core.isActive {
207
+ BubblLog.info(core.privacy.state?.active == true ? "Bubbl is paused by the server" : "Bubbl is waiting for consent")
208
+ }
209
+ resumePrivacyWork(core)
210
+ // Maintenance runs whatever the state: it's how an SDK below the minimum comes back.
211
+ submit("maintenance") { await $0.maintenance(configMaxAgeSeconds: EngineCore.openConfigMaxAgeSeconds) }
212
+ guard core.isActive else { return }
213
+ submit("device") { await $0.syncDevice() }
214
+ submit("events") { await $0.flushEvents() }
215
+ submit("segments") { await $0.pushSegments() }
216
+ if core.locationActive { checkGeofences() }
217
+ }
218
+
219
+ /// Runs `work` against whichever engine is current when it runs (none after `stop`).
220
+ func submit(_ name: String, _ work: @escaping @Sendable (EngineCore) async -> WorkOutcome) {
221
+ Task {
222
+ await runner.submit(name) {
223
+ guard let core = self.current else { return .done }
224
+ return await work(core)
225
+ }
226
+ }
227
+ }
228
+
229
+ // MARK: - The app
230
+
231
+ private func observeTheApp() {
232
+ let first: Bool = lock.sync {
233
+ defer { observing = true }
234
+ return !observing
235
+ }
236
+ guard first else { return }
237
+
238
+ #if canImport(UIKit) && !os(watchOS)
239
+ let center = NotificationCenter.default
240
+ // Every unlock, the first after a reboot included: held work may be able to run now. (Not
241
+ // protectedDataWillBecomeUnavailable, nor isProtectedDataAvailable: those follow the
242
+ // Complete class, which closes at every lock, while the engine's data stays readable.)
243
+ center.addObserver(forName: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil, queue: nil) { [weak self] _ in
244
+ guard let self else { return }
245
+ Task { await self.runner.recheck() }
246
+ }
247
+ center.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: nil) { [weak self] _ in
248
+ self?.appOpened()
249
+ }
250
+ center.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { [weak self] _ in
251
+ MainActor.assumeIsolated { self?.appWentToTheBack() }
252
+ }
253
+ Task { @MainActor [weak self] in
254
+ // Launched into the front (not woken in the background): that's an app open.
255
+ if UIApplication.shared.applicationState != .background { self?.appOpened() }
256
+ }
257
+ #endif
258
+ }
259
+
260
+ /// Where the device stands on permissions, for PUT /device and `permissions.status()` (the
261
+ /// person may have changed them in Settings while the app was away).
262
+ func refreshPermissions() {
263
+ #if os(iOS)
264
+ Task { @MainActor in _ = await PermissionFlow.shared.status() }
265
+ #endif
266
+ }
267
+
268
+ /// The app came to the front: app.opened, the queue, and the config if it's due.
269
+ private func appOpened() {
270
+ refreshPermissions()
271
+ #if os(iOS)
272
+ // Notifications may have been allowed since (by the app, a plugin, Settings): the device token
273
+ // then, not only at the next launch. (With the integration off, the app hands it over.)
274
+ PushIntegration.registerIfAllowed()
275
+ #endif
276
+ submit("app.opened") { core in
277
+ // Pushes iOS never delivered: pulled, and shown in front.
278
+ for notification in await core.appOpened(pull: true) {
279
+ _ = await self.show(notification, received: true, opened: false)
280
+ }
281
+ return .done
282
+ }
283
+ submit("events") { await $0.flushEvents() }
284
+ submit("maintenance") { await $0.maintenance(configMaxAgeSeconds: EngineCore.openConfigMaxAgeSeconds) }
285
+ appOpenedForLocation()
286
+ }
287
+
288
+ #if canImport(UIKit) && !os(watchOS)
289
+ /// The app went to the back: send what's queued in the time iOS allows.
290
+ @MainActor
291
+ private func appWentToTheBack() {
292
+ appWentToTheBackForLocation()
293
+ guard let core = current, core.isActive else { return }
294
+ BackgroundRefresh.schedule()
295
+ let time = BackgroundTime("tech.bubbl.sdk.flush")
296
+ Task {
297
+ // Not sent in time: the runner retries it, with its backoff, when the app next runs.
298
+ if case .retry = await core.flushEvents() { submit("events") { await $0.flushEvents() } }
299
+ time.end()
300
+ }
301
+ }
302
+ #endif
303
+
304
+ // MARK: - Files
305
+
306
+ /// Application Support/tech.bubbl.sdk, kept out of backups: a restored install id would make two
307
+ /// phones one device, and a restored geofence state would claim places the device isn't in.
308
+ static func directory() -> URL? {
309
+ guard let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil }
310
+ var directory = base.appendingPathComponent("tech.bubbl.sdk", isDirectory: true)
311
+ do {
312
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
313
+ var values = URLResourceValues()
314
+ values.isExcludedFromBackup = true
315
+ try directory.setResourceValues(values)
316
+ } catch {
317
+ // Already there (and excluded), or not writable yet: the stores say which when used.
318
+ }
319
+ return directory
320
+ }
321
+
322
+ /// Whether the engine's files (and the Keychain item, whose class matches) can be read now,
323
+ /// tried on a file of their protection class: before the first unlock after a reboot an
324
+ /// existing one can't be read and a new one can't be made.
325
+ static func dataReadable(in directory: URL? = directory()) -> Bool {
326
+ guard let directory else { return false }
327
+ let probe = directory.appendingPathComponent("readable")
328
+ if (try? Data(contentsOf: probe)) != nil { return true }
329
+ guard !FileManager.default.fileExists(atPath: probe.path) else { return false }
330
+ do {
331
+ try Data([1]).write(to: probe, options: writeOptions.union(.atomic))
332
+ return true
333
+ } catch {
334
+ return false
335
+ }
336
+ }
337
+
338
+ /// Readable from the first unlock after a reboot until the next reboot (region events in the
339
+ /// background need that); unreadable before it.
340
+ static var writeOptions: Data.WritingOptions {
341
+ #if os(iOS)
342
+ [.completeFileProtectionUntilFirstUserAuthentication]
343
+ #else
344
+ []
345
+ #endif
346
+ }
347
+ }
348
+
349
+ /// Region monitoring until the location slice (7): watches nothing, as for a user who allowed
350
+ /// location only while the app is in use.
351
+ @available(iOS 17, *)
352
+ struct InactiveRegionMonitor: RegionMonitor {
353
+ func capacity() async -> Int { 0 }
354
+ func watch(_ regions: [WatchedRegion]) async -> Bool { true }
355
+ func stop() async {}
356
+ }
@@ -0,0 +1,320 @@
1
+ import Foundation
2
+ #if canImport(UIKit)
3
+ import UIKit
4
+ #endif
5
+ #if canImport(UserNotifications)
6
+ import UserNotifications
7
+ #endif
8
+ #if !COCOAPODS
9
+ import BubblCore
10
+ #endif
11
+
12
+ /// `Bubbl`'s calls on iOS 17 and later: the engine.
13
+ @available(iOS 17, *)
14
+ struct SupportedBubbl: BubblBackend {
15
+ var isSupported: Bool { true }
16
+
17
+ // MARK: - Starting
18
+
19
+ func start(apiKey: String, options: BubblOptions) {
20
+ let key = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
21
+ var baseUrl = options.baseUrl.trimmingCharacters(in: .whitespacesAndNewlines)
22
+ while baseUrl.hasSuffix("/") { baseUrl.removeLast() }
23
+
24
+ guard !key.isEmpty else {
25
+ SystemLog.use(options.logLevel)
26
+ return BubblLog.error("Bubbl.start: the API key is empty")
27
+ }
28
+ guard Bubbl.isAllowedBaseUrl(baseUrl) else {
29
+ SystemLog.use(options.logLevel)
30
+ return BubblLog.error("Bubbl.start: baseUrl must be https:// (http:// only for localhost or a .test host)")
31
+ }
32
+
33
+ guard let core = EngineHost.shared.start(EngineConfig(apiKey: key, baseUrl: baseUrl), options: options) else { return }
34
+ BubblLog.info("Bubbl \(BubblVersion.sdk) started")
35
+ if let warning = EngineStart.keyWarning(apiKey: key, debugBuild: Self.debugBuild) { BubblLog.warning(warning) }
36
+ core.announceSandbox()
37
+ }
38
+
39
+ /// The app's build, as the SDK was built with it (a Swift package builds in the app's
40
+ /// configuration): for the key-and-build warning.
41
+ static var debugBuild: Bool {
42
+ #if DEBUG
43
+ true
44
+ #else
45
+ false
46
+ #endif
47
+ }
48
+
49
+ func start(credential: BubblCredential, options: BubblOptions) {
50
+ var baseUrl = options.baseUrl.trimmingCharacters(in: .whitespacesAndNewlines)
51
+ while baseUrl.hasSuffix("/") { baseUrl.removeLast() }
52
+
53
+ guard [credential.keyId, credential.secret, credential.installId].allSatisfy({ !$0.trimmingCharacters(in: .whitespaces).isEmpty }) else {
54
+ SystemLog.use(options.logLevel)
55
+ return BubblLog.error("Bubbl.start: the credential is incomplete")
56
+ }
57
+ guard Bubbl.isAllowedBaseUrl(baseUrl) else {
58
+ SystemLog.use(options.logLevel)
59
+ return BubblLog.error("Bubbl.start: baseUrl must be https:// (http:// only for localhost or a .test host)")
60
+ }
61
+
62
+ guard let core = EngineHost.shared.start(EngineConfig(apiKey: nil, baseUrl: baseUrl), credential: credential.issued, options: options) else { return }
63
+ BubblLog.info("Bubbl \(BubblVersion.sdk) started with a credential")
64
+ core.announceSandbox()
65
+ }
66
+
67
+ func stop() {
68
+ EngineHost.shared.stop()
69
+ }
70
+
71
+ // MARK: - Consent and privacy
72
+
73
+ func setConsent(_ granted: Bool) {
74
+ guard granted else { return optOut() }
75
+ guard let core = started("setConsent") else { return }
76
+ do {
77
+ if try core.grantConsent() { EngineHost.shared.consentGiven(core) }
78
+ } catch {
79
+ BubblLog.warning("Bubbl.setConsent can't be saved until the device is unlocked: ask again then")
80
+ }
81
+ }
82
+
83
+ func optOut() {
84
+ guard let core = started("optOut") else { return }
85
+ EngineHost.shared.optOut(core)
86
+ }
87
+
88
+ func deleteMyData() {
89
+ guard let core = started("deleteMyData") else { return }
90
+ EngineHost.shared.erase(core)
91
+ }
92
+
93
+ func setLocationEnabled(_ enabled: Bool) {
94
+ guard let core = started("setLocationEnabled") else { return }
95
+ EngineHost.shared.setLocationEnabled(enabled, core)
96
+ }
97
+
98
+ func registerTestDevice(_ code: String) async -> BubblTestDeviceResult {
99
+ guard let core = started("registerTestDevice") else {
100
+ return BubblTestDeviceResult(approved: false, message: "Bubbl.start hasn't been called")
101
+ }
102
+ let result = await core.registerTestDevice(code)
103
+ return BubblTestDeviceResult(approved: result.approved, message: result.message)
104
+ }
105
+
106
+ // MARK: - Targeting and events
107
+
108
+ func setSegments(_ segments: [String]) {
109
+ guard let core = started("setSegments") else { return }
110
+ do {
111
+ if try core.segments.set(segments) { EngineHost.shared.submit("segments") { await $0.pushSegments() } }
112
+ } catch {
113
+ BubblLog.warning("Bubbl.setSegments: they can't be saved until the device is unlocked")
114
+ }
115
+ }
116
+
117
+ func track(_ name: String, properties: [String: Any?]) {
118
+ guard let core = started("track") else { return }
119
+ var converted: [String: JSONValue] = [:]
120
+ for (key, value) in properties {
121
+ if let json = JSONValue(app: value) { converted[key] = json } else {
122
+ BubblLog.warning("Bubbl.track: a property that isn't text, a number or true/false was left out")
123
+ }
124
+ }
125
+ Task {
126
+ if await core.track(name, properties: converted) {
127
+ EngineHost.shared.submit("events") { await $0.flushEvents() }
128
+ }
129
+ }
130
+ }
131
+
132
+ // MARK: - Notifications
133
+
134
+ func setNotificationListener(_ listener: (@MainActor @Sendable (BubblMessage) -> Bool)?) {
135
+ EngineHost.shared.notificationListener = listener
136
+ }
137
+
138
+ func addEventListener(_ listener: @escaping @MainActor @Sendable (BubblEvent) -> Void) -> BubblEventToken {
139
+ BubblEvents.shared.add(listener)
140
+ }
141
+
142
+ func removeEventListener(_ token: BubblEventToken) {
143
+ BubblEvents.shared.remove(token)
144
+ }
145
+
146
+ func present(_ message: BubblMessage) {
147
+ guard started("present") != nil else { return }
148
+ Task { _ = await EngineHost.shared.presenter.present(message.notification.json, opened: false) }
149
+ }
150
+
151
+ /// Records a notification event the app reports (and tells its event listeners), then sends
152
+ /// the queue.
153
+ func report(_ call: String, _ record: @escaping @Sendable (NotificationEvents) async throws -> Void) {
154
+ guard let core = started(call), core.isActive else { return }
155
+ let events = EngineHost.shared.notificationEvents(core)
156
+ Task {
157
+ do {
158
+ try await record(events)
159
+ EngineHost.shared.submit("events") { await $0.flushEvents() }
160
+ } catch {
161
+ BubblLog.warning("Bubbl.\(call): not recorded (\(type(of: error)))")
162
+ }
163
+ }
164
+ }
165
+
166
+ func submitSurvey(_ message: BubblMessage, answers: [String: Any?]) -> Bool {
167
+ var converted: [String: JSONValue] = [:]
168
+ for (id, value) in answers {
169
+ guard let json = JSONValue(app: value) else {
170
+ BubblLog.warning("Bubbl.submitSurvey: the answer to \(id) isn't a choice id, a list of them, a number, true/false or text")
171
+ return false
172
+ }
173
+ converted[id] = json
174
+ }
175
+ switch SurveyForm.fromAnswers(message.notification.questions, converted) {
176
+ case .failure(let problem):
177
+ BubblLog.warning("Bubbl.submitSurvey: \(problem)")
178
+ return false
179
+ case .success(let form):
180
+ report("submitSurvey") { try await $0.surveySubmitted(message.notification, form) }
181
+ return true
182
+ }
183
+ }
184
+
185
+ func openCta(_ message: BubblMessage) {
186
+ guard let link = message.ctaUrl, LinkPolicy.canOpen(link), let url = URL(string: link) else { return }
187
+ report("reportCtaClicked") { try await $0.ctaClicked(message.notification) }
188
+ #if canImport(UIKit) && !os(watchOS)
189
+ Task { @MainActor in UIApplication.shared.open(url) }
190
+ #endif
191
+ }
192
+
193
+ // MARK: - Push
194
+
195
+ func isBubblMessage(_ userInfo: [AnyHashable: Any]) -> Bool {
196
+ PushMessage.isBubbl(EngineHost.pushData(userInfo))
197
+ }
198
+
199
+ #if os(iOS)
200
+ func setPushToken(_ deviceToken: Data) {
201
+ EngineHost.shared.pushTokenReceived(deviceToken)
202
+ }
203
+
204
+ func willPresent(_ notification: UNNotification, completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) -> Bool {
205
+ NotificationDelegateHooks.willPresent(notification, completionHandler)
206
+ }
207
+
208
+ func didReceive(_ response: UNNotificationResponse, completionHandler: @escaping () -> Void) -> Bool {
209
+ NotificationDelegateHooks.didReceive(response, completionHandler)
210
+ }
211
+ #endif
212
+
213
+ // MARK: - Permissions
214
+
215
+ func permissionStatus() -> BubblPermissionStatus? {
216
+ guard EngineHost.shared.current != nil else { return nil }
217
+ return EngineHost.shared.platform.permissionStatus.map(BubblPermissionStatus.init)
218
+ }
219
+
220
+ func requestPermission(_ request: PermissionRequest, _ call: String) async -> BubblPermissionStatus? {
221
+ guard EngineHost.shared.current != nil else {
222
+ BubblLog.warning("Bubbl.\(call) before Bubbl.start: ignored")
223
+ return nil
224
+ }
225
+ #if os(iOS)
226
+ return BubblPermissionStatus(await PermissionFlow.shared.request(request))
227
+ #else
228
+ return nil
229
+ #endif
230
+ }
231
+
232
+ /// No engine needed, so no `start` check: an app can send the user to its settings page before
233
+ /// starting Bubbl.
234
+ func openSettings() {
235
+ #if os(iOS)
236
+ Task { @MainActor in PermissionFlow.openSettings() }
237
+ #endif
238
+ }
239
+
240
+ // MARK: - Diagnostics
241
+
242
+ func diagnostics() async -> Bubbl.Diagnostics {
243
+ let backgroundRefresh = await Self.backgroundRefreshAvailable()
244
+ let lowPower = ProcessInfo.processInfo.isLowPowerModeEnabled
245
+ guard let core = EngineHost.shared.current else {
246
+ return Bubbl.Diagnostics(
247
+ started: false, supported: true, sdkVersion: Bubbl.sdkVersion, sdkSupported: true, registered: false, installId: nil, active: false,
248
+ pausedUntil: nil, consentRequired: false, consent: nil, locationEnabled: true, permissions: nil, hasPushToken: false,
249
+ geofences: 0, queuedEvents: 0, lastError: BubblLog.lastError, credentialRejected: EngineHost.shared.isCredentialRejected, backgroundLocation: false,
250
+ transitionsDroppedWhileLocked: 0, backgroundRefreshAvailable: backgroundRefresh, lowPowerMode: lowPower,
251
+ osWatchesGeofences: false, environment: nil, testDevice: nil
252
+ )
253
+ }
254
+ let privacy = core.privacy.state
255
+ return Bubbl.Diagnostics(
256
+ started: true,
257
+ supported: true,
258
+ sdkVersion: Bubbl.sdkVersion,
259
+ sdkSupported: core.sdkSupported,
260
+ registered: core.everRegistered,
261
+ installId: core.keptInstallId,
262
+ active: core.isActive,
263
+ pausedUntil: core.pausedUntilSeconds,
264
+ consentRequired: privacy?.requireConsent ?? false,
265
+ consent: privacy?.consent,
266
+ locationEnabled: privacy?.locationEnabled ?? true,
267
+ permissions: permissionStatus(),
268
+ hasPushToken: EngineHost.shared.platform.pushToken() != nil,
269
+ geofences: (try? await core.geofences.geofenceCount()) ?? 0,
270
+ queuedEvents: (try? await core.events.size()) ?? 0,
271
+ lastError: BubblLog.lastError,
272
+ credentialRejected: EngineHost.shared.isCredentialRejected,
273
+ backgroundLocation: await Self.backgroundLocation(),
274
+ transitionsDroppedWhileLocked: core.transitionsDroppedWhileLocked,
275
+ backgroundRefreshAvailable: backgroundRefresh,
276
+ lowPowerMode: lowPower,
277
+ osWatchesGeofences: await Self.osWatchesGeofences(),
278
+ environment: core.workspace?.environment,
279
+ testDevice: core.workspace?.testDevice.map { Bubbl.Diagnostics.TestDevice(status: $0.status, code: $0.code) }
280
+ )
281
+ }
282
+
283
+ // MARK: - Internals
284
+
285
+ private func started(_ call: String) -> EngineCore? {
286
+ guard let core = EngineHost.shared.current else {
287
+ BubblLog.warning("Bubbl.\(call) before Bubbl.start: ignored")
288
+ return nil
289
+ }
290
+ return core
291
+ }
292
+
293
+ /// "Always", with precise location: iOS watches the geofences with the app closed.
294
+ @MainActor
295
+ private static func backgroundLocation() -> Bool {
296
+ #if os(iOS)
297
+ LocationService.shared.access == .always
298
+ #else
299
+ false
300
+ #endif
301
+ }
302
+
303
+ @MainActor
304
+ private static func osWatchesGeofences() -> Bool {
305
+ #if os(iOS)
306
+ LocationService.shared.osWatches
307
+ #else
308
+ false
309
+ #endif
310
+ }
311
+
312
+ @MainActor
313
+ private static func backgroundRefreshAvailable() -> Bool {
314
+ #if canImport(UIKit) && !os(watchOS)
315
+ UIApplication.shared.backgroundRefreshStatus == .available
316
+ #else
317
+ false
318
+ #endif
319
+ }
320
+ }