@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.
- package/BubblReactNativeSdk.podspec +29 -9
- package/CHANGELOG.md +52 -1
- package/LICENSE +202 -1
- package/README.md +172 -83
- package/android/build.gradle +33 -30
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/tech/bubbl/reactnative/BubblJson.kt +135 -0
- package/android/src/main/java/tech/bubbl/reactnative/BubblSdkModule.kt +277 -0
- package/android/src/main/java/tech/bubbl/reactnative/BubblSdkPackage.kt +26 -0
- package/app.plugin.js +2 -0
- package/ios/BubblPush.swift +44 -0
- package/ios/BubblSdk.h +7 -0
- package/ios/BubblSdk.mm +252 -0
- package/ios/BubblSdkImpl.swift +335 -0
- package/ios-extension/BubblReactNativeNotificationService.podspec +44 -0
- package/ios-extension/Sources/BubblCore/API/ApiResponse.swift +90 -0
- package/ios-extension/Sources/BubblCore/API/CredentialStore.swift +116 -0
- package/ios-extension/Sources/BubblCore/API/DeviceApiClient.swift +239 -0
- package/ios-extension/Sources/BubblCore/API/ErrorActions.swift +68 -0
- package/ios-extension/Sources/BubblCore/API/Http.swift +39 -0
- package/ios-extension/Sources/BubblCore/API/ServerClock.swift +69 -0
- package/ios-extension/Sources/BubblCore/BubblVersion.swift +5 -0
- package/ios-extension/Sources/BubblCore/Device/ConfigSync.swift +216 -0
- package/ios-extension/Sources/BubblCore/Device/DeviceSync.swift +160 -0
- package/ios-extension/Sources/BubblCore/Device/ProvisioningProfile.swift +24 -0
- package/ios-extension/Sources/BubblCore/Engine/EngineCore.swift +523 -0
- package/ios-extension/Sources/BubblCore/Engine/Geofencing.swift +170 -0
- package/ios-extension/Sources/BubblCore/Engine/Permissions.swift +90 -0
- package/ios-extension/Sources/BubblCore/Engine/Privacy.swift +64 -0
- package/ios-extension/Sources/BubblCore/Engine/PrivacyControls.swift +184 -0
- package/ios-extension/Sources/BubblCore/Engine/Pushes.swift +61 -0
- package/ios-extension/Sources/BubblCore/Engine/Sandbox.swift +149 -0
- package/ios-extension/Sources/BubblCore/Events/EventQueue.swift +207 -0
- package/ios-extension/Sources/BubblCore/Events/EventStore.swift +159 -0
- package/ios-extension/Sources/BubblCore/Geofence/Geofence.swift +160 -0
- package/ios-extension/Sources/BubblCore/Geofence/GeofenceEngine.swift +488 -0
- package/ios-extension/Sources/BubblCore/Geofence/GeofenceState.swift +103 -0
- package/ios-extension/Sources/BubblCore/Geofence/RegionBudget.swift +62 -0
- package/ios-extension/Sources/BubblCore/Notification/BubblNotification.swift +137 -0
- package/ios-extension/Sources/BubblCore/Notification/NotificationEvents.swift +160 -0
- package/ios-extension/Sources/BubblCore/Notification/Push.swift +134 -0
- package/ios-extension/Sources/BubblCore/Notification/SurveyForm.swift +185 -0
- package/ios-extension/Sources/BubblCore/RequestSigner.swift +56 -0
- package/ios-extension/Sources/BubblCore/Support/AsyncMutex.swift +39 -0
- package/ios-extension/Sources/BubblCore/Support/BubblLog.swift +52 -0
- package/ios-extension/Sources/BubblCore/Support/JSON.swift +31 -0
- package/ios-extension/Sources/BubblCore/Support/JSONValue.swift +94 -0
- package/ios-extension/Sources/BubblCore/Support/Locking.swift +11 -0
- package/ios-extension/Sources/BubblCore/Support/ValueStore.swift +109 -0
- package/ios-extension/Sources/BubblNotificationService/BubblNotificationService.swift +124 -0
- package/ios-sdk/BubblCore/API/ApiResponse.swift +90 -0
- package/ios-sdk/BubblCore/API/CredentialStore.swift +116 -0
- package/ios-sdk/BubblCore/API/DeviceApiClient.swift +239 -0
- package/ios-sdk/BubblCore/API/ErrorActions.swift +68 -0
- package/ios-sdk/BubblCore/API/Http.swift +39 -0
- package/ios-sdk/BubblCore/API/ServerClock.swift +69 -0
- package/ios-sdk/BubblCore/BubblVersion.swift +5 -0
- package/ios-sdk/BubblCore/Device/ConfigSync.swift +216 -0
- package/ios-sdk/BubblCore/Device/DeviceSync.swift +160 -0
- package/ios-sdk/BubblCore/Device/ProvisioningProfile.swift +24 -0
- package/ios-sdk/BubblCore/Engine/EngineCore.swift +523 -0
- package/ios-sdk/BubblCore/Engine/Geofencing.swift +170 -0
- package/ios-sdk/BubblCore/Engine/Permissions.swift +90 -0
- package/ios-sdk/BubblCore/Engine/Privacy.swift +64 -0
- package/ios-sdk/BubblCore/Engine/PrivacyControls.swift +184 -0
- package/ios-sdk/BubblCore/Engine/Pushes.swift +61 -0
- package/ios-sdk/BubblCore/Engine/Sandbox.swift +149 -0
- package/ios-sdk/BubblCore/Events/EventQueue.swift +207 -0
- package/ios-sdk/BubblCore/Events/EventStore.swift +159 -0
- package/ios-sdk/BubblCore/Geofence/Geofence.swift +160 -0
- package/ios-sdk/BubblCore/Geofence/GeofenceEngine.swift +488 -0
- package/ios-sdk/BubblCore/Geofence/GeofenceState.swift +103 -0
- package/ios-sdk/BubblCore/Geofence/RegionBudget.swift +62 -0
- package/ios-sdk/BubblCore/Notification/BubblNotification.swift +137 -0
- package/ios-sdk/BubblCore/Notification/NotificationEvents.swift +160 -0
- package/ios-sdk/BubblCore/Notification/Push.swift +134 -0
- package/ios-sdk/BubblCore/Notification/SurveyForm.swift +185 -0
- package/ios-sdk/BubblCore/RequestSigner.swift +56 -0
- package/ios-sdk/BubblCore/Support/AsyncMutex.swift +39 -0
- package/ios-sdk/BubblCore/Support/BubblLog.swift +52 -0
- package/ios-sdk/BubblCore/Support/JSON.swift +31 -0
- package/ios-sdk/BubblCore/Support/JSONValue.swift +94 -0
- package/ios-sdk/BubblCore/Support/Locking.swift +11 -0
- package/ios-sdk/BubblCore/Support/ValueStore.swift +109 -0
- package/ios-sdk/BubblLaunch/BubblLaunch.m +45 -0
- package/ios-sdk/BubblLaunch/include/BubblLaunch.h +5 -0
- package/ios-sdk/BubblSDK/Bubbl.swift +308 -0
- package/ios-sdk/BubblSDK/BubblBackend.swift +118 -0
- package/ios-sdk/BubblSDK/BubblCredential.swift +30 -0
- package/ios-sdk/BubblSDK/BubblMessage.swift +151 -0
- package/ios-sdk/BubblSDK/BubblOptions.swift +39 -0
- package/ios-sdk/BubblSDK/BubblPermissions.swift +79 -0
- package/ios-sdk/BubblSDK/BubblTestDeviceResult.swift +10 -0
- package/ios-sdk/BubblSDK/Engine/BackgroundRefresh.swift +92 -0
- package/ios-sdk/BubblSDK/Engine/EngineHost+Geofences.swift +177 -0
- package/ios-sdk/BubblSDK/Engine/EngineHost+Privacy.swift +65 -0
- package/ios-sdk/BubblSDK/Engine/EngineHost+Push.swift +154 -0
- package/ios-sdk/BubblSDK/Engine/EngineHost.swift +356 -0
- package/ios-sdk/BubblSDK/Engine/SupportedBubbl.swift +320 -0
- package/ios-sdk/BubblSDK/Engine/SystemLog.swift +27 -0
- package/ios-sdk/BubblSDK/Engine/WorkRunner.swift +107 -0
- package/ios-sdk/BubblSDK/Notification/Look.swift +78 -0
- package/ios-sdk/BubblSDK/Notification/NotificationMedia.swift +329 -0
- package/ios-sdk/BubblSDK/Notification/NotificationModel.swift +172 -0
- package/ios-sdk/BubblSDK/Notification/NotificationScreen.swift +428 -0
- package/ios-sdk/BubblSDK/Notification/ScreenPresenter.swift +148 -0
- package/ios-sdk/BubblSDK/Permissions/PermissionFlow.swift +169 -0
- package/ios-sdk/BubblSDK/Permissions/PrivacyView.swift +110 -0
- package/ios-sdk/BubblSDK/Platform/AppleDevicePlatform.swift +95 -0
- package/ios-sdk/BubblSDK/Platform/KeychainCredentialStore.swift +114 -0
- package/ios-sdk/BubblSDK/Platform/LocationService.swift +418 -0
- package/ios-sdk/BubblSDK/Platform/PushIntegration.swift +288 -0
- package/ios-sdk/BubblSDK/Platform/URLSessionHttpClient.swift +69 -0
- package/ios-sdk/BubblSDK/PrivacyInfo.xcprivacy +76 -0
- package/jest/index.js +75 -0
- package/lib/commonjs/BubblModule.js +14 -0
- package/lib/commonjs/BubblModule.js.map +1 -0
- package/lib/commonjs/BubblModule.web.js +13 -0
- package/lib/commonjs/BubblModule.web.js.map +1 -0
- package/lib/commonjs/NativeBubblSdk.js +20 -0
- package/lib/commonjs/NativeBubblSdk.js.map +1 -0
- package/lib/commonjs/index.js +336 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/types.js +2 -0
- package/lib/commonjs/types.js.map +1 -0
- package/lib/module/BubblModule.js +5 -0
- package/lib/module/BubblModule.js.map +1 -0
- package/lib/module/BubblModule.web.js +9 -0
- package/lib/module/BubblModule.web.js.map +1 -0
- package/lib/module/NativeBubblSdk.js +18 -0
- package/lib/module/NativeBubblSdk.js.map +1 -0
- package/lib/module/index.js +331 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/commonjs/package.json +1 -0
- package/lib/typescript/commonjs/src/BubblModule.d.ts +3 -0
- package/lib/typescript/commonjs/src/BubblModule.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/BubblModule.web.d.ts +8 -0
- package/lib/typescript/commonjs/src/BubblModule.web.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/NativeBubblSdk.d.ts +59 -0
- package/lib/typescript/commonjs/src/NativeBubblSdk.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/index.d.ts +111 -0
- package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/types.d.ts +192 -0
- package/lib/typescript/commonjs/src/types.d.ts.map +1 -0
- package/lib/typescript/module/package.json +1 -0
- package/lib/typescript/module/src/BubblModule.d.ts +3 -0
- package/lib/typescript/module/src/BubblModule.d.ts.map +1 -0
- package/lib/typescript/module/src/BubblModule.web.d.ts +8 -0
- package/lib/typescript/module/src/BubblModule.web.d.ts.map +1 -0
- package/lib/typescript/module/src/NativeBubblSdk.d.ts +59 -0
- package/lib/typescript/module/src/NativeBubblSdk.d.ts.map +1 -0
- package/lib/typescript/module/src/index.d.ts +111 -0
- package/lib/typescript/module/src/index.d.ts.map +1 -0
- package/lib/typescript/module/src/types.d.ts +192 -0
- package/lib/typescript/module/src/types.d.ts.map +1 -0
- package/package.json +105 -24
- package/plugin/build/imageNotifications.d.ts +35 -0
- package/plugin/build/imageNotifications.js +198 -0
- package/plugin/build/index.d.ts +84 -0
- package/plugin/build/index.js +176 -0
- package/src/BubblModule.ts +2 -0
- package/src/BubblModule.web.ts +9 -0
- package/src/NativeBubblSdk.ts +67 -0
- package/src/index.ts +338 -282
- package/src/types.ts +182 -0
- package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkModule.kt +0 -680
- package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkNotificationIntents.kt +0 -32
- package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkPackage.kt +0 -14
- package/ios/BubblSdk.swift +0 -802
- package/ios/BubblSdkBridge.m +0 -86
- package/react-native.config.js +0 -11
- 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
|
+
}
|