Add APNs push notification registration and payload routing.
Introduce AppDelegate, explicit Info.plist and entitlements, and wire PushNotificationManager into login lifecycle for device token upload and notification handling. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -35,9 +35,22 @@
|
||||
EDE192531FA9E2FD908B9EC1 /* Pods_suixinkanTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_suixinkanTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* 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 */
|
||||
939AC7982FE3F832004B22E4 /* suixinkan */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
A00000102FECC00000000010 /* Exceptions for "suixinkan" folder in "suixinkan" target */,
|
||||
);
|
||||
path = suixinkan;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@ -445,22 +458,15 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "用于扫码核销订单和直播推流预览";
|
||||
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";
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = suixinkan/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
@ -490,22 +496,15 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "用于扫码核销订单和直播推流预览";
|
||||
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";
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = suixinkan/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
|
||||
41
suixinkan/App/AppDelegate.swift
Normal file
41
suixinkan/App/AppDelegate.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -28,6 +28,7 @@ struct RootView: View {
|
||||
@State private var statisticsAPI: StatisticsAPI
|
||||
@State private var paymentAPI: PaymentAPI
|
||||
@State private var walletAPI: WalletAPI
|
||||
@State private var pushAPI: PushAPI
|
||||
@State private var scenicPermissionAPI: ScenicPermissionAPI
|
||||
@State private var scenicSettlementAPI: ScenicSettlementAPI
|
||||
@State private var messageCenterAPI: MessageCenterAPI
|
||||
@ -63,6 +64,7 @@ struct RootView: View {
|
||||
_statisticsAPI = State(initialValue: StatisticsAPI(client: apiClient))
|
||||
_paymentAPI = State(initialValue: PaymentAPI(client: apiClient))
|
||||
_walletAPI = State(initialValue: WalletAPI(client: apiClient))
|
||||
_pushAPI = State(initialValue: PushAPI(client: apiClient))
|
||||
_scenicPermissionAPI = State(initialValue: ScenicPermissionAPI(client: apiClient))
|
||||
_scenicSettlementAPI = State(initialValue: ScenicSettlementAPI(client: apiClient))
|
||||
_messageCenterAPI = State(initialValue: MessageCenterAPI(client: apiClient))
|
||||
@ -114,6 +116,7 @@ struct RootView: View {
|
||||
.environment(statisticsAPI)
|
||||
.environment(paymentAPI)
|
||||
.environment(walletAPI)
|
||||
.environment(pushAPI)
|
||||
.environment(scenicPermissionAPI)
|
||||
.environment(scenicSettlementAPI)
|
||||
.environment(messageCenterAPI)
|
||||
@ -132,6 +135,7 @@ struct RootView: View {
|
||||
.environment(authSessionCoordinator)
|
||||
.task {
|
||||
apiClient.bindAuthTokenProvider { appSession.token }
|
||||
PushNotificationManager.shared.configure(api: pushAPI, session: appSession, router: appRouter)
|
||||
await globalLoading.withLoading {
|
||||
await sessionBootstrapper.restore(
|
||||
appSession: appSession,
|
||||
@ -142,6 +146,9 @@ struct RootView: View {
|
||||
toastCenter: toastCenter
|
||||
)
|
||||
}
|
||||
if appSession.isLoggedIn {
|
||||
PushNotificationManager.shared.requestAuthorizationAndRegister()
|
||||
}
|
||||
}
|
||||
.task(id: scenicSpotTaskID) {
|
||||
guard appSession.isLoggedIn else {
|
||||
@ -176,12 +183,18 @@ struct RootView: View {
|
||||
}
|
||||
}
|
||||
.onChange(of: appSession.phase) { _, phase in
|
||||
guard phase == .loggedOut else { return }
|
||||
accountContext.reset()
|
||||
permissionContext.reset()
|
||||
scenicSpotContext.reset()
|
||||
appRouter.reset()
|
||||
toastCenter.dismiss()
|
||||
switch phase {
|
||||
case .loggedIn:
|
||||
PushNotificationManager.shared.requestAuthorizationAndRegister()
|
||||
case .loggedOut:
|
||||
accountContext.reset()
|
||||
permissionContext.reset()
|
||||
scenicSpotContext.reset()
|
||||
appRouter.reset()
|
||||
toastCenter.dismiss()
|
||||
case .restoring:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -10,6 +10,8 @@ import SwiftUI
|
||||
/// 应用入口实体,负责把 SwiftUI 根视图挂载到主窗口。
|
||||
@main
|
||||
struct suixinkanApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
|
||||
32
suixinkan/Core/Push/PushAPI.swift
Normal file
32
suixinkan/Core/Push/PushAPI.swift
Normal 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)]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
150
suixinkan/Core/Push/PushNotificationManager.swift
Normal file
150
suixinkan/Core/Push/PushNotificationManager.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
99
suixinkan/Core/Push/PushPayload.swift
Normal file
99
suixinkan/Core/Push/PushPayload.swift
Normal 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
58
suixinkan/Info.plist
Normal 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>
|
||||
8
suixinkan/suixinkan.entitlements
Normal file
8
suixinkan/suixinkan.entitlements
Normal 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>
|
||||
133
suixinkanTests/PushNotificationTests.swift
Normal file
133
suixinkanTests/PushNotificationTests.swift
Normal file
@ -0,0 +1,133 @@
|
||||
//
|
||||
// PushNotificationTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 推送链路测试,覆盖 APNs token、payload 解析、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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user