import UIKit import UserNotifications /// App 桌面图标角标设置能力,便于业务同步未读消息数量并进行测试替换。 @MainActor protocol ApplicationIconBadgeSetting: AnyObject { /// 将桌面图标角标更新为指定非负数量。 func setBadgeCount(_ count: Int) async } /// 使用 UserNotifications 更新 App 桌面图标角标的系统实现。 @MainActor final class SystemApplicationIconBadgeSetter: ApplicationIconBadgeSetting { /// 全局角标设置器。 static let shared = SystemApplicationIconBadgeSetter() private init() {} func setBadgeCount(_ count: Int) async { do { try await UNUserNotificationCenter.current().setBadgeCount(max(count, 0)) } catch { #if DEBUG print("Update application icon badge failed: \(error.localizedDescription)") #endif } } } /// 极光 SDK 的最小能力边界,便于推送生命周期逻辑使用测试替身。 @MainActor protocol PushSDKProviding: AnyObject { /// 初始化极光长连接与 APNs 代理服务。 func initialize(launchOptions: [UIApplication.LaunchOptionsKey: Any]?, isProduction: Bool) /// 请求系统通知权限并注册极光通知代理。 func requestAuthorization(delegate: JPUSHRegisterDelegate) /// 将 APNs Device Token 注册给极光。 func registerDeviceToken(_ deviceToken: Data) /// 获取极光 Registration ID。 func fetchRegistrationID(completion: @escaping (Int32, String?) -> Void) } /// 跨越极光 `@Sendable` 回调边界的只读闭包容器。 private final class RegistrationIDCompletionBox: @unchecked Sendable { let completion: (Int32, String?) -> Void init(completion: @escaping (Int32, String?) -> Void) { self.completion = completion } } /// 生产环境极光 SDK 适配器,集中保存 AppKey 和渠道配置。 @MainActor final class JPushSDKAdapter: PushSDKProviding { private static let appKey = "194a633c2a6b79c2f1033c40" private static let channel = "App Store" /// 初始化极光 SDK;Debug 输出详细日志,Release 只保留告警和错误。 func initialize(launchOptions: [UIApplication.LaunchOptionsKey: Any]?, isProduction: Bool) { JPUSHService.setup( withOption: launchOptions, appKey: Self.appKey, channel: Self.channel, apsForProduction: isProduction ) #if DEBUG JPUSHService.setDebugMode() #else JPUSHService.setLogOFF() #endif } /// 请求 alert、badge、sound 三类通知权限。 func requestAuthorization(delegate: JPUSHRegisterDelegate) { let entity = JPUSHRegisterEntity() entity.types = 1 | 2 | 4 JPUSHService.register(forRemoteNotificationConfig: entity, delegate: delegate) } /// 将 APNs Device Token 交给极光。 func registerDeviceToken(_ deviceToken: Data) { JPUSHService.registerDeviceToken(deviceToken) } /// 异步获取当前设备 Registration ID。 func fetchRegistrationID(completion: @escaping (Int32, String?) -> Void) { let box = RegistrationIDCompletionBox(completion: completion) JPUSHService.registrationIDCompletionHandler { resultCode, registrationID in box.completion(resultCode, registrationID) } } } /// 推送生命周期管理器,负责隐私门禁、权限、Registration ID 绑定及通知点击分发。 @MainActor final class PushNotificationManager: NSObject { /// 全局推送管理器。 static let shared = PushNotificationManager() /// 推送相关的本地持久化键。 private enum Key { static let registrationID = "key_jpush_registration_id" static let uploadedRegistrationIDSuffix = "jpush_uploaded_registration_id" } private let sdk: any PushSDKProviding private let api: any PushRegistrationServing private let appStore: AppStore private let defaults: UserDefaults private let router: any PushRouting private let applicationIconBadgeSetter: any ApplicationIconBadgeSetting private var isInitialized = false private var didRequestAuthorization = false private var uploadTask: Task? private var queuedForcedUpload = false private var isFetchingRegistrationID = false private var queuedForcedFetch = false /// 创建推送管理器;生产环境使用共享依赖,测试可注入替身。 init( sdk: (any PushSDKProviding)? = nil, api: (any PushRegistrationServing)? = nil, appStore: AppStore = .shared, defaults: UserDefaults = .standard, router: (any PushRouting)? = nil, applicationIconBadgeSetter: (any ApplicationIconBadgeSetting)? = nil ) { self.sdk = sdk ?? JPushSDKAdapter() self.api = api ?? NetworkServices.shared.pushAPI self.appStore = appStore self.defaults = defaults self.router = router ?? PushRouteCoordinator.shared self.applicationIconBadgeSetter = applicationIconBadgeSetter ?? SystemApplicationIconBadgeSetter.shared super.init() } deinit { NotificationCenter.default.removeObserver(self) } /// 绑定当前 Scene 的主窗口。 func attach(window: UIWindow) { router.attach(window: window) } /// 仅在用户已同意隐私协议后初始化极光 SDK。 func initializeIfPrivacyAccepted( launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) { guard appStore.session.privacyAgreementAccepted, !isInitialized else { return } isInitialized = true sdk.initialize(launchOptions: launchOptions, isProduction: Self.isProductionBuild) observeJPushNetworkLogin() refreshRegistrationID(forceUpload: false) } /// 登录成功后请求通知权限,并强制绑定当前账号。 func handleLoginCompleted() { initializeIfPrivacyAccepted() guard isInitialized, appStore.session.isLoggedIn else { return } if !didRequestAuthorization { didRequestAuthorization = true sdk.requestAuthorization(delegate: self) } bindCurrentAccount() } /// 账号切换后把同一设备重新绑定到新的业务账号。 func handleAccountSwitched() { guard appStore.session.isLoggedIn else { return } bindCurrentAccount() } /// 退出登录时停止账号上报并清除未完成的业务路由。 func handleLogout() { uploadTask?.cancel() uploadTask = nil queuedForcedUpload = false router.resetPendingRoute() Task { await updateApplicationIconBadgeCount(0) } } /// 将桌面 App Icon 角标更新为最新未读消息数量。 func updateApplicationIconBadgeCount(_ count: Int) async { await applicationIconBadgeSetter.setBadgeCount(max(count, 0)) } /// App 回到前台时补偿失败或尚未完成的 Registration ID 上报。 func retryPendingRegistrationUpload() { guard appStore.session.isLoggedIn else { return } uploadCachedRegistrationID(force: false) refreshRegistrationID(forceUpload: false) } /// 登录根页面建立后继续执行通知点击暂存的路由。 func routePendingNotificationIfPossible() { router.routePendingIfPossible() } /// APNs 注册成功后把 Device Token 注册给极光。 func handleDeviceToken(_ deviceToken: Data) { guard isInitialized else { return } sdk.registerDeviceToken(deviceToken) refreshRegistrationID(forceUpload: false) } /// APNs 注册失败只记录调试信息,不阻断登录和主业务。 func handleRegistrationError(_ error: Error) { #if DEBUG print("JPush APNs registration failed: \(error.localizedDescription)") #endif } /// 处理冷启动或运行中通知点击。 func handleNotificationTap( payload: PushPayload, requestIdentifier: String ) { router.handle( destination: payload.destination, requestIdentifier: requestIdentifier ) } /// 处理 Scene 冷启动携带的系统通知响应,并向极光上报点击。 func handleNotificationResponse(_ response: UNNotificationResponse) { let userInfo = response.notification.request.content.userInfo JPUSHService.handleRemoteNotification(userInfo) handleNotificationTap( payload: PushPayload(userInfo: userInfo), requestIdentifier: response.notification.request.identifier ) } /// 处理后台静默通知并向极光上报到达,不触发页面跳转。 func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) { JPUSHService.handleRemoteNotification(userInfo) NotificationCenter.default.post(name: NotificationName.unreadMessageCountDidChange, object: nil) } private func observeJPushNetworkLogin() { NotificationCenter.default.addObserver( self, selector: #selector(handleJPushNetworkLogin), name: .jpfNetworkDidLogin, object: nil, ) } @objc private func handleJPushNetworkLogin() { refreshRegistrationID(forceUpload: false) } private func refreshRegistrationID(forceUpload: Bool) { guard isInitialized else { return } queuedForcedFetch = queuedForcedFetch || forceUpload guard !isFetchingRegistrationID else { return } isFetchingRegistrationID = true sdk.fetchRegistrationID { [weak self] resultCode, registrationID in Task { @MainActor in guard let self else { return } let shouldForceUpload = self.queuedForcedFetch self.queuedForcedFetch = false self.isFetchingRegistrationID = false guard resultCode == 0 else { return } let normalized = registrationID? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !normalized.isEmpty else { return } self.defaults.set(normalized, forKey: Key.registrationID) self.upload(registrationID: normalized, force: shouldForceUpload) } } } private func uploadCachedRegistrationID(force: Bool) { let registrationID = cachedRegistrationID guard !registrationID.isEmpty else { return } upload(registrationID: registrationID, force: force) } private func bindCurrentAccount() { if cachedRegistrationID.isEmpty { refreshRegistrationID(forceUpload: true) } else { uploadCachedRegistrationID(force: true) refreshRegistrationID(forceUpload: false) } } private var cachedRegistrationID: String { defaults.string(forKey: Key.registrationID)? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" } private func upload(registrationID: String, force: Bool) { guard appStore.session.isLoggedIn, let uploadedKey = appStore.session.accountScopedKey(Key.uploadedRegistrationIDSuffix) else { return } if !force, defaults.string(forKey: uploadedKey) == registrationID { return } if uploadTask != nil { queuedForcedUpload = queuedForcedUpload || force return } let accountScope = appStore.session.accountCachePrefix uploadTask = Task { [weak self] in guard let self else { return } var succeeded = false do { try await self.api.registerJPushID(registrationID) if self.appStore.session.accountCachePrefix == accountScope { self.defaults.set(registrationID, forKey: uploadedKey) } succeeded = true } catch is CancellationError { // 退出登录时取消,不视为失败重试。 } catch { #if DEBUG print("JPush Registration ID upload failed: \(error.localizedDescription)") #endif } self.uploadTask = nil let shouldForceAgain = self.queuedForcedUpload self.queuedForcedUpload = false if succeeded, shouldForceAgain { self.uploadCachedRegistrationID(force: true) } } } private nonisolated static var isProductionBuild: Bool { #if DEBUG false #else true #endif } } extension PushNotificationManager: JPUSHRegisterDelegate { /// 前台通知只展示系统 UI,不自动执行业务路由。 nonisolated func jpushNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (Int) -> Void ) { JPUSHService.handleRemoteNotification(notification.request.content.userInfo) Task { @MainActor in NotificationCenter.default.post(name: NotificationName.unreadMessageCountDidChange, object: nil) } let options: UNNotificationPresentationOptions = [.banner, .list, .sound, .badge] completionHandler(Int(options.rawValue)) } /// 用户点击通知后上报极光统计,并在主 Actor 执行业务路由。 nonisolated func jpushNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { let userInfo = response.notification.request.content.userInfo JPUSHService.handleRemoteNotification(userInfo) let payload = PushPayload(userInfo: userInfo) let requestIdentifier = response.notification.request.identifier Task { @MainActor [weak self] in self?.handleNotificationTap( payload: payload, requestIdentifier: requestIdentifier ) } completionHandler() } /// 系统通知设置入口回调,首期无需额外应用内页面。 nonisolated func jpushNotificationCenter( _ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification ) {} /// 极光通知授权状态回调,权限变化由系统设置管理。 nonisolated func jpushNotificationAuthorization( _ status: JPAuthorizationStatus, withInfo info: [AnyHashable: Any]? ) {} }