import ComposableArchitecture2 import Dependencies #if os(Android) import SkipFuse #elseif canImport(OSLog) import OSLog #endif #if os(Android) || canImport(OSLog) private let logger = Logger( subsystem: "app.openmusicevent", category: "notifications" ) private func logInfo(_ message: String) { #if os(Android) logger.info(message) #else logger.info("\(message, privacy: .public)") #endif } private func logError(_ message: String) { #if os(Android) logger.error(message) #else logger.error("\(message, privacy: .public)") #endif } #endif /// A transparent decorator that connects app-level notification transport to a content feature tree. /// /// The wrapper forwards provider-issued device destinations to the host's authenticated client /// without storing token material in feature state. /// /// Descendants publish desired subscriptions with ``FeatureProtocol/notificationSubscriptions(_:)``. /// Provider-delivered notification-center events enter through an app-owned ``NotificationEvent`` /// trigger. Transport signals and decoded content leave through their own app-owned triggers. public struct NotificationsFeature< Content: FeatureProtocol, Route: NotificationRoute >: FeatureProtocol { private let appDelegateEventTrigger: KeyPath> private let content: Content private let contentTrigger: KeyPath>> private let destinationTrigger: KeyPath> private let notificationEventTrigger: KeyPath> private let signalTrigger: KeyPath> @Dependency(\.notificationsClient) private var notificationsClient public init( appDelegateEventTrigger: KeyPath>, contentTrigger: KeyPath>>, destinationTrigger: KeyPath>, notificationEventTrigger: KeyPath>, signalTrigger: KeyPath>, content makeContent: @escaping @Sendable () -> Content ) { self.appDelegateEventTrigger = appDelegateEventTrigger self.contentTrigger = contentTrigger self.destinationTrigger = destinationTrigger self.notificationEventTrigger = notificationEventTrigger self.signalTrigger = signalTrigger self.content = makeContent() } fileprivate init( appDelegateEventTrigger: KeyPath>, contentTrigger: KeyPath>>, destinationTrigger: KeyPath>, notificationEventTrigger: KeyPath>, signalTrigger: KeyPath>, content: Content ) { self.appDelegateEventTrigger = appDelegateEventTrigger self.contentTrigger = contentTrigger self.destinationTrigger = destinationTrigger self.notificationEventTrigger = notificationEventTrigger self.signalTrigger = signalTrigger self.content = content } public typealias State = Content.State public typealias Action = Content.Action @FeatureBindable private var store = FeatureStore() public var body: some FeatureProtocol { content .onTrigger(store.state[keyPath: destinationTrigger]) { destination, _ in #if os(Android) || canImport(OSLog) let provider: String let applicationID: String switch destination { case .apns(let registration): provider = "APNs" applicationID = registration.applicationID.rawValue case .fcm(let registration): provider = "FCM" applicationID = registration.applicationID.rawValue } logInfo( "Registering \(provider) notification destination for \(applicationID)" ) #endif store.addTask { do { try await notificationsClient.registerDestination(destination) #if os(Android) || canImport(OSLog) logInfo( "Registered \(provider) notification destination for \(applicationID)" ) #endif } catch let error as CancellationError { throw error } catch { #if os(Android) || canImport(OSLog) logError( "Notification destination registration failed for \(applicationID): \(String(describing: error))" ) #endif // The next resume or destination change retries the desired state. } } } .onTrigger(store.state[keyPath: appDelegateEventTrigger]) { event, _ in switch event { case .resumed: #if os(Android) || canImport(OSLog) logInfo("Synchronizing notification registration after application resumed") #endif store.addTask { do { try await notificationsClient.synchronize() #if os(Android) || canImport(OSLog) logInfo("Notification registration synchronization completed") #endif } catch let error as CancellationError { throw error } catch { #if os(Android) || canImport(OSLog) logError( "Notification registration synchronization failed: \(String(describing: error))" ) #endif throw error } } default: break } } .onTrigger(store.state[keyPath: notificationEventTrigger]) { notification, _ in let message: NotificationMessage do { message = try NotificationProviderMessage(json: notification.json) .message(as: Route.self) } catch { #if os(Android) || canImport(OSLog) logError( "Ignoring invalid delivered notification JSON: \(String(describing: error))" ) #endif return } #if os(Android) || canImport(OSLog) switch message { case .content(let content): logInfo( "Received \(String(describing: notification.delivery)) notification for route \(content.route.notificationRoute) from source \(content.source.id.rawValue) at version \(content.source.version.rawValue)" ) case .sourceAvailable(let source): logInfo( "Received \(String(describing: notification.delivery)) notification for source \(source.id.rawValue) at version \(source.version.rawValue)" ) } #endif switch message { case .content(let content): switch notification.delivery { case .userInteraction: store.addTask { let route = try store.state[keyPath: contentTrigger](content) let refresh = try store.state[keyPath: signalTrigger]( .sourceAvailable(content.source) ) await route?.value await refresh?.value #if os(Android) || canImport(OSLog) logInfo("Delivered notification content to the application feature tree") #endif } case .contentAvailable, .foreground: store.addTask { let trigger = try store.state[keyPath: signalTrigger]( .sourceAvailable(content.source) ) await trigger?.value #if os(Android) || canImport(OSLog) logInfo("Delivered notification signal to the application feature tree") #endif } } case .sourceAvailable(let source): store.addTask { let trigger = try store.state[keyPath: signalTrigger]( .sourceAvailable(source) ) await trigger?.value #if os(Android) || canImport(OSLog) logInfo("Delivered notification signal to the application feature tree") #endif } } } .onMount { _ in store.addTask { for await signal in await notificationsClient.signals() { #if os(Android) || canImport(OSLog) switch signal { case .sourceAvailable(let source): logInfo( "Received notification transport signal for source \(source.id.rawValue) at version \(source.version.rawValue)" ) } #endif let trigger = try store.state[keyPath: signalTrigger](signal) await trigger?.value #if os(Android) || canImport(OSLog) logInfo("Delivered notification transport signal to the application feature tree") #endif } } } .onPreferenceChange(NotificationSubscriptionsPreferenceKey.self) { subscriptions, _ in store.addTask { do { try await notificationsClient.replaceSubscriptions(subscriptions) #if os(Android) || canImport(OSLog) let sourceIDs = subscriptions .map(\.sourceID.rawValue) .sorted() .joined(separator: ", ") logInfo( "Replaced notification subscriptions; count: \(subscriptions.count); sources: \(sourceIDs)" ) #endif } catch let error as CancellationError { throw error } catch { #if os(Android) || canImport(OSLog) logError( "Replacing notification subscriptions failed: \(String(describing: error))" ) #endif throw error } } } } } public extension FeatureProtocol { /// Installs notification orchestration at the owning app-root boundary. func notifications( appDelegateEventTrigger: KeyPath>, contentTrigger: KeyPath>>, destinationTrigger: KeyPath>, notificationEventTrigger: KeyPath>, signalTrigger: KeyPath> ) -> some FeatureProtocol { NotificationsFeature( appDelegateEventTrigger: appDelegateEventTrigger, contentTrigger: contentTrigger, destinationTrigger: destinationTrigger, notificationEventTrigger: notificationEventTrigger, signalTrigger: signalTrigger, content: self ) } }