XuqmGroup-iOSSDK/Sources/XuqmSDK/Push/PushSDK.swift

394 行
12 KiB
Swift

2026-04-21 22:07:29 +08:00
import Foundation
import UserNotifications
#if canImport(UIKit)
import UIKit
#endif
#if canImport(ObjectiveC)
import ObjectiveC
#endif
2026-04-21 22:07:29 +08:00
public enum PushVendor: String {
case apns = "APNS"
case fcm = "FCM"
}
public struct PushMessage: Sendable {
public let title: String?
public let body: String?
public let payload: [String: Any]
public let rawUserInfo: [AnyHashable: Any]
}
public protocol PushMessageDelegate: AnyObject, Sendable {
func pushSDK(_ sdk: PushSDK, didReceiveMessage message: PushMessage)
func pushSDK(_ sdk: PushSDK, didTapNotification message: PushMessage)
}
2026-04-21 22:07:29 +08:00
@MainActor
public final class PushSDK: NSObject, UNUserNotificationCenterDelegate {
2026-04-21 22:07:29 +08:00
public static let shared = PushSDK()
public weak var delegate: PushMessageDelegate?
private var cachedToken: String?
private var cachedPushConfigAt: Date?
private var cachedPushConfig: PushRuntimeConfig?
private override init() {
super.init()
}
public func start(options: UNAuthorizationOptions = [.alert, .badge, .sound]) async throws {
UNUserNotificationCenter.current().delegate = self
#if canImport(UIKit) && canImport(ObjectiveC)
PushAppDelegateInterceptor.install()
#endif
try? await applyTenantConfiguration()
_ = try await requestAuthorization(options: options)
}
@MainActor
public func requestAuthorization(options: UNAuthorizationOptions = [.alert, .badge, .sound]) async throws -> Bool {
let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: options)
#if canImport(UIKit)
if granted {
await MainActor.run {
UIApplication.shared.registerForRemoteNotifications()
}
}
#endif
return granted
}
public func registerDeviceToken(_ deviceToken: Data, userId: String) async throws {
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
try await registerToken(token, userId: userId)
}
2026-04-21 22:07:29 +08:00
public func registerToken(_ token: String, userId: String, vendor: PushVendor = .apns) async throws {
let config = XuqmSDK.shared.requireConfig()
try? await applyTenantConfiguration()
cachedToken = token
2026-04-21 22:07:29 +08:00
let _: EmptyResponse = try await ApiClient.shared.request(
path: "/api/push/register",
method: "POST",
queryItems: [
URLQueryItem(name: "appId", value: config.appId),
URLQueryItem(name: "userId", value: userId),
URLQueryItem(name: "vendor", value: vendor.rawValue),
URLQueryItem(name: "token", value: token),
URLQueryItem(name: "deviceId", value: deviceId),
URLQueryItem(name: "brand", value: "Apple"),
URLQueryItem(name: "model", value: deviceModel),
URLQueryItem(name: "osVersion", value: osVersion),
URLQueryItem(name: "platform", value: "IOS"),
URLQueryItem(name: "appVersion", value: appVersion),
2026-04-21 22:07:29 +08:00
]
)
}
public func registerFcmToken(_ token: String, userId: String) async throws {
try await registerToken(token, userId: userId, vendor: .fcm)
}
public var isFcmAvailable: Bool {
#if canImport(FirebaseMessaging)
return true
#else
return false
#endif
}
public func categoryIdentifier(for routeType: String) async -> String? {
if cachedPushConfig == nil {
try? await applyTenantConfiguration()
}
return cachedPushConfig?.routing[routeType]?.category
}
public func unregisterToken(userId: String, vendor: PushVendor = .apns) async throws {
let config = XuqmSDK.shared.requireConfig()
let _: EmptyResponse = try await ApiClient.shared.request(
path: "/api/push/unregister",
method: "DELETE",
queryItems: [
URLQueryItem(name: "appId", value: config.appId),
URLQueryItem(name: "userId", value: userId),
URLQueryItem(name: "vendor", value: vendor.rawValue),
URLQueryItem(name: "deviceId", value: deviceId),
]
)
}
public func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
let message = parseMessage(notification.request.content)
if let message = message {
delegate?.pushSDK(self, didReceiveMessage: message)
}
completionHandler([.banner, .sound, .badge])
}
public func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let message = parseMessage(response.notification.request.content)
if let message = message {
delegate?.pushSDK(self, didTapNotification: message)
}
completionHandler()
}
private func parseMessage(_ content: UNNotificationContent) -> PushMessage? {
let userInfo = content.userInfo
let aps = userInfo["aps"] as? [String: Any]
let alert = aps?["alert"] as? [String: Any]
let title = content.title.isEmpty ? (alert?["title"] as? String) : content.title
let body = content.body.isEmpty ? (alert?["body"] as? String) : content.body
return PushMessage(
title: title,
body: body,
payload: userInfo.compactMapKeys { $0 as? String },
rawUserInfo: userInfo
)
}
private func applyTenantConfiguration(force: Bool = false) async throws {
if !force,
let cachedPushConfigAt,
Date().timeIntervalSince(cachedPushConfigAt) < 300 {
return
}
let config = XuqmSDK.shared.requireConfig()
let response: SdkRuntimeConfigResponse = try await ApiClient.shared.request(
path: "/api/sdk/config",
queryItems: [
URLQueryItem(name: "appId", value: config.appId),
URLQueryItem(name: "platform", value: "IOS"),
]
)
cachedPushConfig = response.pushConfig
cachedPushConfigAt = Date()
registerNotificationCategories(response.pushConfig)
}
private func registerNotificationCategories(_ config: PushRuntimeConfig?) {
let categoryIds = Set(config?.routing.values.compactMap {
$0.category.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : $0.category
} ?? [])
guard !categoryIds.isEmpty else { return }
let categories = categoryIds.map { categoryId in
UNNotificationCategory(
identifier: categoryId,
actions: [],
intentIdentifiers: [],
options: []
)
}
UNUserNotificationCenter.current().setNotificationCategories(Set(categories))
}
}
private struct SdkRuntimeConfigResponse: Decodable {
let pushConfig: PushRuntimeConfig?
}
private struct PushRuntimeConfig: Decodable {
let channels: [PushChannelConfig]
let routing: [String: PushRouteConfig]
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
channels = try container.decodeIfPresent([PushChannelConfig].self, forKey: .channels) ?? []
routing = try container.decodeIfPresent([String: PushRouteConfig].self, forKey: .routing) ?? [:]
}
private enum CodingKeys: String, CodingKey {
case channels
case routing
}
}
private struct PushChannelConfig: Decodable {
let key: String
let channelId: String
let version: Int
let name: String
let description: String?
let importance: String
let sound: Bool
let vibration: Bool
let badge: Bool
}
private struct PushRouteConfig: Decodable {
let channel: String
let category: String
let priority: String
}
#if canImport(UIKit) && canImport(ObjectiveC)
private final class OriginalImpBox {
let imp: IMP
init(_ imp: IMP) {
self.imp = imp
}
}
private var didRegisterOriginalImpKey: UInt8 = 0
private final class PushAppDelegateInterceptor: NSObject {
private static let lock = NSLock()
private static var installedClassNames: Set<String> = []
static func install() {
guard let delegate = UIApplication.shared.delegate else {
return
}
let delegateClass: AnyClass = object_getClass(delegate) ?? type(of: delegate)
let className = NSStringFromClass(delegateClass)
lock.lock()
let alreadyInstalled = installedClassNames.contains(className)
if !alreadyInstalled {
installedClassNames.insert(className)
}
lock.unlock()
guard !alreadyInstalled else {
return
}
installDidRegisterHandler(on: delegateClass)
}
private static func installDidRegisterHandler(on delegateClass: AnyClass) {
let originalSelector = #selector(
UIApplicationDelegate.application(
_:didRegisterForRemoteNotificationsWithDeviceToken:
)
)
let interceptorSelector = #selector(
PushAppDelegateInterceptor.xuqm_application(
_:didRegisterForRemoteNotificationsWithDeviceToken:
)
)
guard let interceptorMethod = class_getInstanceMethod(Self.self, interceptorSelector) else {
return
}
if let originalMethod = class_getInstanceMethod(delegateClass, originalSelector) {
objc_setAssociatedObject(
delegateClass,
&didRegisterOriginalImpKey,
OriginalImpBox(method_getImplementation(originalMethod)),
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
}
class_replaceMethod(
delegateClass,
originalSelector,
method_getImplementation(interceptorMethod),
method_getTypeEncoding(interceptorMethod)
)
}
@objc private func xuqm_application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
Task { @MainActor in
XuqmSDK.shared.registerDeviceToken(deviceToken)
}
callOriginalDidRegister(application, deviceToken: deviceToken)
}
private func callOriginalDidRegister(_ application: UIApplication, deviceToken: Data) {
guard let box = objc_getAssociatedObject(
object_getClass(self) ?? type(of: self),
&didRegisterOriginalImpKey
) as? OriginalImpBox else {
return
}
typealias DidRegisterImp = @convention(c) (
AnyObject,
Selector,
UIApplication,
Data
) -> Void
let original = unsafeBitCast(box.imp, to: DidRegisterImp.self)
original(
self,
#selector(
UIApplicationDelegate.application(
_:didRegisterForRemoteNotificationsWithDeviceToken:
)
),
application,
deviceToken
)
}
}
#endif
private extension PushSDK {
var deviceId: String {
#if canImport(UIKit)
return UIDevice.current.identifierForVendor?.uuidString ?? "ios-device"
#else
return "ios-device"
#endif
}
var deviceModel: String {
#if canImport(UIKit)
return UIDevice.current.model
#else
return "Apple"
#endif
}
var osVersion: String {
#if canImport(UIKit)
return "\(UIDevice.current.systemName) \(UIDevice.current.systemVersion)"
#else
return "iOS"
#endif
}
var appVersion: String? {
let info = Bundle.main.infoDictionary
let version = info?["CFBundleShortVersionString"] as? String
let build = info?["CFBundleVersion"] as? String
switch (version, build) {
case let (version?, build?):
return "\(version)(\(build))"
case let (version?, nil):
return version
default:
return nil
}
}
}
private extension Dictionary {
func compactMapKeys<T: Hashable>(_ transform: (Key) -> T?) -> [T: Value] {
var result: [T: Value] = [:]
for (key, value) in self {
if let newKey = transform(key) {
result[newKey] = value
}
}
return result
}
2026-04-21 22:07:29 +08:00
}