Files
Sybil-2/ios/Packages/Sybil/Sources/Sybil/SybilNotificationPlatform.swift
T

192 lines
8.7 KiB
Swift

import Foundation
import UIKit
import UserNotifications
@MainActor
final class SybilNotificationPlatform: AgentNotificationPlatform {
static let shared = SybilNotificationPlatform()
private var center: UNUserNotificationCenter { .current() }
// UIKit's callback has no request ID. Keep an outstanding request tied to its
// original generation until it drains; queue the next session behind it.
private var outstanding: UUID?
private var queued: UUID?
private var registeredGeneration: UUID?
var environment: AgentAPNSEnvironment? {
#if targetEnvironment(simulator)
return isFixture ? .sandbox : nil
#elseif targetEnvironment(macCatalyst)
return nil
#else
return AgentAPNSEnvironment(entitlementSetting: Bundle.main.object(forInfoDictionaryKey: "SybilAPNSEnvironment") as? String)
#endif
}
var allowsLoopback: Bool { isFixture }
var isFixture: Bool {
#if DEBUG && targetEnvironment(simulator)
return ProcessInfo.processInfo.environment["SYBIL_PUSH_FIXTURE"] == "1"
#else
return false
#endif
}
private var fixturePermission: AgentNotificationPermission = .notDetermined
func permission() async -> AgentNotificationPermission {
if isFixture {
if ProcessInfo.processInfo.environment["SYBIL_PUSH_PERMISSION"] == "authorized" { return .authorized }
if ProcessInfo.processInfo.environment["SYBIL_PUSH_PERMISSION"] == "denied" { return .denied }
return fixturePermission
}
guard environment != nil else { return .notDetermined }
switch await center.notificationSettings().authorizationStatus {
case .authorized, .ephemeral: return .authorized
case .provisional: return .provisional
case .denied: return .denied
default: return .notDetermined
}
}
func requestPermission() async throws -> Bool {
if isFixture {
fixturePermission = ProcessInfo.processInfo.environment["SYBIL_PUSH_DENIED"] == "1" ? .denied : .authorized
return fixturePermission == .authorized
}
guard environment != nil else { return false }
return try await center.requestAuthorization(options: [.alert, .sound, .badge])
}
func register(generation: UUID) {
guard environment != nil else { return }
if let outstanding {
if outstanding != generation { queued = generation }
return
}
outstanding = generation
if isFixture {
// Deliberately synthetic bytes; never call Apple's registrar in a simulator.
Task { didRegister(Data([0x00, 0x01, 0x0a, 0xff] + Array(repeating: 0x42, count: 28))) }
} else {
#if !targetEnvironment(simulator) && !targetEnvironment(macCatalyst)
UIApplication.shared.registerForRemoteNotifications()
#endif
}
}
func unregister() {
queued = nil; registeredGeneration = nil
#if !targetEnvironment(simulator) && !targetEnvironment(macCatalyst)
UIApplication.shared.unregisterForRemoteNotifications()
#endif
// Do not reassign an old callback to a new session.
}
func didRegister(_ data: Data) {
let run = outstanding ?? registeredGeneration
outstanding = nil
if let run {
registeredGeneration = run
SybilAgentNotifications.shared.receivedDeviceToken(data, generation: run)
}
drainQueued()
}
func didFail() {
let run = outstanding ?? registeredGeneration
outstanding = nil
if let run { SybilAgentNotifications.shared.registrationFailed(generation: run) }
drainQueued()
}
private func drainQueued() {
if let next = queued { queued = nil; register(generation: next) }
}
func delivered() async -> [AgentDeliveredNotification] {
if isFixture || environment == nil { return [] }
return await center.deliveredNotifications().map {
AgentDeliveredNotification(identifier: $0.request.identifier, payload: AgentPushPayload($0.request.content.userInfo))
}
}
func removeDelivered(identifiers: [String]) {
guard !isFixture, environment != nil else { return }
center.removeDeliveredNotifications(withIdentifiers: identifiers)
}
}
/// Public app-delegate bridge. The package coordinator remains injectable in tests.
@MainActor
public enum SybilPushNotifications {
public struct Event: Sendable {
let payload: AgentPushPayload?
public init(userInfo: [AnyHashable: Any]) { payload = AgentPushPayload(userInfo) }
}
#if DEBUG && targetEnvironment(simulator)
private static var fixtureWasActive = false
private static var fixtureForegroundSequence = 0
#endif
public static func didRegister(deviceToken: Data) { SybilNotificationPlatform.shared.didRegister(deviceToken) }
public static func didFailToRegister() { SybilNotificationPlatform.shared.didFail() }
public static func presentationOptions(event: Event) -> UNNotificationPresentationOptions {
guard let payload = event.payload else { return [] }
let notifications = SybilAgentNotifications.shared
Task { await notifications.syncRead() }
// Covers foreground bootstrap before a SwiftUI scene has attached too.
guard UIApplication.shared.applicationState != .active, notifications.shouldPresent(payload) else { return [] }
return [.banner, .list, .sound]
}
public static func didRespond(userInfo: [AnyHashable: Any], identifier: String) {
didRespond(event: Event(userInfo: userInfo), identifier: identifier)
}
public static func didRespond(event: Event, identifier: String) {
guard let payload = event.payload else { return }
SybilAgentNotifications.shared.notificationResponse(payload, identifier: identifier)
}
public static func receiveBackground(userInfo: [AnyHashable: Any], completion: @escaping @MainActor (UIBackgroundFetchResult) -> Void) {
guard let payload = AgentPushPayload(userInfo), payload.kind == .readState else { completion(.noData); return }
let notifications = SybilAgentNotifications.shared
let settings = SybilSettingsStore()
if let base = settings.normalizedAPIBaseURL {
notifications.restoreBackgroundScope(baseURL: base, token: settings.trimmedTokenOrNil, payload: payload)
}
AgentBackgroundCompletion.run(timeout: .seconds(5), operation: {
await notifications.receiveBackground(payload) ? .newData : .noData
}, completion: completion)
}
public static func launchFixtureIfRequested() {
#if DEBUG && targetEnvironment(simulator)
guard SybilNotificationPlatform.shared.isFixture,
ProcessInfo.processInfo.environment["SYBIL_PUSH_TAP"] == "1" else { return }
didRespond(userInfo: ["version": 1, "sourceId": "11111111-1111-4111-8111-111111111111", "kind": "message", "messageId": "outbound:3", "cursor": "3"], identifier: "fixture-system-request")
#endif
}
static func foregroundFixtureIfRequested() {
#if DEBUG && targetEnvironment(simulator)
guard SybilNotificationPlatform.shared.isFixture else { return }
let wasActive = fixtureWasActive
fixtureWasActive = true
guard wasActive, ProcessInfo.processInfo.environment["SYBIL_PUSH_TAP_ON_FOREGROUND"] == "1" else { return }
fixtureForegroundSequence += 1
didRespond(userInfo: ["version": 1, "sourceId": "11111111-1111-4111-8111-111111111111", "kind": "message", "messageId": "fixture-warm-tap-\(fixtureForegroundSequence)", "cursor": "3"], identifier: "fixture-warm-system-request")
#endif
}
}
/// A bounded callback gate: even an uncooperative notification-center fixture (or
/// OS suspension) cannot delay the fetch completion waiting for a network/socket.
@MainActor
enum AgentBackgroundCompletion {
static func run(timeout: Duration, operation: @escaping @MainActor () async -> UIBackgroundFetchResult,
completion: @escaping @MainActor (UIBackgroundFetchResult) -> Void) {
let gate = Gate(completion)
let work = Task { gate.finish(await operation()) }
Task {
try? await Task.sleep(for: timeout)
if !gate.finished { work.cancel(); gate.finish(.noData) }
}
}
@MainActor private final class Gate {
var finished = false
let completion: @MainActor (UIBackgroundFetchResult) -> Void
init(_ completion: @escaping @MainActor (UIBackgroundFetchResult) -> Void) { self.completion = completion }
func finish(_ result: UIBackgroundFetchResult) {
guard !finished else { return }
finished = true; completion(result)
}
}
}