@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,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
|