新增 APNs 推送注册与 Payload 路由

引入 AppDelegate、显式 Info.plist 与 entitlements,并将 PushNotificationManager 接入登录生命周期,用于 device token 上报与通知处理。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 09:18:49 +08:00
parent a04168cf30
commit 0314033a7f
10 changed files with 561 additions and 26 deletions

View File

@ -35,9 +35,22 @@
EDE192531FA9E2FD908B9EC1 /* Pods_suixinkanTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_suixinkanTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; EDE192531FA9E2FD908B9EC1 /* Pods_suixinkanTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_suixinkanTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
A00000102FECC00000000010 /* Exceptions for "suixinkan" folder in "suixinkan" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 939AC7952FE3F832004B22E4 /* suixinkan */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
939AC7982FE3F832004B22E4 /* suixinkan */ = { 939AC7982FE3F832004B22E4 /* suixinkan */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
A00000102FECC00000000010 /* Exceptions for "suixinkan" folder in "suixinkan" target */,
);
path = suixinkan; path = suixinkan;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@ -445,22 +458,15 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 56GVN5RNVN; DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_KEY_NSCameraUsageDescription = "用于扫码核销订单和直播推流预览"; INFOPLIST_FILE = suixinkan/Info.plist;
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "用于景区选择、打卡点选点和位置上报时获取当前位置";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "用于直播推流时采集现场声音";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "用于保存收款二维码到相册";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 17; IPHONEOS_DEPLOYMENT_TARGET = 17;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
@ -490,22 +496,15 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 56GVN5RNVN; DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_KEY_NSCameraUsageDescription = "用于扫码核销订单和直播推流预览"; INFOPLIST_FILE = suixinkan/Info.plist;
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "用于景区选择、打卡点选点和位置上报时获取当前位置";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "用于直播推流时采集现场声音";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "用于保存收款二维码到相册";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 17; IPHONEOS_DEPLOYMENT_TARGET = 17;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",

View File

@ -0,0 +1,41 @@
//
// AppDelegate.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import UIKit
/// AppDelegate APNs SwiftUI App adaptor
final class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
Task { @MainActor in
PushNotificationManager.shared.handleDeviceToken(deviceToken)
}
}
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
Task { @MainActor in
PushNotificationManager.shared.handleRegistrationError(error)
}
}
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
let payload = PushPayload(userInfo: userInfo)
Task { @MainActor in
PushNotificationManager.shared.handleRemoteNotification(payload)
completionHandler(.newData)
}
}
}

View File

