@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,148 @@
1
+ #if os(iOS)
2
+ import SwiftUI
3
+ import UIKit
4
+ import UserNotifications
5
+ #if !COCOAPODS
6
+ import BubblCore
7
+ #endif
8
+
9
+ /// Shows notifications as Android's NotificationPresenter does: Bubbl's screen straight away when
10
+ /// the app is in front (or the notification was tapped); otherwise a system notification whose tap
11
+ /// opens the screen. True when it's been taken care of, so the system banner isn't needed too.
12
+ @available(iOS 17, *)
13
+ struct ScreenPresenter: NotificationPresenting {
14
+ func present(_ notification: JSONValue, opened: Bool) async -> Bool {
15
+ guard let parsed = BubblNotification(notification) else { return false }
16
+ let inFront = await MainActor.run { UIApplication.shared.applicationState != .background }
17
+ if opened || inFront {
18
+ return await MainActor.run { NotificationScreens.shared.show(parsed, opened: opened) }
19
+ }
20
+ return await SystemNotifications.post(parsed)
21
+ }
22
+ }
23
+
24
+ /// Bubbl's screen, one notification at a time, in a window of its own over the app. Others that
25
+ /// arrive meanwhile wait their turn; one launched by a tap before the app has a scene waits for it.
26
+ @available(iOS 17, *)
27
+ @MainActor
28
+ final class NotificationScreens {
29
+ static let shared = NotificationScreens()
30
+
31
+ private var window: UIWindow?
32
+ private weak var previousKeyWindow: UIWindow?
33
+ private var showing: String?
34
+ private var waiting: [(notification: BubblNotification, opened: Bool)] = []
35
+ private var watchingScenes = false
36
+
37
+ func show(_ notification: BubblNotification, opened: Bool) -> Bool {
38
+ let id = notification.campaignNotificationId
39
+ guard showing != id, !waiting.contains(where: { $0.notification.campaignNotificationId == id }) else { return true }
40
+ guard window == nil, let scene = activeScene() else {
41
+ waiting.append((notification, opened))
42
+ watchScenes()
43
+ return true
44
+ }
45
+
46
+ let events = EngineHost.shared.current.map(EngineHost.shared.notificationEvents)
47
+ let model = NotificationModel(notification: notification, opened: opened, events: events) { [weak self] in
48
+ self?.closeCurrent()
49
+ }
50
+ let host = UIHostingController(rootView: NotificationScreen(model: model))
51
+ host.view.backgroundColor = .clear
52
+
53
+ let window = UIWindow(windowScene: scene)
54
+ window.windowLevel = .alert + 1
55
+ window.rootViewController = host
56
+ previousKeyWindow = scene.windows.first { $0.isKeyWindow }
57
+ window.makeKeyAndVisible()
58
+ self.window = window
59
+ showing = id
60
+ return true
61
+ }
62
+
63
+ private func closeCurrent() {
64
+ window?.isHidden = true
65
+ window = nil
66
+ showing = nil
67
+ previousKeyWindow?.makeKey()
68
+ showNext()
69
+ }
70
+
71
+ private func showNext() {
72
+ guard window == nil, !waiting.isEmpty, activeScene() != nil else { return }
73
+ let next = waiting.removeFirst()
74
+ _ = show(next.notification, opened: next.opened)
75
+ }
76
+
77
+ private func activeScene() -> UIWindowScene? {
78
+ let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
79
+ return scenes.first { $0.activationState == .foregroundActive } ?? scenes.first { $0.activationState == .foregroundInactive }
80
+ }
81
+
82
+ /// A notification waiting for the app to come to the front shows when it does.
83
+ private func watchScenes() {
84
+ guard !watchingScenes else { return }
85
+ watchingScenes = true
86
+ _ = NotificationCenter.default.addObserver(forName: UIScene.didActivateNotification, object: nil, queue: .main) { _ in
87
+ MainActor.assumeIsolated { NotificationScreens.shared.showNext() }
88
+ }
89
+ }
90
+ }
91
+
92
+ /// A notification that arrived with the app in the background (a geofence's): a system notification
93
+ /// carrying it, whose tap opens Bubbl's screen (it comes back as a Bubbl push, `opened`).
94
+ @available(iOS 17, *)
95
+ enum SystemNotifications {
96
+ static func post(_ notification: BubblNotification) async -> Bool {
97
+ let center = UNUserNotificationCenter.current()
98
+ let status = await PermissionFlow.notificationAuthorization()
99
+ guard status == .authorized || status == .provisional || status == .ephemeral else {
100
+ BubblLog.warning("A notification wasn't shown: the app is in the background and notifications aren't allowed")
101
+ return false
102
+ }
103
+
104
+ let content = UNMutableNotificationContent()
105
+ content.title = notification.headline
106
+ content.body = notification.body
107
+ content.sound = .default
108
+ content.userInfo = [
109
+ "bubbl": "1",
110
+ "bubbl_v": PushMessage.version,
111
+ "campaign_notification_id": notification.campaignNotificationId,
112
+ "notification": notification.jsonText,
113
+ ]
114
+ if let picture = await attachment(notification) { content.attachments = [picture] }
115
+
116
+ do {
117
+ try await center.add(UNNotificationRequest(identifier: "bubbl.\(notification.campaignNotificationId)", content: content, trigger: nil))
118
+ } catch {
119
+ BubblLog.warning("A notification couldn't be posted (\(type(of: error)))")
120
+ return false
121
+ }
122
+ BubblLog.debug("Notification posted (the app is in the background)")
123
+ if let core = EngineHost.shared.current {
124
+ try? await EngineHost.shared.notificationEvents(core).displayed(notification)
125
+ EngineHost.shared.submit("events") { await $0.flushEvents() }
126
+ }
127
+ return true
128
+ }
129
+
130
+ /// The notification's picture, downloaded for the system notification when it's quick (a
131
+ /// geofence's event has only seconds); none otherwise.
132
+ private static func attachment(_ notification: BubblNotification) async -> UNNotificationAttachment? {
133
+ guard let link = notification.media?.pictureUrl, link.lowercased().hasPrefix("https://"), let url = URL(string: link) else { return nil }
134
+ let configuration = URLSessionConfiguration.ephemeral
135
+ configuration.timeoutIntervalForResource = 5
136
+ let session = URLSession(configuration: configuration)
137
+ defer { session.finishTasksAndInvalidate() }
138
+ guard let downloaded = try? await session.download(from: url),
139
+ let http = downloaded.1 as? HTTPURLResponse, (200...299).contains(http.statusCode)
140
+ else { return nil }
141
+ let location = downloaded.0
142
+ let fileExtension = url.pathExtension.isEmpty ? "jpg" : url.pathExtension
143
+ let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString).appendingPathExtension(fileExtension)
144
+ guard (try? FileManager.default.moveItem(at: location, to: file)) != nil else { return nil }
145
+ return try? UNNotificationAttachment(identifier: "bubbl-image", url: file)
146
+ }
147
+ }
148
+ #endif
@@ -0,0 +1,169 @@
1
+ #if os(iOS)
2
+ import CoreLocation
3
+ import UIKit
4
+ import UserNotifications
5
+ #if !COCOAPODS
6
+ import BubblCore
7
+ #endif
8
+
9
+ /// Asking for the permissions Bubbl uses, from anywhere in the app: the privacy view first when the
10
+ /// dashboard says so, then the system's prompt, "Always" location in the two steps iOS expects
11
+ /// (PermissionPlanner). Where the device stands is reported to the server with the device.
12
+ @available(iOS 17, *)
13
+ @MainActor
14
+ final class PermissionFlow: NSObject, CLLocationManagerDelegate {
15
+ static let shared = PermissionFlow()
16
+
17
+ private let manager = CLLocationManager()
18
+ private var asking = false
19
+
20
+ override private init() {
21
+ super.init()
22
+ manager.delegate = self
23
+ }
24
+
25
+ /// Where the device stands now.
26
+ func status() async -> PermissionStatus {
27
+ let notifications: PermissionStatus.Notifications = switch await Self.notificationAuthorization() {
28
+ case .authorized, .provisional, .ephemeral: .granted
29
+ case .denied: .denied
30
+ default: .notDetermined
31
+ }
32
+ let location: PermissionStatus.Location = switch manager.authorizationStatus {
33
+ case .authorizedAlways: .always
34
+ case .authorizedWhenInUse: .whenInUse
35
+ case .denied, .restricted: .denied
36
+ default: .notDetermined
37
+ }
38
+ let status = PermissionStatus(notifications: notifications, location: location, preciseLocation: manager.accuracyAuthorization == .fullAccuracy)
39
+ EngineHost.shared.platform.permissionStatus = status
40
+ return status
41
+ }
42
+
43
+ /// Asks for `request` as the planner says, one request at a time. "Not now" on the privacy view,
44
+ /// or a refusal, ends it. Returns where the device stands afterwards.
45
+ func request(_ request: PermissionRequest) async -> PermissionStatus {
46
+ guard !asking else { return await status() }
47
+ asking = true
48
+ defer { asking = false }
49
+
50
+ let memory = PermissionMemory.shared
51
+ let config = EngineHost.shared.current?.configSync.current
52
+ let steps = PermissionPlanner.plan(
53
+ request,
54
+ status: await status(),
55
+ notice: config?.privacyNotice ?? "automatic",
56
+ explained: memory.explained,
57
+ askedAlways: memory.askedAlways
58
+ )
59
+
60
+ for step in steps {
61
+ switch step {
62
+ case .explain(let kind):
63
+ memory.markExplained(kind)
64
+ guard await PrivacyView.show(kind: kind, text: config?.privacyText, policy: config?.privacyUrl) else {
65
+ return await finished()
66
+ }
67
+ case .ask(.notifications):
68
+ let granted = (try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge])) ?? false
69
+ if !granted { return await finished() }
70
+ case .ask(.locationWhenInUse):
71
+ await prompt { manager.requestWhenInUseAuthorization() }
72
+ // Refused: "Always" can't follow.
73
+ if !(await status()).hasLocation { return await finished() }
74
+ case .ask(.locationAlways):
75
+ memory.markAskedAlways()
76
+ await prompt { manager.requestAlwaysAuthorization() }
77
+ }
78
+ }
79
+ return await finished()
80
+ }
81
+
82
+ /// Whether notifications are allowed (only the status crosses over: the settings object isn't
83
+ /// Sendable).
84
+ nonisolated static func notificationAuthorization() async -> UNAuthorizationStatus {
85
+ await withCheckedContinuation { continuation in
86
+ UNUserNotificationCenter.current().getNotificationSettings { settings in
87
+ continuation.resume(returning: settings.authorizationStatus)
88
+ }
89
+ }
90
+ }
91
+
92
+ /// Static: opening the app's settings page needs none of the flow's state (no location manager),
93
+ /// so it can run before `Bubbl.start`.
94
+ static func openSettings() {
95
+ guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
96
+ UIApplication.shared.open(url)
97
+ }
98
+
99
+ /// What asking changed: a device token once notifications are allowed, the server told, and
100
+ /// geofences checked with the location now allowed.
101
+ private func finished() async -> PermissionStatus {
102
+ let now = await status()
103
+ if now.notifications == .granted { UIApplication.shared.registerForRemoteNotifications() }
104
+ EngineHost.shared.submit("device") { await $0.syncDevice() }
105
+ if now.hasLocation { EngineHost.shared.checkGeofences(rewatch: true) }
106
+ return now
107
+ }
108
+
109
+ /// Shows a system location prompt and waits for its answer. iOS doesn't say when it decides not
110
+ /// to show one (asked before), so: a prompt makes the app inactive; if that hasn't happened
111
+ /// within a second, there wasn't one.
112
+ private func prompt(_ ask: () -> Void) async {
113
+ ask()
114
+ var shown = false
115
+ for _ in 0..<20 {
116
+ try? await Task.sleep(nanoseconds: 50_000_000)
117
+ if UIApplication.shared.applicationState != .active {
118
+ shown = true
119
+ break
120
+ }
121
+ }
122
+ guard shown else { return }
123
+ while UIApplication.shared.applicationState != .active {
124
+ try? await Task.sleep(nanoseconds: 200_000_000)
125
+ }
126
+ // The answer reaches CoreLocation just after the app is active again.
127
+ try? await Task.sleep(nanoseconds: 300_000_000)
128
+ }
129
+
130
+ nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
131
+ Task { @MainActor in _ = await PermissionFlow.shared.status() }
132
+ }
133
+ }
134
+
135
+ /// What Bubbl has explained (the privacy view, per kind) and whether it has asked for "Always"
136
+ /// (iOS shows that prompt once), kept between launches.
137
+ @available(iOS 17, *)
138
+ @MainActor
139
+ final class PermissionMemory {
140
+ static let shared = PermissionMemory()
141
+
142
+ private struct Saved: Codable {
143
+ var explained: [String] = []
144
+ var askedAlways = false
145
+ }
146
+
147
+ private let store: FileValueStore<Saved>?
148
+ private var saved: Saved
149
+
150
+ private init() {
151
+ store = EngineHost.directory().map { FileValueStore(url: $0.appendingPathComponent("permissions.json"), writeOptions: EngineHost.writeOptions) }
152
+ saved = ((try? store?.load()) ?? nil) ?? Saved()
153
+ }
154
+
155
+ var explained: Set<String> { Set(saved.explained) }
156
+ var askedAlways: Bool { saved.askedAlways }
157
+
158
+ func markExplained(_ kind: String) {
159
+ guard !saved.explained.contains(kind) else { return }
160
+ saved.explained.append(kind)
161
+ try? store?.save(saved)
162
+ }
163
+
164
+ func markAskedAlways() {
165
+ saved.askedAlways = true
166
+ try? store?.save(saved)
167
+ }
168
+ }
169
+ #endif
@@ -0,0 +1,110 @@
1
+ #if os(iOS)
2
+ import SwiftUI
3
+ import UIKit
4
+ #if !COCOAPODS
5
+ import BubblCore
6
+ #endif
7
+
8
+ /// The privacy view: why Bubbl is about to ask for a permission, shown before the system's prompt
9
+ /// (when the dashboard's notice mode says so), in the dashboard's words when it has set some, with
10
+ /// its privacy policy link. Continue goes on to the prompt; "Not now" ends the request.
11
+ @available(iOS 17, *)
12
+ @MainActor
13
+ enum PrivacyView {
14
+ private static var window: UIWindow?
15
+
16
+ /// True for Continue. With no screen to show it on, it goes straight on to the prompt.
17
+ static func show(kind: String, text: String?, policy: String?) async -> Bool {
18
+ let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
19
+ guard window == nil, let scene = scenes.first(where: { $0.activationState == .foregroundActive }) else { return true }
20
+
21
+ return await withCheckedContinuation { continuation in
22
+ let previousKey = scene.windows.first { $0.isKeyWindow }
23
+ let view = PrivacyExplanation(kind: kind, text: text, policy: policy) { accepted in
24
+ window?.isHidden = true
25
+ window = nil
26
+ previousKey?.makeKey()
27
+ continuation.resume(returning: accepted)
28
+ }
29
+ let host = UIHostingController(rootView: view)
30
+ host.view.backgroundColor = .clear
31
+ let created = UIWindow(windowScene: scene)
32
+ created.windowLevel = .alert + 1
33
+ created.rootViewController = host
34
+ created.makeKeyAndVisible()
35
+ window = created
36
+ }
37
+ }
38
+ }
39
+
40
+ @available(iOS 17, *)
41
+ struct PrivacyExplanation: View {
42
+ let kind: String
43
+ let text: String?
44
+ let policy: String?
45
+ let done: @MainActor (Bool) -> Void
46
+
47
+ private var title: String {
48
+ switch kind {
49
+ case "notifications": Words.text("bubbl_permission_notifications_title", "Stay in the loop")
50
+ case "background_location": Words.text("bubbl_permission_background_title", "Offers even when the app is closed")
51
+ default: Words.text("bubbl_permission_location_title", "Offers near you")
52
+ }
53
+ }
54
+
55
+ private var message: String {
56
+ switch kind {
57
+ case "notifications": Words.text("bubbl_permission_notifications_body", "Allow notifications to hear about offers and updates as they happen.")
58
+ case "background_location": Words.text("bubbl_permission_background_body", "To hear about places nearby while the app isn't open, choose \"Change to Always Allow\" when asked. You can change this at any time in Settings.")
59
+ default: Words.text("bubbl_permission_location_body", "Allow your location to get offers and messages when you're near the places they're for.")
60
+ }
61
+ }
62
+
63
+ var body: some View {
64
+ ZStack {
65
+ Look.scrim.ignoresSafeArea()
66
+ ScrollView {
67
+ VStack(alignment: .leading, spacing: 0) {
68
+ Text(title)
69
+ .font(.title3.weight(.bold))
70
+ .foregroundStyle(Look.onSurface)
71
+ .fixedSize(horizontal: false, vertical: true)
72
+ .accessibilityAddTraits(.isHeader)
73
+ Text(message)
74
+ .font(.subheadline)
75
+ .foregroundStyle(Look.onSurfaceMuted)
76
+ .padding(.top, 8)
77
+ .fixedSize(horizontal: false, vertical: true)
78
+ if let text, !text.isEmpty {
79
+ Text(text)
80
+ .font(.footnote)
81
+ .foregroundStyle(Look.onSurfaceMuted)
82
+ .padding(.top, 12)
83
+ .fixedSize(horizontal: false, vertical: true)
84
+ }
85
+ if let policy, LinkPolicy.canOpen(policy), let url = URL(string: policy) {
86
+ Link(Words.text("bubbl_privacy_policy", "Privacy policy"), destination: url)
87
+ .font(.subheadline)
88
+ .tint(Look.accent)
89
+ .padding(.top, 12)
90
+ }
91
+ PrimaryButton(title: Words.text("bubbl_continue", "Continue")) { done(true) }
92
+ .padding(.top, 20)
93
+ .accessibilityIdentifier("bubbl.continue")
94
+ SecondaryButton(title: Words.text("bubbl_not_now", "Not now")) { done(false) }
95
+ .padding(.top, 4)
96
+ .accessibilityIdentifier("bubbl.notNow")
97
+ }
98
+ .padding(Look.cardPadding)
99
+ .background(Look.surface)
100
+ .clipShape(RoundedRectangle(cornerRadius: Look.cardRadius, style: .continuous))
101
+ .frame(maxWidth: Look.cardMaxWidth)
102
+ .padding(.horizontal, 16)
103
+ .padding(.vertical, 24)
104
+ }
105
+ .frame(maxHeight: .infinity)
106
+ }
107
+ .accessibilityAddTraits(.isModal)
108
+ }
109
+ }
110
+ #endif
@@ -0,0 +1,95 @@
1
+ import Foundation
2
+ #if !COCOAPODS
3
+ import BubblCore
4
+ #endif
5
+
6
+ /// What the device says about itself (the contract's DeviceAttributes). Permissions and the push
7
+ /// token come with slices 9 and 11; until then the platform can't say.
8
+ @available(iOS 17, *)
9
+ final class AppleDevicePlatform: DevicePlatform, @unchecked Sendable {
10
+ private let lock = NSLock()
11
+ private var token: PushToken?
12
+ private var status: PermissionStatus?
13
+
14
+ func attributes() -> [String: JSONValue] {
15
+ let bundle = Bundle.main
16
+ let version = ProcessInfo.processInfo.operatingSystemVersion
17
+ let locale = Locale.current
18
+ var attributes: [String: JSONValue] = [
19
+ "platform": "ios",
20
+ "os_version": .string("\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)"),
21
+ "device_model": .string(String(Self.modelIdentifier.prefix(100))),
22
+ "manufacturer": "Apple",
23
+ "app_id": .string(bundle.bundleIdentifier ?? "unknown"),
24
+ "locale": .string(String(locale.identifier(.bcp47).prefix(35))),
25
+ "timezone": .string(TimeZone.current.identifier),
26
+ ]
27
+ attributes["app_version"] = (bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String).map { .string(String($0.prefix(50))) } ?? .null
28
+ attributes["country"] = locale.region.map { $0.identifier.count == 2 ? .string($0.identifier) : .null } ?? .null
29
+ return attributes
30
+ }
31
+
32
+ /// As last looked (PermissionFlow); nil until then.
33
+ func permissions() -> JSONValue? { lock.sync { status?.json } }
34
+
35
+ var permissionStatus: PermissionStatus? {
36
+ get { lock.sync { status } }
37
+ set { lock.sync { status = newValue } }
38
+ }
39
+
40
+ func pushToken() -> PushToken? { lock.sync { token } }
41
+
42
+ func setPushToken(_ token: PushToken?) {
43
+ lock.sync { self.token = token }
44
+ }
45
+
46
+ /// "iPhone16,2"; on the Simulator, the model it simulates.
47
+ static var modelIdentifier: String {
48
+ if let simulated = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"] { return simulated }
49
+ var system = utsname()
50
+ uname(&system)
51
+ return withUnsafeBytes(of: &system.machine) { buffer in
52
+ String(decoding: buffer.prefix { $0 != 0 }, as: UTF8.self)
53
+ }
54
+ }
55
+ }
56
+
57
+ @available(iOS 17, *)
58
+ extension JSONValue {
59
+ /// A value an app handed Bubbl (event properties, survey answers) as JSON, or nil when it
60
+ /// isn't text, a number, true/false, nil, or a list or dictionary of those. True and false
61
+ /// stay booleans: on Apple platforms they arrive as NSNumber, like numbers do.
62
+ init?(app value: Any?) {
63
+ switch value {
64
+ case nil, is NSNull:
65
+ self = .null
66
+ case let text as String:
67
+ self = .string(text)
68
+ case let number as NSNumber:
69
+ if CFGetTypeID(number) == CFBooleanGetTypeID() {
70
+ self = .bool(number.boolValue)
71
+ } else if CFNumberIsFloatType(number) {
72
+ guard number.doubleValue.isFinite else { return nil }
73
+ self = .double(number.doubleValue)
74
+ } else {
75
+ self = .int(number.int64Value)
76
+ }
77
+ case let list as [Any?]:
78
+ var items: [JSONValue] = []
79
+ for item in list {
80
+ guard let converted = JSONValue(app: item) else { return nil }
81
+ items.append(converted)
82
+ }
83
+ self = .array(items)
84
+ case let dictionary as [String: Any?]:
85
+ var object: [String: JSONValue] = [:]
86
+ for (key, item) in dictionary {
87
+ guard let converted = JSONValue(app: item) else { return nil }
88
+ object[key] = converted
89
+ }
90
+ self = .object(object)
91
+ default:
92
+ return nil
93
+ }
94
+ }
95
+ }
@@ -0,0 +1,114 @@
1
+ #if canImport(Security)
2
+ import Foundation
3
+ import Security
4
+ #if !COCOAPODS
5
+ import BubblCore
6
+ #endif
7
+
8
+ /// The install's credential in the Keychain: this device only, readable after the first unlock
9
+ /// (so a region event after a reboot, before the phone is unlocked, finds it locked rather than
10
+ /// missing, and the engine waits instead of registering again).
11
+ @available(iOS 17, *)
12
+ final class KeychainCredentialStore: CredentialStore, @unchecked Sendable {
13
+ private let service: String
14
+ private let account = "credential"
15
+ private let lock = NSLock()
16
+
17
+ private struct Stored: Codable {
18
+ let keyId: String
19
+ let secret: String
20
+ }
21
+
22
+ init(service: String = "tech.bubbl.sdk") {
23
+ self.service = service
24
+ }
25
+
26
+ func read() -> CredentialRead {
27
+ lock.sync {
28
+ var query = baseQuery
29
+ query[kSecReturnData as String] = true
30
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
31
+
32
+ var result: CFTypeRef?
33
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
34
+ switch status {
35
+ case errSecSuccess:
36
+ guard let data = result as? Data, let stored = try? JSONDecoder().decode(Stored.self, from: data) else {
37
+ // Unreadable content: as good as none (registering again replaces it).
38
+ return .missing
39
+ }
40
+ return .present(SigningCredential(keyId: stored.keyId, secret: stored.secret))
41
+ case errSecItemNotFound:
42
+ return .missing
43
+ default:
44
+ // errSecInteractionNotAllowed before the first unlock, and anything else the
45
+ // Keychain can't do right now: try again later, never "not registered".
46
+ report(status, "read")
47
+ return .unavailable
48
+ }
49
+ }
50
+ }
51
+
52
+ private var reportedMissingEntitlement = false
53
+
54
+ /// A Keychain failure, logged. Most pass (the device locked since a reboot): a debug line. An app
55
+ /// that can't use the Keychain at all (errSecMissingEntitlement: not code-signed, or signed with
56
+ /// no keychain access) never will, and all of Bubbl's work waits on it, so that's an error, once,
57
+ /// which diagnostics' lastError shows.
58
+ private func report(_ status: OSStatus, _ operation: String) {
59
+ guard status == errSecMissingEntitlement else {
60
+ BubblLog.debug("Keychain \(operation) unavailable (\(status))")
61
+ return
62
+ }
63
+ guard !reportedMissingEntitlement else { return }
64
+ reportedMissingEntitlement = true
65
+ BubblLog.error("The app can't use the Keychain (errSecMissingEntitlement): is it code-signed? Bubbl can't keep its credential, so nothing is sent")
66
+ }
67
+
68
+ func save(keyId: String, secret: String) throws {
69
+ let data = try JSONEncoder().encode(Stored(keyId: keyId, secret: secret))
70
+ try lock.sync {
71
+ let update: [String: Any] = [
72
+ kSecValueData as String: data,
73
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
74
+ ]
75
+ var status = SecItemUpdate(baseQuery as CFDictionary, update as CFDictionary)
76
+ if status == errSecItemNotFound {
77
+ var add = baseQuery
78
+ add.merge(update) { _, new in new }
79
+ status = SecItemAdd(add as CFDictionary, nil)
80
+ }
81
+ guard status == errSecSuccess else {
82
+ report(status, "save")
83
+ throw KeychainError(status: status)
84
+ }
85
+ }
86
+ }
87
+
88
+ func clear() throws {
89
+ try lock.sync {
90
+ let status = SecItemDelete(baseQuery as CFDictionary)
91
+ guard status == errSecSuccess || status == errSecItemNotFound else {
92
+ BubblLog.warning("The Keychain couldn't forget the credential (\(status))")
93
+ throw KeychainError(status: status)
94
+ }
95
+ }
96
+ }
97
+
98
+ private var baseQuery: [String: Any] {
99
+ [
100
+ kSecClass as String: kSecClassGenericPassword,
101
+ kSecAttrService as String: service,
102
+ kSecAttrAccount as String: account,
103
+ // The data-protection keychain on macOS too, so it behaves as on iOS.
104
+ kSecUseDataProtectionKeychain as String: true,
105
+ ]
106
+ }
107
+ }
108
+
109
+ @available(iOS 17, *)
110
+ struct KeychainError: Error, CustomStringConvertible {
111
+ let status: OSStatus
112
+ var description: String { "Keychain error \(status)" }
113
+ }
114
+ #endif