@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,39 @@
1
+ /// A lock for async code: one caller at a time runs the body, the rest wait their turn (in
2
+ /// order) without blocking a thread. An actor alone isn't enough, since an actor lets other
3
+ /// calls in whenever the running one awaits; registering the install must not interleave.
4
+ package actor AsyncMutex {
5
+ private var locked = false
6
+ private var waiters: [CheckedContinuation<Void, Never>] = []
7
+
8
+ package init() {}
9
+
10
+ /// Runs `body` holding the lock, and releases it however `body` ends.
11
+ package nonisolated func withLock<T: Sendable>(_ body: @Sendable () async throws -> T) async rethrows -> T {
12
+ await lock()
13
+ do {
14
+ let result = try await body()
15
+ await unlock()
16
+ return result
17
+ } catch {
18
+ await unlock()
19
+ throw error
20
+ }
21
+ }
22
+
23
+ private func lock() async {
24
+ guard locked else {
25
+ locked = true
26
+ return
27
+ }
28
+ await withCheckedContinuation { waiters.append($0) }
29
+ }
30
+
31
+ private func unlock() {
32
+ if waiters.isEmpty {
33
+ locked = false
34
+ } else {
35
+ // The lock passes straight to the next waiter: it stays held.
36
+ waiters.removeFirst().resume()
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,52 @@
1
+ import Foundation
2
+
3
+ /// How much the SDK says (BubblOptions.logLevel), most important first.
4
+ package enum LogSeverity: Int, Sendable, Comparable, CaseIterable {
5
+ case error, warning, info, debug
6
+
7
+ package static func < (lhs: LogSeverity, rhs: LogSeverity) -> Bool { lhs.rawValue < rhs.rawValue }
8
+ }
9
+
10
+ /// The SDK's log, as Android's BubblLog: one place the core reports whatever it drops or holds
11
+ /// back (a question type a newer dashboard added, a refused event, a batch the server rejected),
12
+ /// so a developer can see why. Silent until the iOS layer points it at os.Logger and sets the
13
+ /// level BubblOptions asks for; a message below that level is never even built.
14
+ ///
15
+ /// **What a message may say.** It can end up in Console and sysdiagnose for anyone holding the
16
+ /// device, so: ids, types, counts, codes and statuses only. Never coordinates or fixes, push
17
+ /// tokens, the credential or its secret, survey answers, or the app's own event properties.
18
+ package enum BubblLog {
19
+ private static let lock = NSLock()
20
+ nonisolated(unsafe) private static var sink: @Sendable (LogSeverity, String) -> Void = { _, _ in }
21
+ nonisolated(unsafe) private static var level: LogSeverity = .warning
22
+ nonisolated(unsafe) private static var lastErrorMessage: String?
23
+
24
+ /// Where messages go from now on (the iOS layer, or a test), and from which level up.
25
+ package static func setSink(level: LogSeverity = .debug, _ sink: @escaping @Sendable (LogSeverity, String) -> Void) {
26
+ lock.sync {
27
+ self.sink = sink
28
+ self.level = level
29
+ }
30
+ }
31
+
32
+ /// The level messages are kept from (BubblOptions.logLevel).
33
+ package static func setLevel(_ level: LogSeverity) {
34
+ lock.sync { self.level = level }
35
+ }
36
+
37
+ /// The last error logged, for diagnostics (kept whatever the level).
38
+ package static var lastError: String? { lock.sync { lastErrorMessage } }
39
+
40
+ package static func error(_ message: @autoclosure () -> String) { log(.error, message) }
41
+ package static func warning(_ message: @autoclosure () -> String) { log(.warning, message) }
42
+ package static func info(_ message: @autoclosure () -> String) { log(.info, message) }
43
+ package static func debug(_ message: @autoclosure () -> String) { log(.debug, message) }
44
+
45
+ private static func log(_ level: LogSeverity, _ message: () -> String) {
46
+ let (sink, wanted) = lock.sync { (self.sink, level <= self.level) }
47
+ guard wanted || level == .error else { return }
48
+ let text = message()
49
+ if level == .error { lock.sync { lastErrorMessage = text } }
50
+ if wanted { sink(level, text) }
51
+ }
52
+ }
@@ -0,0 +1,31 @@
1
+ import Foundation
2
+
3
+ /// JSON as the device API speaks it, read loosely: a body that isn't the expected shape is nil
4
+ /// rather than an error, since a proxy's error page or an empty 304 must not throw.
5
+ package enum JSON {
6
+ /// The object in `text`, or nil when it isn't a JSON object.
7
+ package static func object(_ text: String) -> [String: Any]? {
8
+ guard let data = text.data(using: .utf8) else { return nil }
9
+ return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
10
+ }
11
+
12
+ /// A JSON number as Int64, however the platform's JSONSerialization boxed it (NSNumber on
13
+ /// Apple platforms, Swift numbers elsewhere); nil when it isn't a number.
14
+ package static func int64(_ value: Any?) -> Int64? {
15
+ switch value {
16
+ case let value as Int: Int64(value)
17
+ case let value as Int64: value
18
+ case let value as Double: Int64(exactly: value.rounded(.towardZero))
19
+ case let value as NSNumber: value.int64Value
20
+ default: nil
21
+ }
22
+ }
23
+
24
+ /// `value` (a dictionary or array of JSON values) as compact JSON text.
25
+ package static func string(_ value: Any) -> String {
26
+ guard JSONSerialization.isValidJSONObject(value),
27
+ let data = try? JSONSerialization.data(withJSONObject: value, options: [.sortedKeys])
28
+ else { return "{}" }
29
+ return String(decoding: data, as: UTF8.self)
30
+ }
31
+ }
@@ -0,0 +1,94 @@
1
+ /// Any JSON value, as Codable: for the parts of the device API that carry free-form JSON (an
2
+ /// event's `data`, a rejection's `errors`). JSONEncoder and JSONDecoder read and write it the same
3
+ /// way on every platform the core runs on, unlike JSONSerialization's boxed numbers.
4
+ package enum JSONValue: Sendable, Hashable, Codable {
5
+ case null
6
+ case bool(Bool)
7
+ case int(Int64)
8
+ case double(Double)
9
+ case string(String)
10
+ case array([JSONValue])
11
+ case object([String: JSONValue])
12
+
13
+ package init(from decoder: any Decoder) throws {
14
+ let container = try decoder.singleValueContainer()
15
+ if container.decodeNil() {
16
+ self = .null
17
+ } else if let value = try? container.decode(Bool.self) {
18
+ self = .bool(value)
19
+ } else if let value = try? container.decode(Int64.self) {
20
+ self = .int(value)
21
+ } else if let value = try? container.decode(Double.self) {
22
+ self = .double(value)
23
+ } else if let value = try? container.decode(String.self) {
24
+ self = .string(value)
25
+ } else if let value = try? container.decode([JSONValue].self) {
26
+ self = .array(value)
27
+ } else {
28
+ self = .object(try container.decode([String: JSONValue].self))
29
+ }
30
+ }
31
+
32
+ package func encode(to encoder: any Encoder) throws {
33
+ var container = encoder.singleValueContainer()
34
+ switch self {
35
+ case .null: try container.encodeNil()
36
+ case .bool(let value): try container.encode(value)
37
+ case .int(let value): try container.encode(value)
38
+ case .double(let value): try container.encode(value)
39
+ case .string(let value): try container.encode(value)
40
+ case .array(let value): try container.encode(value)
41
+ case .object(let value): try container.encode(value)
42
+ }
43
+ }
44
+
45
+ /// The value at `key`, when this is an object.
46
+ package subscript(key: String) -> JSONValue? {
47
+ if case .object(let object) = self { object[key] } else { nil }
48
+ }
49
+
50
+ package var stringValue: String? {
51
+ if case .string(let value) = self { value } else { nil }
52
+ }
53
+
54
+ /// A number as a whole Int64, never trapping: fractions are cut, and values beyond Int64
55
+ /// (1e30 from a server) are clamped. Nil for anything that isn't a finite number.
56
+ package var int64: Int64? {
57
+ switch self {
58
+ case .int(let value): return value
59
+ case .double(let value):
60
+ guard value.isFinite else { return nil }
61
+ let whole = value.rounded(.towardZero)
62
+ if let exact = Int64(exactly: whole) { return exact }
63
+ return whole > 0 ? .max : .min
64
+ default: return nil
65
+ }
66
+ }
67
+
68
+ /// int64, clamped to `range`: for a server number that sets a limit or an interval.
69
+ package func int64(in range: ClosedRange<Int64>) -> Int64? {
70
+ int64.map { min(max($0, range.lowerBound), range.upperBound) }
71
+ }
72
+
73
+ /// Whether every number in it is finite: JSON has no NaN or infinity, so JSONEncoder refuses
74
+ /// them (Android's JSONObject does too).
75
+ package var isEncodable: Bool {
76
+ switch self {
77
+ case .double(let value): value.isFinite
78
+ case .array(let values): values.allSatisfy(\.isEncodable)
79
+ case .object(let values): values.values.allSatisfy(\.isEncodable)
80
+ case .null, .bool, .int, .string: true
81
+ }
82
+ }
83
+ }
84
+
85
+ extension JSONValue: ExpressibleByStringLiteral, ExpressibleByIntegerLiteral, ExpressibleByBooleanLiteral,
86
+ ExpressibleByFloatLiteral, ExpressibleByDictionaryLiteral, ExpressibleByArrayLiteral, ExpressibleByNilLiteral {
87
+ package init(stringLiteral value: String) { self = .string(value) }
88
+ package init(integerLiteral value: Int64) { self = .int(value) }
89
+ package init(booleanLiteral value: Bool) { self = .bool(value) }
90
+ package init(floatLiteral value: Double) { self = .double(value) }
91
+ package init(dictionaryLiteral elements: (String, JSONValue)...) { self = .object(Dictionary(uniqueKeysWithValues: elements)) }
92
+ package init(arrayLiteral elements: JSONValue...) { self = .array(elements) }
93
+ package init(nilLiteral: ()) { self = .null }
94
+ }
@@ -0,0 +1,11 @@
1
+ import Foundation
2
+
3
+ extension NSLock {
4
+ /// Runs `body` holding the lock. (Foundation's own `withLock` isn't on every platform the
5
+ /// core builds for.)
6
+ package func sync<T>(_ body: () throws -> T) rethrows -> T {
7
+ lock()
8
+ defer { unlock() }
9
+ return try body()
10
+ }
11
+ }
@@ -0,0 +1,109 @@
1
+ import Foundation
2
+
3
+ /// A small piece of the engine's state kept between launches (what's been shown, the config, the
4
+ /// device's segments…). `load` is nil when nothing has been saved yet.
5
+ package protocol ValueStore<Value>: Sendable {
6
+ associatedtype Value: Codable & Sendable
7
+ func load() throws -> Value?
8
+ func save(_ value: Value) throws
9
+ func delete() throws
10
+ }
11
+
12
+ /// The file couldn't be read or written, so it did neither. Before the first unlock after a reboot
13
+ /// iOS keeps protected files closed; an unreadable file is never taken for "nothing saved".
14
+ package struct ValueStoreUnavailable: Error {
15
+ package let underlying: any Error
16
+ }
17
+
18
+ /// A value read once and then kept in memory, for state that's asked for often. Only a successful
19
+ /// read is kept: one that failed (the file locked) is tried again next time.
20
+ package final class Cached<Value: Sendable>: @unchecked Sendable {
21
+ private let lock = NSLock()
22
+ private var loaded = false
23
+ private var stored: Value?
24
+
25
+ package init() {}
26
+
27
+ /// The kept value, or `load`'s result (kept) the first time.
28
+ package func value(_ load: () throws -> Value?) rethrows -> Value? {
29
+ try lock.sync {
30
+ if loaded { return stored }
31
+ let value = try load()
32
+ stored = value
33
+ loaded = true
34
+ return value
35
+ }
36
+ }
37
+
38
+ package func set(_ value: Value?) {
39
+ lock.sync {
40
+ stored = value
41
+ loaded = true
42
+ }
43
+ }
44
+ }
45
+
46
+ /// A ValueStore in memory: for tests.
47
+ package final class InMemoryValueStore<Value: Codable & Sendable>: ValueStore, @unchecked Sendable {
48
+ private let lock = NSLock()
49
+ private var value: Value?
50
+
51
+ package init(_ value: Value? = nil) {
52
+ self.value = value
53
+ }
54
+
55
+ package func load() -> Value? { lock.sync { value } }
56
+ package func save(_ value: Value) { lock.sync { self.value = value } }
57
+ package func delete() { lock.sync { value = nil } }
58
+ }
59
+
60
+ /// A ValueStore as one JSON file, written atomically (on iOS, with file protection). A missing
61
+ /// file is nothing saved, and so is one that reads but doesn't decode (from an older build, or
62
+ /// corrupt); one that can't be read at all throws ValueStoreUnavailable.
63
+ package final class FileValueStore<Value: Codable & Sendable>: ValueStore, @unchecked Sendable {
64
+ private let lock = NSLock()
65
+ private let url: URL
66
+ private let writeOptions: Data.WritingOptions
67
+
68
+ /// - Parameter writeOptions: added to `.atomic`; on iOS, the file protection class.
69
+ package init(url: URL, writeOptions: Data.WritingOptions = []) {
70
+ self.url = url
71
+ self.writeOptions = writeOptions.union(.atomic)
72
+ }
73
+
74
+ package func load() throws -> Value? {
75
+ try lock.sync {
76
+ guard FileManager.default.fileExists(atPath: url.path) else { return nil }
77
+ let data: Data
78
+ do {
79
+ data = try Data(contentsOf: url)
80
+ } catch {
81
+ throw ValueStoreUnavailable(underlying: error)
82
+ }
83
+ return try? JSONDecoder().decode(Value.self, from: data)
84
+ }
85
+ }
86
+
87
+ package func save(_ value: Value) throws {
88
+ try lock.sync {
89
+ let data = try JSONEncoder().encode(value)
90
+ do {
91
+ try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
92
+ try data.write(to: url, options: writeOptions)
93
+ } catch {
94
+ throw ValueStoreUnavailable(underlying: error)
95
+ }
96
+ }
97
+ }
98
+
99
+ package func delete() throws {
100
+ try lock.sync {
101
+ guard FileManager.default.fileExists(atPath: url.path) else { return }
102
+ do {
103
+ try FileManager.default.removeItem(at: url)
104
+ } catch {
105
+ throw ValueStoreUnavailable(underlying: error)
106
+ }
107
+ }
108
+ }
109
+ }
@@ -0,0 +1,124 @@
1
+ #if os(iOS)
2
+ import Foundation
3
+ import UniformTypeIdentifiers
4
+ import UserNotifications
5
+ #if !COCOAPODS
6
+ import BubblCore
7
+ #endif
8
+
9
+ /// Adds the picture to Bubbl's pushes (optional). Add a Notification Service Extension target to
10
+ /// the app, add this package's BubblNotificationService product to it, and make the extension's
11
+ /// principal class a subclass:
12
+ ///
13
+ /// import BubblNotificationService
14
+ /// class NotificationService: BubblNotificationService {}
15
+ ///
16
+ /// The push's image_url is downloaded (https only, at most 10 MB, within the time iOS gives) and
17
+ /// attached; anything that goes wrong shows the push as it came. Other pushes pass through
18
+ /// untouched (and all of them below iOS 17, where Bubbl does nothing): an app with its own
19
+ /// extension work can override `didReceive` and call `super` only for pushes where `isBubbl(_:)`
20
+ /// is true.
21
+ ///
22
+ /// Nothing is recorded here: the extension is a separate process with no access to Bubbl's
23
+ /// state, and it doesn't need any.
24
+ open class BubblNotificationService: UNNotificationServiceExtension, @unchecked Sendable {
25
+ private static let maxImageBytes = 10 * 1024 * 1024
26
+
27
+ private let lock = NSLock()
28
+ private var deliver: ((UNNotificationContent) -> Void)?
29
+ private var content: UNMutableNotificationContent?
30
+ private var download: URLSessionDownloadTask?
31
+
32
+ /// How long the picture may take to download. iOS gives the extension about 30 s in all.
33
+ open var imageTimeout: TimeInterval { 20 }
34
+
35
+ /// Whether a push is Bubbl's.
36
+ public static func isBubbl(_ request: UNNotificationRequest) -> Bool {
37
+ PushMessage.isBubbl(data(request.content.userInfo))
38
+ }
39
+
40
+ open override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
41
+ // Below iOS 17 Bubbl does nothing: pushes pass through as they came.
42
+ guard #available(iOS 17, *) else {
43
+ contentHandler(request.content)
44
+ return
45
+ }
46
+ guard let imageUrl = PushMessage.imageUrl(Self.data(request.content.userInfo)),
47
+ let url = URL(string: imageUrl),
48
+ let content = request.content.mutableCopy() as? UNMutableNotificationContent
49
+ else {
50
+ contentHandler(request.content)
51
+ return
52
+ }
53
+
54
+ let configuration = URLSessionConfiguration.ephemeral
55
+ configuration.timeoutIntervalForResource = imageTimeout
56
+ let session = URLSession(configuration: configuration)
57
+ let task = session.downloadTask(with: url) { [weak self] location, response, _ in
58
+ self?.attach(location, response)
59
+ self?.finish()
60
+ }
61
+ lock.sync {
62
+ deliver = contentHandler
63
+ self.content = content
64
+ download = task
65
+ }
66
+ task.resume()
67
+ session.finishTasksAndInvalidate()
68
+ }
69
+
70
+ /// iOS is about to end the extension: show the push with whatever is ready.
71
+ open override func serviceExtensionTimeWillExpire() {
72
+ lock.sync { download }?.cancel()
73
+ finish()
74
+ }
75
+
76
+ @available(iOS 17, *)
77
+ private func attach(_ location: URL?, _ response: URLResponse?) {
78
+ guard let location, let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode),
79
+ let size = try? location.resourceValues(forKeys: [.fileSizeKey]).fileSize, size > 0, size <= Self.maxImageBytes
80
+ else { return }
81
+
82
+ // iOS tells the picture's kind from the file's extension: from the MIME type, else the URL.
83
+ let fromMime = http.mimeType.flatMap { UTType(mimeType: $0)?.preferredFilenameExtension }
84
+ guard let fileExtension = fromMime ?? http.url.map(\.pathExtension).flatMap({ $0.isEmpty ? nil : $0 }) else { return }
85
+
86
+ let file = FileManager.default.temporaryDirectory
87
+ .appendingPathComponent(UUID().uuidString)
88
+ .appendingPathExtension(fileExtension)
89
+ do {
90
+ try FileManager.default.moveItem(at: location, to: file)
91
+ let attachment = try UNNotificationAttachment(identifier: "bubbl-image", url: file)
92
+ lock.sync { content?.attachments = [attachment] }
93
+ } catch {
94
+ // Not a picture iOS can show: the push goes as it came.
95
+ }
96
+ }
97
+
98
+ /// Hands iOS the content, once (the download and the time running out may both get here).
99
+ private func finish() {
100
+ let (deliver, content) = lock.sync {
101
+ defer { self.deliver = nil }
102
+ return (self.deliver, self.content)
103
+ }
104
+ if let deliver, let content { deliver(content) }
105
+ }
106
+
107
+ /// The push's top-level data as JSON: Bubbl's keys are strings (or 1/true for the flags).
108
+ private static func data(_ userInfo: [AnyHashable: Any]) -> [String: JSONValue] {
109
+ var data: [String: JSONValue] = [:]
110
+ for (key, value) in userInfo {
111
+ guard let key = key as? String else { continue }
112
+ switch value {
113
+ case let text as String:
114
+ data[key] = .string(text)
115
+ case let number as NSNumber:
116
+ data[key] = CFGetTypeID(number) == CFBooleanGetTypeID() ? .bool(number.boolValue) : .int(number.int64Value)
117
+ default:
118
+ continue
119
+ }
120
+ }
121
+ return data
122
+ }
123
+ }
124
+ #endif
@@ -0,0 +1,90 @@
1
+ import Foundation
2
+
3
+ /// A device API v1 response: its status, body and headers, and — for an error — what the engine
4
+ /// should do about it (`handling`, from the contract's error table).
5
+ package struct ApiResponse: Sendable {
6
+ package let status: Int
7
+ package let body: String
8
+ private let raw: HttpResponse
9
+ private let forcedHandling: ErrorHandling?
10
+
11
+ package init(_ raw: HttpResponse, handledAs forcedHandling: ErrorHandling? = nil) {
12
+ status = raw.status
13
+ body = raw.body
14
+ self.raw = raw
15
+ self.forcedHandling = forcedHandling
16
+ }
17
+
18
+ package var isSuccessful: Bool { (200...299).contains(status) || status == 304 }
19
+
20
+ /// The body as a JSON object, or nil when it isn't one (a 304, or a proxy's error page).
21
+ package var json: [String: Any]? { JSON.object(body) }
22
+
23
+ /// The body decoded as `T`, or nil when it isn't one. Codable reads the same on every
24
+ /// platform, so parsing goes through here rather than `json`.
25
+ package func decode<T: Decodable>(_ type: T.Type) -> T? {
26
+ try? JSONDecoder().decode(type, from: Data(body.utf8))
27
+ }
28
+
29
+ /// The error's stable `code`, when the body has one.
30
+ package var code: String? {
31
+ (json?["code"] as? String).flatMap { $0.isEmpty ? nil : $0 }
32
+ }
33
+
34
+ /// What to do about a failed response; nil for a success.
35
+ package var handling: ErrorHandling? {
36
+ isSuccessful ? nil : forcedHandling ?? ErrorActions.forResponse(status: status, code: code)
37
+ }
38
+
39
+ /// The same response, handled as `handling` says instead of by its code.
40
+ package func handled(as handling: ErrorHandling) -> ApiResponse {
41
+ ApiResponse(raw, handledAs: handling)
42
+ }
43
+
44
+ package func header(_ name: String) -> String? { raw.header(name) }
45
+ }
46
+
47
+ /// Why a call to the device API didn't get through, reduced to what whoever scheduled it (a
48
+ /// background task, an app resume) has to do next. Built from the contract's error table.
49
+ package enum ApiFailure: Sendable, Equatable {
50
+ /// Try again later with exponential backoff (5xx, no network).
51
+ case backoff
52
+ /// The server asked for a wait (429, request_in_progress).
53
+ case retryAfter(seconds: Int)
54
+ /// Stop calling the API for a while (workspace paused, free tier); keep queuing.
55
+ case pause(hours: Int, code: String?)
56
+ /// The server doesn't know this device yet: PUT /device, then try again.
57
+ case describeDevice
58
+ /// The request can never succeed as sent: discard it (and refetch geofences, if said).
59
+ case drop(refreshGeofences: Bool, status: Int, code: String?)
60
+ /// Something only a developer can fix (misconfiguration): surface it in diagnostics.
61
+ case stopped(status: Int, code: String?)
62
+
63
+ private static let defaultRetrySeconds = 30
64
+ private static let defaultPauseHours = 1
65
+
66
+ /// What a failed `response` means for the caller. Never called with a success.
67
+ package static func of(_ response: ApiResponse) -> ApiFailure {
68
+ guard let handling = response.handling else { return .stopped(status: response.status, code: response.code) }
69
+
70
+ switch handling.action {
71
+ case .backoff:
72
+ return .backoff
73
+ case .waitAndRetry:
74
+ let seconds = response.header("Retry-After").flatMap { Int($0.trimmingCharacters(in: .whitespaces)) }
75
+ return .retryAfter(seconds: max(seconds ?? defaultRetrySeconds, 1))
76
+ case .pause:
77
+ return .pause(hours: handling.pauseHours ?? defaultPauseHours, code: response.code)
78
+ case .describeDevice:
79
+ return .describeDevice
80
+ case .drop:
81
+ return .drop(refreshGeofences: false, status: response.status, code: response.code)
82
+ case .dropAndRefreshGeofences:
83
+ return .drop(refreshGeofences: true, status: response.status, code: response.code)
84
+ // The client has already registered again or corrected the clock once; what's left of
85
+ // those, and everything else, needs a person.
86
+ case .reRegister, .correctClockAndRetry, .stopAndReport, .showMessage:
87
+ return .stopped(status: response.status, code: response.code)
88
+ }
89
+ }
90
+ }
@@ -0,0 +1,116 @@
1
+ import Foundation
2
+
3
+ /// The install's credential, read together so a registration in between can't mix old and new.
4
+ package struct SigningCredential: Sendable, Equatable {
5
+ package let keyId: String
6
+ package let secret: String
7
+
8
+ package init(keyId: String, secret: String) {
9
+ self.keyId = keyId
10
+ self.secret = secret
11
+ }
12
+ }
13
+
14
+ /// What reading the credential found. `unavailable` is not `missing`: before the first unlock
15
+ /// after a reboot iOS can relaunch the app (for a region event) while the Keychain can't be read
16
+ /// yet. Taking that for "not registered" would register again and replace a working credential.
17
+ package enum CredentialRead: Sendable, Equatable {
18
+ case present(SigningCredential)
19
+ case missing
20
+ case unavailable
21
+ }
22
+
23
+ /// Where the install's signing credential lives: the Keychain on a device
24
+ /// (AfterFirstUnlockThisDeviceOnly), memory in tests.
25
+ package protocol CredentialStore: Sendable {
26
+ /// The key id and secret, read together.
27
+ func read() -> CredentialRead
28
+
29
+ /// Keep a new credential, replacing any old one. Throws when it couldn't be kept (the Keychain
30
+ /// refused it): the registration it came from then doesn't count.
31
+ func save(keyId: String, secret: String) throws
32
+
33
+ /// Forget the credential (deleteMyData, or an earlier installation's). Throws when it couldn't
34
+ /// be forgotten (the Keychain refused), so nothing claims the device's data is gone when it isn't.
35
+ func clear() throws
36
+ }
37
+
38
+ /// A credential kept only while this install's id is. On iOS the Keychain outlives the app and its
39
+ /// files don't: after a reinstall the old install's credential would sign for a new install id.
40
+ /// A credential with no install id beside it (none saved, not merely unreadable) belongs to an
41
+ /// earlier installation, so it's forgotten and this install registers as itself, as on Android,
42
+ /// where uninstalling wipes the Keystore. Checked once per process: from then on a credential is
43
+ /// only saved by a registration, which makes the install id first.
44
+ package final class InstallBoundCredentialStore: CredentialStore, @unchecked Sendable {
45
+ private let base: any CredentialStore
46
+ private let installId: any ValueStore<String>
47
+ private let lock = NSLock()
48
+ private var checked = false
49
+
50
+ package init(_ base: any CredentialStore, installId: any ValueStore<String>) {
51
+ self.base = base
52
+ self.installId = installId
53
+ }
54
+
55
+ package func read() -> CredentialRead {
56
+ lock.sync {
57
+ let read = base.read()
58
+ guard !checked, case .present = read else {
59
+ if read == .missing { checked = true }
60
+ return read
61
+ }
62
+ let id: String?
63
+ do {
64
+ id = try installId.load()
65
+ } catch {
66
+ return .unavailable
67
+ }
68
+ if let id, !id.isEmpty {
69
+ checked = true
70
+ return read
71
+ }
72
+ do {
73
+ try base.clear()
74
+ } catch {
75
+ // Still there: not this install's, and not gone either. Asked again next time.
76
+ return .unavailable
77
+ }
78
+ checked = true
79
+ BubblLog.info("A credential left by an earlier installation of the app was forgotten")
80
+ return .missing
81
+ }
82
+ }
83
+
84
+ package func save(keyId: String, secret: String) throws {
85
+ try base.save(keyId: keyId, secret: secret)
86
+ }
87
+
88
+ package func clear() throws {
89
+ try base.clear()
90
+ }
91
+ }
92
+
93
+ /// A CredentialStore in memory: for tests, and for nothing else.
94
+ package final class InMemoryCredentialStore: CredentialStore, @unchecked Sendable {
95
+ private let lock = NSLock()
96
+ private var credential: SigningCredential?
97
+
98
+ package init() {}
99
+
100
+ /// The current key id, for tests.
101
+ package func keyId() -> String? {
102
+ lock.sync { credential?.keyId }
103
+ }
104
+
105
+ package func read() -> CredentialRead {
106
+ lock.sync { credential.map(CredentialRead.present) ?? .missing }
107
+ }
108
+
109
+ package func save(keyId: String, secret: String) throws {
110
+ lock.sync { credential = SigningCredential(keyId: keyId, secret: secret) }
111
+ }
112
+
113
+ package func clear() throws {
114
+ lock.sync { credential = nil }
115
+ }
116
+ }