@ -28,6 +28,7 @@ struct RootView: View {
@State private var statisticsAPI: StatisticsAPI @State private var statisticsAPI: StatisticsAPI
@State private var paymentAPI: PaymentAPI @State private var paymentAPI: PaymentAPI
@State private var walletAPI: WalletAPI @State private var walletAPI: WalletAPI
@State private var pushAPI: PushAPI
@State private var scenicPermissionAPI: ScenicPermissionAPI @State private var scenicPermissionAPI: ScenicPermissionAPI
@State private var scenicSettlementAPI: ScenicSettlementAPI @State private var scenicSettlementAPI: ScenicSettlementAPI
@State private var messageCenterAPI: MessageCenterAPI @State private var messageCenterAPI: MessageCenterAPI
@ -63,6 +64,7 @@ struct RootView: View {
_statisticsAPI = State(initialValue: StatisticsAPI(client: apiClient)) _statisticsAPI = State(initialValue: StatisticsAPI(client: apiClient))
_paymentAPI = State(initialValue: PaymentAPI(client: apiClient)) _paymentAPI = State(initialValue: PaymentAPI(client: apiClient))
_walletAPI = State(initialValue: WalletAPI(client: apiClient)) _walletAPI = State(initialValue: WalletAPI(client: apiClient))
_pushAPI = State(initialValue: PushAPI(client: apiClient))
_scenicPermissionAPI = State(initialValue: ScenicPermissionAPI(client: apiClient)) _scenicPermissionAPI = State(initialValue: ScenicPermissionAPI(client: apiClient))
_scenicSettlementAPI = State(initialValue: ScenicSettlementAPI(client: apiClient)) _scenicSettlementAPI = State(initialValue: ScenicSettlementAPI(client: apiClient))
_messageCenterAPI = State(initialValue: MessageCenterAPI(client: apiClient)) _messageCenterAPI = State(initialValue: MessageCenterAPI(client: apiClient))
@ -114,6 +116,7 @@ struct RootView: View {
.environment(statisticsAPI) .environment(statisticsAPI)
.environment(paymentAPI) .environment(paymentAPI)
.environment(walletAPI) .environment(walletAPI)
.environment(pushAPI)
.environment(scenicPermissionAPI) .environment(scenicPermissionAPI)
.environment(scenicSettlementAPI) .environment(scenicSettlementAPI)
.environment(messageCenterAPI) .environment(messageCenterAPI)
@ -132,6 +135,7 @@ struct RootView: View {
.environment(authSessionCoordinator) .environment(authSessionCoordinator)
.task { .task {
apiClient.bindAuthTokenProvider { appSession.token } apiClient.bindAuthTokenProvider { appSession.token }
PushNotificationManager.shared.configure(api: pushAPI, session: appSession, router: appRouter)
await globalLoading.withLoading { await globalLoading.withLoading {
await sessionBootstrapper.restore( await sessionBootstrapper.restore(
appSession: appSession, appSession: appSession,
@ -142,6 +146,9 @@ struct RootView: View {
toastCenter: toastCenter toastCenter: toastCenter
) )
} }
if appSession.isLoggedIn {
PushNotificationManager.shared.requestAuthorizationAndRegister()
}
} }
.task(id: scenicSpotTaskID) { .task(id: scenicSpotTaskID) {
guard appSession.isLoggedIn else { guard appSession.isLoggedIn else {
@ -176,12 +183,18 @@ struct RootView: View {
} }
} }
.onChange(of: appSession.phase) { _, phase in .onChange(of: appSession.phase) { _, phase in
guard phase == .loggedOut else { return } switch phase {
accountContext.reset() case .loggedIn:
permissionContext.reset() PushNotificationManager.shared.requestAuthorizationAndRegister()
scenicSpotContext.reset() case .loggedOut:
appRouter.reset() accountContext.reset()
toastCenter.dismiss() permissionContext.reset()
scenicSpotContext.reset()
appRouter.reset()
toastCenter.dismiss()
case .restoring:
break
}
} }
} }

View File

@ -10,6 +10,8 @@ import SwiftUI
/// SwiftUI /// SwiftUI
@main @main
struct suixinkanApp: App { struct suixinkanApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
RootView() RootView()

View File

@ -0,0 +1,32 @@
//
// PushAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import Foundation
import Observation
@MainActor
@Observable
/// API iOS APNs token
final class PushAPI {
@ObservationIgnored private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
/// APNs device token沿 Android/JPush
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,150 @@
//
// PushNotificationManager.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import UIKit
import UserNotifications
@MainActor
/// APNs token
final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate {
static let shared = PushNotificationManager()
private var api: PushAPI?
private weak var session: AppSession?
private weak var router: AppRouter?
private var latestDeviceToken: String?
private var latestAuthorizationStatus: UNAuthorizationStatus = .notDetermined
private let tokenDefaultsKey = "apns_device_token"
private let uploadedTokenDefaultsKey = "apns_uploaded_token"
override init() {
super.init()
}
///
func configure(api: PushAPI, session: AppSession, router: AppRouter) {
self.api = api
self.session = session
self.router = router
UNUserNotificationCenter.current().delegate = self
latestDeviceToken = UserDefaults.standard.string(forKey: tokenDefaultsKey)
}
/// APNs token
func requestAuthorizationAndRegister() {
Task { @MainActor in
let settings = await UNUserNotificationCenter.current().notificationSettings()
latestAuthorizationStatus = settings.authorizationStatus
switch settings.authorizationStatus {
case .notDetermined:
let granted = (try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound])) ?? false
guard granted else { return }
UIApplication.shared.registerForRemoteNotifications()
case .authorized, .provisional, .ephemeral:
UIApplication.shared.registerForRemoteNotifications()
uploadPendingTokenIfPossible()
case .denied:
break
@unknown default:
break
}
}
}
/// APNs token
func handleDeviceToken(_ deviceToken: Data) {
let token = APNsDeviceToken.hexString(from: deviceToken)
latestDeviceToken = token
UserDefaults.standard.set(token, forKey: tokenDefaultsKey)
uploadPendingTokenIfPossible()
}
/// APNs
func handleRegistrationError(_ error: Error) {
#if DEBUG
print("APNs registration failed: \(error.localizedDescription)")
#endif
}
/// token
func uploadPendingTokenIfPossible() {
guard let api, session?.isLoggedIn == true else { return }
guard let token = latestDeviceToken, !token.isEmpty else { return }
let uploaded = UserDefaults.standard.string(forKey: uploadedTokenDefaultsKey)
guard uploaded != token else { return }
Task {
do {
try await api.registerJPushId(token)
UserDefaults.standard.set(token, forKey: uploadedTokenDefaultsKey)
} catch {
#if DEBUG
print("Push token upload failed: \(error.localizedDescription)")
#endif
}
}
}
/// userInfo
func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) {
route(from: PushPayload(userInfo: userInfo))
}
/// payload
func handleRemoteNotification(_ payload: PushPayload) {
route(from: payload)
}
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
let payload = PushPayload(userInfo: notification.request.content.userInfo)
Task { @MainActor in
handleRemoteNotification(payload)
}
completionHandler([.banner, .list, .sound, .badge])
}
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let payload = PushPayload(userInfo: response.notification.request.content.userInfo)
Task { @MainActor in
handleRemoteNotification(payload)
completionHandler()
}
}
private func route(from payload: PushPayload) {
guard let router else { return }
switch payload.route {
case .payment:
navigateHomeRoute(.paymentCollection)
case .order:
router.selectOrders(entry: .storeOrders)
case .verificationOrder:
router.selectOrders(entry: .verificationOrders)
case .task:
navigateHomeRoute(.taskManagement)
case .queue:
navigateHomeRoute(.queueManagement)
case .messageCenter:
navigateHomeRoute(.messageCenter)
}
}
private func navigateHomeRoute(_ route: HomeRoute) {
guard let router else { return }
router.select(.home)
router.router(for: .home).navigate(to: .home(route))
}
}

View File

@ -0,0 +1,99 @@
//
// PushPayload.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import Foundation
/// APNs token Data
enum APNsDeviceToken {
static func hexString(from data: Data) -> String {
data.map { String(format: "%02x", $0) }.joined()
}
}
/// payload extras/data
struct PushPayload: Sendable {
enum Route: Sendable, Equatable {
case payment
case order
case verificationOrder
case task
case queue
case messageCenter
}
private let values: [String: String]
nonisolated init(userInfo: [AnyHashable: Any]) {
var result: [String: String] = [:]
for (key, value) in userInfo {
guard let key = key as? String else { continue }
result[key] = Self.stringValue(value)
if key == "extras" || key == "extra" || key == "data" || key == "JMessageExtra" {
Self.mergeJSON(value, into: &result)
}
}
values = result
}
nonisolated var route: Route {
let typeText = values["type"] ?? ""
let routeText = values["route"] ?? ""
let uriText = values["uri"] ?? ""
let actionText = values["action"] ?? ""
let merged = [typeText, routeText, uriText, actionText].joined(separator: " ").lowercased()
if typeText == "1" || merged.contains("payment") || merged.contains("pay") || merged.contains("收款") {
return .payment
}
if merged.contains("queue") || merged.contains("排队") || merged.contains("叫号") || uriText == "/scenic-queue" {
return .queue
}
if merged.contains("verification") || merged.contains("writeoff") || merged.contains("核销") {
return .verificationOrder
}
if merged.contains("order") || merged.contains("订单") {
return .order
}
if merged.contains("task") || merged.contains("任务") {
return .task
}
return .messageCenter
}
nonisolated private static func mergeJSON(_ value: Any, into result: inout [String: String]) {
if let dict = value as? [String: Any] {
for (key, value) in dict {
result[key] = stringValue(value)
mergeJSON(value, into: &result)
}
return
}
guard let text = value as? String,
let data = text.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { return }
for (key, value) in object {
result[key] = stringValue(value)
mergeJSON(value, into: &result)
}
}
nonisolated private static func stringValue(_ value: Any?) -> String {
switch value {
case let string as String:
return string
case let number as NSNumber:
return number.stringValue
case .some(let value):
return "\(value)"
case nil:
return ""
}
}
}

58
suixinkan/Info.plist Normal file
View File

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-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>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>用于扫码核销订单和直播推流预览</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>用于景区选择、打卡点选点和位置上报时获取当前位置</string>
<key>NSMicrophoneUsageDescription</key>
<string>用于直播推流时采集现场声音</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>用于保存收款二维码到相册</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
<key>UISceneConfigurations</key>
<dict/>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
</dict>
</plist>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-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>development</string>
</dict>
</plist>

View File

@ -0,0 +1,133 @@
//
// PushNotificationTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/26.
//
import Foundation
import XCTest
@testable import suixinkan
/// APNs tokenpayload token
final class PushNotificationTests: XCTestCase {
func testAPNsDeviceTokenUsesLowercaseHexString() {
let token = APNsDeviceToken.hexString(from: Data([0x00, 0x0f, 0x10, 0xab, 0xff]))
XCTAssertEqual(token, "000f10abff")
}
func testPushPayloadRoutesTopLevelFields() {
XCTAssertEqual(PushPayload(userInfo: ["type": "1"]).route, .payment)
XCTAssertEqual(PushPayload(userInfo: ["route": "verification_order"]).route, .verificationOrder)
XCTAssertEqual(PushPayload(userInfo: ["uri": "/scenic-queue"]).route, .queue)
XCTAssertEqual(PushPayload(userInfo: ["action": "task_management"]).route, .task)
XCTAssertEqual(PushPayload(userInfo: ["route": "photographer_orders"]).route, .order)
}
func testPushPayloadRoutesNestedJSONString() {
let payload = PushPayload(userInfo: [
"extras": #"{"data":{"uri":"message_center","title":""}}"#
])
XCTAssertEqual(payload.route, .messageCenter)
}
func testPushPayloadRoutesNestedDictionaryAndChineseKeywords() {
let payload = PushPayload(userInfo: [
"data": [
"extra": [
"action": "叫号提醒"
]
]
])
XCTAssertEqual(payload.route, .queue)
}
func testPushPayloadFallsBackToMessageCenter() {
let payload = PushPayload(userInfo: ["title": "系统消息"])
XCTAssertEqual(payload.route, .messageCenter)
}
func testRegisterPushTokenUsesCurrentBackendEndpointAndLegacyQueryName() async throws {
let session = PushRecordingURLSession()
let api = PushAPI(client: APIClient(session: session))
try await api.registerJPushId("000f10abff")
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(request.url?.path, "/api/app/user/register-jpush-id")
XCTAssertEqual(queryItems(from: request)["jpush_reg_id"], "000f10abff")
}
func testPushManagerRoutesHomeDestinationsThroughHomeNavigationStack() {
let router = AppRouter()
let manager = pushManager(router: router)
manager.handleRemoteNotification(PushPayload(userInfo: ["route": "payment_qr"]))
XCTAssertEqual(router.selectedTab, .home)
XCTAssertEqual(router.router(for: .home).path, [.home(.paymentCollection)])
router.router(for: .home).reset()
manager.handleRemoteNotification(PushPayload(userInfo: ["route": "task_management"]))
XCTAssertEqual(router.router(for: .home).path, [.home(.taskManagement)])
router.router(for: .home).reset()
manager.handleRemoteNotification(PushPayload(userInfo: ["uri": "/scenic-queue"]))
XCTAssertEqual(router.router(for: .home).path, [.home(.queueManagement)])
router.router(for: .home).reset()
manager.handleRemoteNotification(PushPayload(userInfo: ["title": "未知通知"]))
XCTAssertEqual(router.router(for: .home).path, [.home(.messageCenter)])
}
func testPushManagerRoutesOrdersEntry() {
let router = AppRouter()
let manager = pushManager(router: router)
manager.handleRemoteNotification(PushPayload(userInfo: ["route": "photographer_orders"]))
XCTAssertEqual(router.selectedTab, .orders)
XCTAssertEqual(router.selectedOrdersEntry, .storeOrders)
manager.handleRemoteNotification(PushPayload(userInfo: ["route": "verification_order"]))
XCTAssertEqual(router.selectedTab, .orders)
XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders)
}
private func pushManager(router: AppRouter) -> PushNotificationManager {
let session = AppSession()
let manager = PushNotificationManager()
manager.configure(
api: PushAPI(client: APIClient(session: PushRecordingURLSession())),
session: session,
router: router
)
return manager
}
}
private final class PushRecordingURLSession: URLSessionProtocol {
private(set) var requests: [URLRequest] = []
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
requests.append(request)
let data = #"{"code":100000,"msg":"success","data":{}}"#.data(using: .utf8)!
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (data, response)
}
}
private func queryItems(from request: URLRequest) -> [String: String] {
guard let url = request.url,
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
else {
return [:]
}
return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in
guard let value = item.value else { return nil }
return (item.name, value)
})
}