feat: integrate JPush notifications

This commit is contained in:
2026-07-20 15:56:06 +08:00
parent 573c9a42ae
commit e7f1d777dd
50 changed files with 6552 additions and 2499 deletions

View File

@ -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)

View File

@ -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 {

View 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)]
)
)
}
}

View 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",
]
}

View 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"
/// SDKDebug 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
}
/// alertbadgesound
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]?
) {}
}

View 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)
}
}
}

View File

@ -42,5 +42,9 @@
</array>
</dict>
</dict>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
</dict>
</plist>

View File

@ -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()
}
}

View File

@ -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

View File

@ -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>)

View File

@ -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>