@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,170 @@
1
+ import Foundation
2
+
3
+ /// What sending the pending geofence transitions came to.
4
+ package struct TransitionsSent: Sendable, Equatable {
5
+ package var outcome: WorkOutcome = .done
6
+ /// Notifications the server says to show now.
7
+ package var notifications: [JSONValue] = []
8
+ /// A transition was refused because its location has gone: check the geofences again.
9
+ package var recheck = false
10
+
11
+ package init(outcome: WorkOutcome = .done, notifications: [JSONValue] = [], recheck: Bool = false) {
12
+ self.outcome = outcome
13
+ self.notifications = notifications
14
+ self.recheck = recheck
15
+ }
16
+ }
17
+
18
+ /// Geofencing as work, as Android's GeofenceWork and receivers: the OS's region events and
19
+ /// location fixes become transitions, which are saved before anything else (the app can be
20
+ /// suspended or killed at any moment in the background) and then sent; checks watch the geofences
21
+ /// and fetch them when due. The platform supplies the fixes and runs the work.
22
+ extension EngineCore {
23
+ /// At most this many transitions wait to be sent; the oldest go first.
24
+ package static let maxPendingTransitions = 200
25
+
26
+ /// The OS says the device entered or left `regionIds` (Bubbl's region names; others are
27
+ /// ignored). Returns what to do next: its transitions are already saved for sending.
28
+ package func regionEvent(_ regionIds: [String], entered: Bool, fix: Fix?, deviceTimeMillis: Int64) async -> Outcome {
29
+ let geofenceEvents = regionIds.filter { $0.hasPrefix(GeofenceEngine.regionPrefix) && $0 != GeofenceEngine.refreshRegionId }.count
30
+ // Before the first unlock after a reboot nothing can be read, not even whether location
31
+ // is allowed: the event is lost (accepted), and counted for diagnostics.
32
+ guard let privacy = privacy.state else {
33
+ droppedWhileLocked(geofenceEvents)
34
+ return Outcome()
35
+ }
36
+ guard privacy.locationActive, isActive else { return Outcome() }
37
+
38
+ var outcome: Outcome
39
+ do {
40
+ outcome = try await geofences.onRegionEvent(regionIds, entered: entered, fix: fix, deviceTimeMillis: deviceTimeMillis)
41
+ } catch {
42
+ droppedWhileLocked(geofenceEvents)
43
+ return Outcome()
44
+ }
45
+ if !record(outcome.transitions) { outcome.transitions = [] }
46
+ return outcome
47
+ }
48
+
49
+ /// A location fix (significant change, in-use updates, one asked for). `osWatches`: the OS
50
+ /// watches the geofences ("Always"); otherwise entering and leaving are decided from the fix.
51
+ package func locationFix(_ fix: Fix, osWatches: Bool) async -> Outcome {
52
+ guard locationActive else { return Outcome() }
53
+ var outcome: Outcome
54
+ do {
55
+ outcome = osWatches ? try await geofences.onFix(fix) : try await geofences.onFixWithoutOs(fix)
56
+ } catch {
57
+ return Outcome()
58
+ }
59
+ if !record(outcome.transitions) { outcome.transitions = [] }
60
+ return outcome
61
+ }
62
+
63
+ /// Android's CheckGeofencesWorker: make sure the geofences are watched (`rewatch`: the OS
64
+ /// dropped them), check them against `fix`, and fetch them if due (or `force`).
65
+ package func checkGeofences(fix: Fix, force: Bool = false, rewatch: Bool = false, osWatches: Bool) async -> WorkOutcome {
66
+ guard locationActive else { return .done }
67
+ saveLockedDrops()
68
+ do {
69
+ _ = rewatch ? try await geofences.rewatch() : try await geofences.ensureWatching()
70
+ } catch {
71
+ BubblLog.warning("Couldn't watch the geofences: their state can't be read yet")
72
+ }
73
+
74
+ // Its transitions are saved for sending; its refresh is the one below.
75
+ _ = await locationFix(fix, osWatches: osWatches)
76
+
77
+ let result: RefreshResult
78
+ do {
79
+ result = try await geofences.refresh(fix, force: force)
80
+ } catch {
81
+ return .retry(afterSeconds: 30)
82
+ }
83
+ BubblLog.debug("Geofence check: \(result)")
84
+ if case .failed(let failure) = result { return await handle(failure, "Checking geofences") }
85
+ return .done
86
+ }
87
+
88
+ /// Send the saved transitions, oldest first, stopping at the first that has to wait. One
89
+ /// sending at a time: a retry and a new region event never send the same one twice.
90
+ package func sendTransitions() async -> TransitionsSent {
91
+ await sendingTransitions.withLock { await self.sendPendingTransitions() }
92
+ }
93
+
94
+ private func sendPendingTransitions() async -> TransitionsSent {
95
+ guard locationActive else { return TransitionsSent() }
96
+ saveLockedDrops()
97
+ let pending: [Transition]
98
+ do {
99
+ pending = try transitionsLock.sync { try stores.transitions.load() ?? [] }
100
+ } catch {
101
+ return TransitionsSent(outcome: .retry(afterSeconds: 30))
102
+ }
103
+
104
+ var sent = TransitionsSent()
105
+ var finished: Set<String> = []
106
+ loop: for transition in pending {
107
+ switch await geofences.send(transition) {
108
+ case .delivered(let notifications):
109
+ BubblLog.debug("Geofence \(transition.enter ? "entry" : "exit") sent: \(notifications.count) notification(s) to show")
110
+ sent.notifications += notifications
111
+ finished.insert(transition.key)
112
+ case .dropped(let reason):
113
+ sent.recheck = sent.recheck || reason == "unknown_location"
114
+ finished.insert(transition.key)
115
+ case .failed(let failure):
116
+ sent.outcome = await handle(failure, "Sending a geofence transition")
117
+ break loop
118
+ }
119
+ }
120
+
121
+ if !finished.isEmpty {
122
+ try? transitionsLock.sync {
123
+ let left = (try stores.transitions.load() ?? []).filter { !finished.contains($0.key) }
124
+ try stores.transitions.save(left)
125
+ }
126
+ }
127
+ return sent
128
+ }
129
+
130
+ /// Whether transitions are waiting to be sent (false while that can't be read).
131
+ package var hasPendingTransitions: Bool {
132
+ transitionsLock.sync { ((try? stores.transitions.load()) ?? nil)?.isEmpty == false }
133
+ }
134
+
135
+ /// Region events lost before the first unlock after a reboot, since this install began.
136
+ package var transitionsDroppedWhileLocked: Int {
137
+ let saved: Int?? = try? stores.lockedDrops.load()
138
+ return ((saved ?? nil) ?? 0) + unsavedLockedDrops.value
139
+ }
140
+
141
+ /// Save `transitions` to be sent. False (and counted as lost) when they can't be written.
142
+ private func record(_ transitions: [Transition]) -> Bool {
143
+ guard !transitions.isEmpty else { return true }
144
+ do {
145
+ try transitionsLock.sync {
146
+ let all = (try stores.transitions.load() ?? []) + transitions
147
+ try stores.transitions.save(Array(all.suffix(Self.maxPendingTransitions)))
148
+ }
149
+ return true
150
+ } catch {
151
+ droppedWhileLocked(transitions.count)
152
+ return false
153
+ }
154
+ }
155
+
156
+ private func droppedWhileLocked(_ count: Int) {
157
+ guard count > 0 else { return }
158
+ unsavedLockedDrops.add(count)
159
+ BubblLog.info("\(count) geofence event(s) before the first unlock since the phone restarted: not kept")
160
+ saveLockedDrops()
161
+ }
162
+
163
+ /// Adds the drops counted in memory to the saved count, once it can be written.
164
+ private func saveLockedDrops() {
165
+ try? unsavedLockedDrops.drain { count in
166
+ let saved = try stores.lockedDrops.load() ?? 0
167
+ try stores.lockedDrops.save(saved + count)
168
+ }
169
+ }
170
+ }
@@ -0,0 +1,90 @@
1
+ import Foundation
2
+
3
+ /// Where the device stands on the permissions Bubbl uses: the contract's Permissions for PUT
4
+ /// /device, and what the app sees (BubblPermissionStatus).
5
+ package struct PermissionStatus: Sendable, Equatable {
6
+ package enum Notifications: String, Sendable {
7
+ case granted, denied
8
+ case notDetermined = "not_determined"
9
+ }
10
+
11
+ package enum Location: String, Sendable {
12
+ case always, denied
13
+ case whenInUse = "when_in_use"
14
+ case notDetermined = "not_determined"
15
+ }
16
+
17
+ package let notifications: Notifications
18
+ package let location: Location
19
+ /// Precise rather than approximate location.
20
+ package let preciseLocation: Bool
21
+
22
+ package init(notifications: Notifications, location: Location, preciseLocation: Bool) {
23
+ self.notifications = notifications
24
+ self.location = location
25
+ self.preciseLocation = preciseLocation
26
+ }
27
+
28
+ package var hasLocation: Bool { location == .always || location == .whenInUse }
29
+
30
+ /// As PUT /device takes it.
31
+ package var json: JSONValue {
32
+ ["notifications": .string(notifications.rawValue), "location": .string(location.rawValue), "precise_location": .bool(preciseLocation)]
33
+ }
34
+ }
35
+
36
+ /// What an app asks Bubbl to get permission for.
37
+ package enum PermissionRequest: Sendable, Equatable {
38
+ case notifications, locationWhenInUse, locationAlways
39
+ }
40
+
41
+ /// One step of asking: explain first (the privacy view), or show the system's prompt.
42
+ package enum PermissionStep: Sendable, Equatable {
43
+ /// The privacy view, for "notifications", "location" or "background_location".
44
+ case explain(String)
45
+ case ask(PermissionRequest)
46
+ }
47
+
48
+ /// The steps to get a permission on iOS, given where the device stands (Android's
49
+ /// PermissionPlanner, with iOS's rules):
50
+ /// - iOS prompts for each only once: a permission already refused (or "Always" already asked for
51
+ /// and not given) has no steps; the app can send the person to Settings instead;
52
+ /// - "Always" comes in two steps, as iOS expects: "While Using" first, then "Always" (asked for
53
+ /// straight away, iOS gives only a provisional Always).
54
+ /// The privacy view comes before each prompt as the dashboard's notice mode says: "never",
55
+ /// "always", or "automatic" (the first time each kind is asked for).
56
+ package enum PermissionPlanner {
57
+ package static func plan(_ request: PermissionRequest, status: PermissionStatus, notice: String, explained: Set<String>, askedAlways: Bool) -> [PermissionStep] {
58
+ var steps: [PermissionStep] = []
59
+ func explain(_ kind: String) {
60
+ if notice == "always" || (notice != "never" && !explained.contains(kind)) { steps.append(.explain(kind)) }
61
+ }
62
+
63
+ switch request {
64
+ case .notifications:
65
+ if status.notifications == .notDetermined {
66
+ explain("notifications")
67
+ steps.append(.ask(.notifications))
68
+ }
69
+ case .locationWhenInUse:
70
+ if status.location == .notDetermined {
71
+ explain("location")
72
+ steps.append(.ask(.locationWhenInUse))
73
+ }
74
+ case .locationAlways:
75
+ switch status.location {
76
+ case .notDetermined:
77
+ explain("location")
78
+ steps.append(.ask(.locationWhenInUse))
79
+ explain("background_location")
80
+ steps.append(.ask(.locationAlways))
81
+ case .whenInUse where !askedAlways:
82
+ explain("background_location")
83
+ steps.append(.ask(.locationAlways))
84
+ default:
85
+ break
86
+ }
87
+ }
88
+ return steps
89
+ }
90
+ }
@@ -0,0 +1,64 @@
1
+ import Foundation
2
+
3
+ /// What the app's user has agreed to, and what the engine may do because of it (Android's
4
+ /// PrivacyState).
5
+ ///
6
+ /// - requireConsent: the app started Bubbl with consent required; nothing runs (no registration,
7
+ /// no network, no location) until consent is true.
8
+ /// - consent: nil until the user answers; false after opting out or erasing their data, which
9
+ /// stops the engine whether or not consent was required.
10
+ /// - locationEnabled: the app can turn location off (setLocationEnabled) while the rest runs.
11
+ /// - pendingDelete: deleteMyData() was asked for and DELETE /device hasn't gone through yet.
12
+ package struct PrivacyState: Sendable, Equatable, Codable {
13
+ package var requireConsent = false
14
+ package var consent: Bool?
15
+ package var locationEnabled = true
16
+ package var pendingDelete = false
17
+
18
+ package init(requireConsent: Bool = false, consent: Bool? = nil, locationEnabled: Bool = true, pendingDelete: Bool = false) {
19
+ self.requireConsent = requireConsent
20
+ self.consent = consent
21
+ self.locationEnabled = locationEnabled
22
+ self.pendingDelete = pendingDelete
23
+ }
24
+
25
+ /// The engine may run at all: register, send, fetch.
26
+ package var active: Bool { !pendingDelete && consent != false && (!requireConsent || consent == true) }
27
+
28
+ /// The engine may use location: geofences, fixes.
29
+ package var locationActive: Bool { active && locationEnabled }
30
+ }
31
+
32
+ /// PrivacyState kept between runs. While it can't be read (before the first unlock) the engine
33
+ /// counts as not allowed to run: it never guesses a user's consent.
34
+ package final class PrivacyStore: Sendable {
35
+ private let store: any ValueStore<PrivacyState>
36
+ private let cache = Cached<PrivacyState>()
37
+ private let lock = NSLock()
38
+
39
+ package init(store: any ValueStore<PrivacyState>) {
40
+ self.store = store
41
+ }
42
+
43
+ /// The state; nil while it can't be read.
44
+ package var state: PrivacyState? {
45
+ lock.sync {
46
+ do {
47
+ return try cache.value { try store.load() } ?? PrivacyState()
48
+ } catch {
49
+ return nil
50
+ }
51
+ }
52
+ }
53
+
54
+ @discardableResult
55
+ package func update(_ change: (inout PrivacyState) -> Void) throws -> PrivacyState {
56
+ try lock.sync {
57
+ var state = try cache.value { try store.load() } ?? PrivacyState()
58
+ change(&state)
59
+ try store.save(state)
60
+ cache.set(state)
61
+ return state
62
+ }
63
+ }
64
+ }
@@ -0,0 +1,184 @@
1
+ import Foundation
2
+
3
+ /// What an app's user can decide about Bubbl, and what each decision does to the engine (Android's
4
+ /// PrivacyControls). The engine changes its state and stops what it must; the host runs the work
5
+ /// each returns (and cancels its own), since only it knows how:
6
+ ///
7
+ /// - consent given: everything runs (with consent required, nothing ran until now);
8
+ /// - an opt-out (or consent refused): geofences stop, queued events are dropped, nothing more is
9
+ /// sent or shown; a device the server knows is told, once (PUT /device consent=false);
10
+ /// - deleteMyData: stops as for an opt-out, then DELETE /device erases the device and all that's
11
+ /// recorded about it, and the engine's own data goes too; retried until done, and the engine
12
+ /// stays stopped meanwhile and afterwards (consent given again starts afresh, as a new device);
13
+ /// - location off: geofences and location stop; the rest carries on.
14
+ ///
15
+ /// Each throws while the state can't be saved (before the first unlock after a reboot).
16
+ extension EngineCore {
17
+ /// The user said yes (again, after an opt-out). False when nothing changed: a pending erasure
18
+ /// holds everything until it's done, then consent may be given again.
19
+ @discardableResult
20
+ package func grantConsent() throws -> Bool {
21
+ guard privacy.state?.pendingDelete != true else {
22
+ BubblLog.warning("Bubbl.setConsent(true) while the user's data is being erased: asked again once it's done")
23
+ return false
24
+ }
25
+ let before = privacy.state?.consent
26
+ try privacy.update { $0.consent = true }
27
+ return before != true
28
+ }
29
+
30
+ /// The user opted out. True when the server has to be told (Bubbl was running; then run
31
+ /// `consentWithdrawn` as work until it's done).
32
+ @discardableResult
33
+ package func optOut() async throws -> Bool {
34
+ let wasActive = privacy.state?.active == true
35
+ try privacy.update { $0.consent = false }
36
+ await stopWork()
37
+ return wasActive
38
+ }
39
+
40
+ /// PUT /device consent=false, the one call made after an opt-out. Nothing to tell for an install
41
+ /// that never registered; nothing left to tell once consent is back, or an erasure took over.
42
+ package func consentWithdrawn() async -> WorkOutcome {
43
+ guard let state = privacy.state else { return .retry(afterSeconds: 60) }
44
+ guard state.consent == false, !state.pendingDelete, everRegistered else { return .done }
45
+
46
+ switch await deviceSync.sync(["consent": .bool(false)]) {
47
+ case .failed(.backoff):
48
+ return .retry(afterSeconds: 30)
49
+ case .failed(.retryAfter(let seconds)):
50
+ return .retry(afterSeconds: seconds)
51
+ default:
52
+ // Told, or refused for good (paused, misconfigured): the device stays quiet either way.
53
+ return .done
54
+ }
55
+ }
56
+
57
+ /// The user asked for their data to be erased: stopped now; then run `eraseDevice` as work until
58
+ /// it's done.
59
+ package func requestErasure() async throws {
60
+ try privacy.update {
61
+ $0.consent = false
62
+ $0.pendingDelete = true
63
+ }
64
+ await stopWork()
65
+ }
66
+
67
+ /// DELETE /device, then the engine's own data. A revoked credential is replaced first by the
68
+ /// client (the install id names the same device), so the right device is erased; an install
69
+ /// that never registered has nothing on the server. Retried until done; given up only on an
70
+ /// error a retry can't fix, after `maxEraseAttempts`, and then said so rather than pretending
71
+ /// the data is gone.
72
+ package func eraseDevice() async -> WorkOutcome {
73
+ guard let state = privacy.state else { return .retry(afterSeconds: 60) }
74
+ guard state.pendingDelete else { return .done }
75
+
76
+ if everRegistered {
77
+ let response: ApiResponse
78
+ do {
79
+ response = try await api.request("DELETE", "api/v1/device")
80
+ } catch {
81
+ return .retry(afterSeconds: 30)
82
+ }
83
+ // Erased, or already gone.
84
+ if !response.isSuccessful && response.status != 404 {
85
+ switch ApiFailure.of(response) {
86
+ case .backoff: return .retry(afterSeconds: 30)
87
+ case .retryAfter(let seconds): return .retry(afterSeconds: seconds)
88
+ case .pause(let hours, _): return .retry(afterSeconds: hours * 3_600)
89
+ default:
90
+ if eraseAttempts.next() < Self.maxEraseAttempts { return .retry(afterSeconds: 60) }
91
+ BubblLog.error("Couldn't erase this device (HTTP \(response.status) \(response.code ?? "")): its data is still on the server")
92
+ return .done
93
+ }
94
+ }
95
+ }
96
+
97
+ do {
98
+ try await wipeLocalData()
99
+ try privacy.update {
100
+ $0.pendingDelete = false
101
+ $0.consent = false
102
+ }
103
+ } catch {
104
+ BubblLog.warning("The device was erased on the server; its data here goes when it can be written")
105
+ return .retry(afterSeconds: 60)
106
+ }
107
+ BubblLog.info("This device's data was erased")
108
+ return .done
109
+ }
110
+
111
+ package static let maxEraseAttempts = 5
112
+
113
+ /// Location (geofences) off: they stop, the rest carries on. False when nothing changed.
114
+ @discardableResult
115
+ package func setLocationEnabled(_ enabled: Bool) async throws -> Bool {
116
+ let before = privacy.state?.locationEnabled
117
+ try privacy.update { $0.locationEnabled = enabled }
118
+ if !enabled { try? await geofences.stop() }
119
+ return before != enabled
120
+ }
121
+
122
+ /// Everything the engine keeps here, but what the user decided: the credential (the Keychain),
123
+ /// the install id (the next registration is a new device), the queue, geofences, and each saved
124
+ /// piece of state. Throws when something couldn't be removed.
125
+ package func wipeLocalData() async throws {
126
+ try stores.credentials.clear()
127
+ try await events.clear()
128
+ try await geofences.stop()
129
+ try configSync.forget()
130
+ try segments.forget()
131
+ deviceSync.forget()
132
+ try stores.recentNotifications.delete()
133
+ try stores.installId.delete()
134
+ try stores.clockOffset.delete()
135
+ try stores.pausedUntil.delete()
136
+ try stores.transitions.delete()
137
+ try stores.lockedDrops.delete()
138
+ }
139
+
140
+ /// Stops what the engine does and drops what's queued (geofences, events, unsent transitions).
141
+ private func stopWork() async {
142
+ try? await geofences.stop()
143
+ try? await events.clear()
144
+ try? stores.transitions.delete()
145
+ }
146
+ }
147
+
148
+ extension EngineCore {
149
+ /// A device started with a credential issued outside the app: that credential is this device's
150
+ /// (and its install id, the credential's), unless it's the one already kept. Throws while the
151
+ /// Keychain or the files can't be written.
152
+ package func useCredential(_ credential: IssuedCredential) throws {
153
+ if try stores.installId.load() != credential.installId { try stores.installId.save(credential.installId) }
154
+ if case .present(let kept) = stores.credentials.read(), kept.keyId == credential.keyId { return }
155
+ try stores.credentials.save(keyId: credential.keyId, secret: credential.secret)
156
+ }
157
+
158
+ /// This install's id as kept, without making one (diagnostics); nil before there is one or
159
+ /// while it can't be read.
160
+ package var keptInstallId: String? {
161
+ guard let id = (try? stores.installId.load()) ?? nil, !id.isEmpty else { return nil }
162
+ return id
163
+ }
164
+
165
+ /// Told when the server refuses the credential the device was started with (where an install
166
+ /// would register): the host stops Bubbl until the app starts it with a new one.
167
+ package func onCredentialRejected(_ handler: @escaping @Sendable () -> Void) {
168
+ hooks.credentialRejected = handler
169
+ }
170
+ }
171
+
172
+ /// Counts erasure attempts that failed for good, for the life of the process.
173
+ final class AttemptCounter: @unchecked Sendable {
174
+ private let lock = NSLock()
175
+ private var count = 0
176
+
177
+ /// Counts one more; returns the count so far.
178
+ func next() -> Int {
179
+ lock.sync {
180
+ count += 1
181
+ return count
182
+ }
183
+ }
184
+ }
@@ -0,0 +1,61 @@
1
+ import Foundation
2
+
3
+ /// What to do about a notification that has arrived (a push iOS handed the app, a pull, a geofence).
4
+ package enum PushHandling: Sendable, Equatable {
5
+ /// Show this notification (Bubbl's screen, or the app's own listener). `received`: it arrived
6
+ /// just now for the first time (notification.received was recorded), rather than being opened
7
+ /// again.
8
+ case show(JSONValue, received: Bool)
9
+ /// A dashboard test push: there's nothing behind it to show or report.
10
+ case test(title: String, body: String)
11
+ /// Nothing to do: not Bubbl's, a newer format, already shown, gone, or Bubbl isn't active.
12
+ case nothing
13
+ /// It came without its notification and fetching it failed: try again (`failure` says when).
14
+ case fetchFailed(campaignNotificationId: String, failure: ApiFailure)
15
+ }
16
+
17
+ extension EngineCore {
18
+ /// A push iOS handed the app: its top-level data (beside aps), when it arrived with the app in
19
+ /// front (`opened` false) or when its notification was tapped (`opened` true).
20
+ ///
21
+ /// Unlike Android, where the SDK draws every push, iOS shows the alert itself and the app
22
+ /// hears of it only then. The first time a notification arrives, notification.received is
23
+ /// recorded and it's to be shown; after that (a push and a pull, the same push in front and
24
+ /// then tapped) it's shown again only when tapped: the person asked for it.
25
+ package func receivedPush(_ data: [String: JSONValue], opened: Bool) async -> PushHandling {
26
+ guard let message = PushMessage.parse(data) else { return .nothing }
27
+
28
+ switch message {
29
+ case .test(let title, let body):
30
+ return .test(title: title, body: body)
31
+ case .unsupported:
32
+ return .nothing
33
+ case .full(_, let notification):
34
+ return await notificationArrived(notification, opened: opened)
35
+ case .reference(let id):
36
+ guard isActive else { return .nothing }
37
+ switch await notifications.fetch(id) {
38
+ case .found(let found):
39
+ guard let notification = found.first else { return .nothing }
40
+ return await notificationArrived(notification, opened: opened)
41
+ case .gone:
42
+ BubblLog.info("Notification \(id) is no longer available (deleted, or its campaign ended)")
43
+ return .nothing
44
+ case .failed(let failure):
45
+ return .fetchFailed(campaignNotificationId: id, failure: failure)
46
+ }
47
+ }
48
+ }
49
+
50
+ /// A notification that has arrived, however it came: recorded as received and to be shown the
51
+ /// first time; shown again only when `opened` (tapped). Nothing is shown or recorded without
52
+ /// consent, after an opt-out, or paused.
53
+ package func notificationArrived(_ notification: JSONValue, opened: Bool) async -> PushHandling {
54
+ guard isActive, let id = notification["campaign_notification_id"]?.nonEmptyString else { return .nothing }
55
+ let first = recentNotifications.firstTime(id)
56
+ if first {
57
+ _ = try? await events.enqueue("notification.received", data: ["campaign_notification_id": .string(id)])
58
+ }
59
+ return first || opened ? .show(notification, received: first) : .nothing
60
+ }
61
+ }