355 lines
13 KiB
Swift
355 lines
13 KiB
Swift
import UIKit
|
||
import UserNotifications
|
||
|
||
/// 极光 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 var isInitialized = false
|
||
private var didRequestAuthorization = false
|
||
private var uploadTask: Task<Void, Never>?
|
||
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
|
||
) {
|
||
self.sdk = sdk ?? JPushSDKAdapter()
|
||
self.api = api ?? NetworkServices.shared.pushAPI
|
||
self.appStore = appStore
|
||
self.defaults = defaults
|
||
self.router = router ?? PushRouteCoordinator.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()
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
|
||
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)
|
||
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]?
|
||
) {}
|
||
}
|