feat: integrate JPush notifications
This commit is contained in:
@ -29,6 +29,7 @@ final class NetworkServices {
|
||||
let liveAPI: LiveAPI
|
||||
let cloudDriveAPI: CloudDriveAPI
|
||||
let messageCenterAPI: MessageCenterAPI
|
||||
let pushAPI: PushAPI
|
||||
let wildPhotographerReportAPI: WildPhotographerReportAPI
|
||||
let uploadAPI: UploadAPI
|
||||
let ossUploadService: OSSUploadService
|
||||
@ -54,6 +55,7 @@ final class NetworkServices {
|
||||
liveAPI = LiveAPI(client: client)
|
||||
cloudDriveAPI = CloudDriveAPI(client: client)
|
||||
messageCenterAPI = MessageCenterAPI(client: client)
|
||||
pushAPI = PushAPI(client: client)
|
||||
wildPhotographerReportAPI = WildPhotographerReportAPI(client: client)
|
||||
uploadAPI = UploadAPI(client: client)
|
||||
ossUploadService = OSSUploadService(configService: uploadAPI)
|
||||
@ -84,6 +86,7 @@ final class NetworkServices {
|
||||
liveAPI = LiveAPI(client: apiClient)
|
||||
cloudDriveAPI = CloudDriveAPI(client: apiClient)
|
||||
messageCenterAPI = MessageCenterAPI(client: apiClient)
|
||||
pushAPI = PushAPI(client: apiClient)
|
||||
wildPhotographerReportAPI = WildPhotographerReportAPI(client: apiClient)
|
||||
uploadAPI = UploadAPI(client: apiClient)
|
||||
ossUploadService = OSSUploadService(configService: uploadAPI)
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
import IQKeyboardManagerSwift
|
||||
import IQKeyboardToolbarManager
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
@main
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
@ -18,9 +19,36 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
if AppStore.shared.session.privacyAgreementAccepted, !AppStore.shared.session.token.isEmpty {
|
||||
AMapBootstrap.configureIfNeeded()
|
||||
}
|
||||
PushNotificationManager.shared.initializeIfPrivacyAccepted(launchOptions: launchOptions)
|
||||
return true
|
||||
}
|
||||
|
||||
/// APNs 注册成功后将 Device Token 交给极光。
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
|
||||
) {
|
||||
PushNotificationManager.shared.handleDeviceToken(deviceToken)
|
||||
}
|
||||
|
||||
/// APNs 注册失败不阻断应用启动,仅交由推送管理器记录。
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFailToRegisterForRemoteNotificationsWithError error: Error
|
||||
) {
|
||||
PushNotificationManager.shared.handleRegistrationError(error)
|
||||
}
|
||||
|
||||
/// 处理后台静默通知并完成极光到达统计。
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
|
||||
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
|
||||
) {
|
||||
PushNotificationManager.shared.handleRemoteNotification(userInfo)
|
||||
completionHandler(.newData)
|
||||
}
|
||||
|
||||
// MARK: UISceneSession Lifecycle
|
||||
|
||||
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
|
||||
|
||||
30
suixinkan/Core/Push/PushAPI.swift
Normal file
30
suixinkan/Core/Push/PushAPI.swift
Normal file
@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
|
||||
/// 极光 Registration ID 上报能力,允许推送管理器注入测试替身。
|
||||
@MainActor
|
||||
protocol PushRegistrationServing: AnyObject {
|
||||
/// 将当前设备的极光 Registration ID 绑定到已登录业务账号。
|
||||
func registerJPushID(_ registrationID: String) async throws
|
||||
}
|
||||
|
||||
/// 推送 API,复用 Android 当前使用的 Registration ID 上报接口。
|
||||
@MainActor
|
||||
final class PushAPI: PushRegistrationServing {
|
||||
private let client: APIClient
|
||||
|
||||
/// 使用共享网络客户端创建推送 API。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 上报极光 Registration ID;后端字段名沿用 `jpush_reg_id`。
|
||||
func registerJPushID(_ registrationID: String) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/app/user/register-jpush-id",
|
||||
queryItems: [URLQueryItem(name: "jpush_reg_id", value: registrationID)]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
130
suixinkan/Core/Push/PushModels.swift
Normal file
130
suixinkan/Core/Push/PushModels.swift
Normal file
@ -0,0 +1,130 @@
|
||||
import Foundation
|
||||
|
||||
/// 推送点击后可到达的业务页面。
|
||||
enum PushDestination: Sendable, Equatable {
|
||||
case payment
|
||||
case orders
|
||||
case verificationOrders
|
||||
case task
|
||||
case queue
|
||||
case messageCenter
|
||||
|
||||
/// 目标页面是否依赖当前景区上下文。
|
||||
var requiresScenicContext: Bool {
|
||||
switch self {
|
||||
case .payment, .task, .queue:
|
||||
true
|
||||
case .orders, .verificationOrders, .messageCenter:
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 极光/APNs payload 解析结果,兼容顶层字段及常见 JSON 嵌套格式。
|
||||
struct PushPayload: Sendable, Equatable {
|
||||
private let values: [String: String]
|
||||
|
||||
/// 从系统通知 userInfo 解析路由字段。
|
||||
nonisolated init(userInfo: [AnyHashable: Any]) {
|
||||
var flattened: [String: String] = [:]
|
||||
Self.mergeDictionary(userInfo, into: &flattened)
|
||||
values = flattened
|
||||
}
|
||||
|
||||
/// 从可发送的字符串字典创建 payload,供跨 Actor 回调及测试使用。
|
||||
nonisolated init(values: [String: String]) {
|
||||
self.values = values
|
||||
}
|
||||
|
||||
/// 可发送的规范化字段快照。
|
||||
nonisolated var normalizedValues: [String: String] {
|
||||
values
|
||||
}
|
||||
|
||||
/// 根据后端约定和历史兼容字段解析目标页面。
|
||||
nonisolated var destination: PushDestination {
|
||||
let type = value(for: "type")
|
||||
let route = value(for: "route")
|
||||
let uri = value(for: "uri")
|
||||
let action = value(for: "action")
|
||||
let merged = [route, uri, action].joined(separator: " ").lowercased()
|
||||
|
||||
if type == "1" || type == "6"
|
||||
|| merged.contains("payment") || merged.contains("payment_qr")
|
||||
|| merged.contains("收款") {
|
||||
return .payment
|
||||
}
|
||||
if merged.contains("queue") || merged.contains("scenic-queue")
|
||||
|| merged.contains("排队") || merged.contains("叫号") {
|
||||
return .queue
|
||||
}
|
||||
if merged.contains("verification") || merged.contains("writeoff")
|
||||
|| merged.contains("write_off") || merged.contains("核销") {
|
||||
return .verificationOrders
|
||||
}
|
||||
if merged.contains("order") || merged.contains("订单") {
|
||||
return .orders
|
||||
}
|
||||
if merged.contains("task") || merged.contains("任务") {
|
||||
return .task
|
||||
}
|
||||
return .messageCenter
|
||||
}
|
||||
|
||||
private nonisolated func value(for key: String) -> String {
|
||||
values[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
}
|
||||
|
||||
private nonisolated static func mergeDictionary(
|
||||
_ dictionary: [AnyHashable: Any],
|
||||
into result: inout [String: String]
|
||||
) {
|
||||
for (rawKey, value) in dictionary {
|
||||
guard let key = rawKey as? String else { continue }
|
||||
merge(value, key: key, into: &result)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func merge(
|
||||
_ value: Any,
|
||||
key: String,
|
||||
into result: inout [String: String]
|
||||
) {
|
||||
if let dictionary = value as? [String: Any] {
|
||||
result[key] = stringValue(value)
|
||||
mergeDictionary(dictionary, into: &result)
|
||||
return
|
||||
}
|
||||
if let dictionary = value as? [AnyHashable: Any] {
|
||||
result[key] = stringValue(value)
|
||||
mergeDictionary(dictionary, into: &result)
|
||||
return
|
||||
}
|
||||
|
||||
let text = stringValue(value)
|
||||
result[key] = text
|
||||
guard Self.nestedJSONKeys.contains(key),
|
||||
let data = text.data(using: .utf8),
|
||||
let object = try? JSONSerialization.jsonObject(with: data)
|
||||
else { return }
|
||||
|
||||
if let dictionary = object as? [String: Any] {
|
||||
mergeDictionary(dictionary, into: &result)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func stringValue(_ value: Any) -> String {
|
||||
switch value {
|
||||
case let string as String:
|
||||
string
|
||||
case let number as NSNumber:
|
||||
number.stringValue
|
||||
default:
|
||||
String(describing: value)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static let nestedJSONKeys: Set<String> = [
|
||||
"extras", "extra", "data", "JMessageExtra", "n_extras",
|
||||
]
|
||||
}
|
||||
354
suixinkan/Core/Push/PushNotificationManager.swift
Normal file
354
suixinkan/Core/Push/PushNotificationManager.swift
Normal file
@ -0,0 +1,354 @@
|
||||
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]?
|
||||
) {}
|
||||
}
|
||||
106
suixinkan/Core/Push/PushRouteCoordinator.swift
Normal file
106
suixinkan/Core/Push/PushRouteCoordinator.swift
Normal file
@ -0,0 +1,106 @@
|
||||
import UIKit
|
||||
|
||||
/// 推送点击路由能力,允许生命周期管理器与 UIKit 导航解耦并支持测试替身。
|
||||
@MainActor
|
||||
protocol PushRouting: AnyObject {
|
||||
/// 绑定当前 Scene 的主窗口。
|
||||
func attach(window: UIWindow)
|
||||
|
||||
/// 处理一次通知点击;未登录时暂存到登录成功后继续。
|
||||
func handle(destination: PushDestination, requestIdentifier: String)
|
||||
|
||||
/// 主界面建立后执行尚未完成的通知路由。
|
||||
func routePendingIfPossible()
|
||||
|
||||
/// 清除退出登录前遗留的待处理路由。
|
||||
func resetPendingRoute()
|
||||
}
|
||||
|
||||
/// 集中式推送路由器,负责冷启动去重、登录等待和跨 Tab 导航。
|
||||
@MainActor
|
||||
final class PushRouteCoordinator: PushRouting {
|
||||
/// 全局推送路由器。
|
||||
static let shared = PushRouteCoordinator()
|
||||
|
||||
/// 等待登录或主界面建立后执行的一次性推送路由。
|
||||
private struct PendingRoute {
|
||||
let destination: PushDestination
|
||||
let requestIdentifier: String
|
||||
}
|
||||
|
||||
private weak var window: UIWindow?
|
||||
private var pendingRoute: PendingRoute?
|
||||
private var handledRequestIdentifiers: [String] = []
|
||||
private let appStore: AppStore
|
||||
|
||||
/// 创建路由器并注入应用状态。
|
||||
init(appStore: AppStore = .shared) {
|
||||
self.appStore = appStore
|
||||
}
|
||||
|
||||
/// 绑定当前 Scene 的主窗口。
|
||||
func attach(window: UIWindow) {
|
||||
self.window = window
|
||||
}
|
||||
|
||||
/// 处理通知点击;相同 request identifier 只执行一次。
|
||||
func handle(destination: PushDestination, requestIdentifier: String) {
|
||||
let identifier = requestIdentifier.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !identifier.isEmpty, handledRequestIdentifiers.contains(identifier) {
|
||||
return
|
||||
}
|
||||
markHandled(identifier)
|
||||
|
||||
let route = PendingRoute(destination: destination, requestIdentifier: identifier)
|
||||
guard appStore.session.isLoggedIn, mainTabController != nil else {
|
||||
pendingRoute = route
|
||||
return
|
||||
}
|
||||
perform(route)
|
||||
}
|
||||
|
||||
/// 登录完成、根页面切换后继续执行最后一次待处理路由。
|
||||
func routePendingIfPossible() {
|
||||
guard appStore.session.isLoggedIn,
|
||||
mainTabController != nil,
|
||||
let route = pendingRoute
|
||||
else { return }
|
||||
pendingRoute = nil
|
||||
perform(route)
|
||||
}
|
||||
|
||||
/// 用户退出登录时丢弃之前账号尚未执行的跳转。
|
||||
func resetPendingRoute() {
|
||||
pendingRoute = nil
|
||||
}
|
||||
|
||||
private var mainTabController: MainTabBarController? {
|
||||
window?.rootViewController as? MainTabBarController
|
||||
}
|
||||
|
||||
private func perform(_ route: PendingRoute) {
|
||||
guard let mainTabController else {
|
||||
pendingRoute = route
|
||||
return
|
||||
}
|
||||
|
||||
let destination: PushDestination
|
||||
if route.destination.requiresScenicContext,
|
||||
appStore.session.currentScenicId <= 0 {
|
||||
destination = .messageCenter
|
||||
} else {
|
||||
destination = route.destination
|
||||
}
|
||||
|
||||
mainTabController.dismiss(animated: false)
|
||||
mainTabController.openPushDestination(destination)
|
||||
}
|
||||
|
||||
private func markHandled(_ identifier: String) {
|
||||
guard !identifier.isEmpty else { return }
|
||||
handledRequestIdentifiers.append(identifier)
|
||||
if handledRequestIdentifiers.count > 50 {
|
||||
handledRequestIdentifiers.removeFirst(handledRequestIdentifiers.count - 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -42,5 +42,9 @@
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@ -24,7 +24,18 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
self.window = window
|
||||
(UIApplication.shared.delegate as? AppDelegate)?.window = window
|
||||
|
||||
PushNotificationManager.shared.attach(window: window)
|
||||
|
||||
registerNotifications()
|
||||
|
||||
if AppStore.shared.session.isLoggedIn {
|
||||
DispatchQueue.main.async {
|
||||
PushNotificationManager.shared.handleLoginCompleted()
|
||||
}
|
||||
}
|
||||
if let response = connectionOptions.notificationResponse {
|
||||
PushNotificationManager.shared.handleNotificationResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
func sceneDidDisconnect(_ scene: UIScene) {
|
||||
@ -43,6 +54,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
_ = WeChatManager.shared.handleContinueUserActivity(userActivity)
|
||||
}
|
||||
|
||||
func sceneDidBecomeActive(_ scene: UIScene) {
|
||||
PushNotificationManager.shared.retryPendingRegistrationUpload()
|
||||
}
|
||||
|
||||
private func registerNotifications() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
@ -62,19 +77,35 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
name: NotificationName.userDidLogin,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleAccountDidSwitch),
|
||||
name: NotificationName.accountDidSwitch,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func handleSessionDidExpire() {
|
||||
PushNotificationManager.shared.handleLogout()
|
||||
AppStore.shared.logout()
|
||||
AppRouter.setRoot(.login, on: window)
|
||||
}
|
||||
|
||||
@objc private func handleUserDidLogout() {
|
||||
PushNotificationManager.shared.handleLogout()
|
||||
AppStore.shared.logout()
|
||||
AppRouter.setRoot(.login, on: window)
|
||||
}
|
||||
|
||||
@objc private func handleUserDidLogin() {
|
||||
AppRouter.setRoot(.mainTab, on: window)
|
||||
DispatchQueue.main.async {
|
||||
PushNotificationManager.shared.handleLoginCompleted()
|
||||
PushNotificationManager.shared.routePendingNotificationIfPossible()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleAccountDidSwitch() {
|
||||
PushNotificationManager.shared.handleAccountSwitched()
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,6 +65,38 @@ final class MainTabBarController: UITabBarController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 响应推送点击,切换到目标 Tab 并打开对应业务页面。
|
||||
func openPushDestination(_ destination: PushDestination) {
|
||||
let targetTab: AppTab = switch destination {
|
||||
case .orders, .verificationOrders:
|
||||
.orders
|
||||
case .payment, .task, .queue, .messageCenter:
|
||||
.home
|
||||
}
|
||||
|
||||
selectTab(targetTab)
|
||||
guard let navigationController = tabNavigationControllers[targetTab] else { return }
|
||||
_ = navigationController.popToRootViewController(animated: false)
|
||||
|
||||
let controller: UIViewController?
|
||||
switch destination {
|
||||
case .payment:
|
||||
controller = PaymentCollectionDetailsViewController()
|
||||
case .task:
|
||||
controller = TaskAddViewController()
|
||||
case .queue:
|
||||
controller = ScenicQueueViewController()
|
||||
case .messageCenter:
|
||||
controller = MessageCenterViewController()
|
||||
case .orders, .verificationOrders:
|
||||
controller = nil
|
||||
}
|
||||
|
||||
if let controller {
|
||||
navigationController.pushViewController(controller, animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func configureTabBarAppearance() {
|
||||
tabBar.tintColor = AppColor.primary
|
||||
tabBar.unselectedItemTintColor = AppColor.textTabInactive
|
||||
|
||||
@ -22,6 +22,10 @@
|
||||
#if __has_include(<UMCommon/UMCommon.h>)
|
||||
#import <UMCommon/UMCommon.h>
|
||||
#endif
|
||||
|
||||
#if __has_include(<JPUSHService.h>)
|
||||
#import <JPUSHService.h>
|
||||
#endif
|
||||
#import "Features/ScenicQueue/Services/AliyunNuiTTSBridge.h"
|
||||
|
||||
#if __has_include(<UMAPM/UMAPM.h>)
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>$(APS_ENVIRONMENT)</string>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array>
|
||||
<string>applinks:www.zhifly.cn</string>
|
||||
|
||||
Reference in New Issue
Block a user