feat(background-task): 实现iOS原生后台任务V2系统并重构锻炼通知消息模板
- 新增iOS原生BackgroundTaskBridge桥接模块,支持后台任务注册、调度和完成 - 重构BackgroundTaskManager为V2版本,集成原生iOS后台任务能力 - 在AppDelegate中注册后台任务处理器,确保应用启动时正确初始化 - 重构锻炼通知消息生成逻辑,使用配置化模板提升可维护性 - 扩展健康数据类型映射,支持更多运动项目的中文显示 - 替换原有backgroundTaskManager引用为backgroundTaskManagerV2
This commit is contained in:
@@ -14,7 +14,7 @@ import { WorkoutSummaryCard } from '@/components/WorkoutSummaryCard';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { BackgroundTaskManager } from '@/services/backgroundTaskManager';
|
||||
import { BackgroundTaskManager } from '@/services/backgroundTaskManagerV2';
|
||||
import { setHealthData } from '@/store/healthSlice';
|
||||
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||
import { fetchTodayWaterStats } from '@/store/waterSlice';
|
||||
|
||||
@@ -29,7 +29,7 @@ import { MembershipModalProvider } from '@/contexts/MembershipModalContext';
|
||||
import { ToastProvider } from '@/contexts/ToastContext';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { STORAGE_KEYS } from '@/services/api';
|
||||
import { BackgroundTaskManager } from '@/services/backgroundTaskManager';
|
||||
import { BackgroundTaskManager } from '@/services/backgroundTaskManagerV2';
|
||||
import { fetchChallenges } from '@/store/challengesSlice';
|
||||
import AsyncStorage from '@/utils/kvStore';
|
||||
import { logger } from '@/utils/logger';
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
79B2CB702E7B954600B51753 /* OutLive-Bridging-Header.h in Sources */ = {isa = PBXBuildFile; fileRef = F11748442D0722820044C1D9 /* OutLive-Bridging-Header.h */; };
|
||||
79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB712E7B954F00B51753 /* HealthKitManager.m */; };
|
||||
79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79B2CB722E7B954F00B51753 /* HealthKitManager.swift */; };
|
||||
B6B9273B2FD4F4A800C6391C /* BackgroundTaskBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B9273A2FD4F4A800C6391C /* BackgroundTaskBridge.swift */; };
|
||||
B6B9273D2FD4F4A800C6391C /* BackgroundTaskBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = B6B9273C2FD4F4A800C6391C /* BackgroundTaskBridge.m */; };
|
||||
91B7BA17B50D328546B5B4B8 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */; };
|
||||
AE00ECEC9D078460F642F131 /* libPods-OutLive.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6F6136AA7113B3D210693D88 /* libPods-OutLive.a */; };
|
||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
||||
@@ -35,6 +37,8 @@
|
||||
79B2CB712E7B954F00B51753 /* HealthKitManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = HealthKitManager.m; path = OutLive/HealthKitManager.m; sourceTree = "<group>"; };
|
||||
79B2CB722E7B954F00B51753 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = HealthKitManager.swift; path = OutLive/HealthKitManager.swift; sourceTree = "<group>"; };
|
||||
9B6A6CEBED2FC0931F7B7236 /* Pods-OutLive.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OutLive.release.xcconfig"; path = "Target Support Files/Pods-OutLive/Pods-OutLive.release.xcconfig"; sourceTree = "<group>"; };
|
||||
B6B9273A2FD4F4A800C6391C /* BackgroundTaskBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BackgroundTaskBridge.swift; path = OutLive/BackgroundTaskBridge.swift; sourceTree = "<group>"; };
|
||||
B6B9273C2FD4F4A800C6391C /* BackgroundTaskBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = BackgroundTaskBridge.m; path = OutLive/BackgroundTaskBridge.m; sourceTree = "<group>"; };
|
||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = OutLive/SplashScreen.storyboard; sourceTree = "<group>"; };
|
||||
B7F23062EE59F61E6260DBA8 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = OutLive/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
|
||||
@@ -102,6 +106,8 @@
|
||||
792C52612EB05B8F002F3F09 /* NativeToastManager.swift */,
|
||||
79B2CB712E7B954F00B51753 /* HealthKitManager.m */,
|
||||
79B2CB722E7B954F00B51753 /* HealthKitManager.swift */,
|
||||
B6B9273C2FD4F4A800C6391C /* BackgroundTaskBridge.m */,
|
||||
B6B9273A2FD4F4A800C6391C /* BackgroundTaskBridge.swift */,
|
||||
13B07FAE1A68108700A75B9A /* OutLive */,
|
||||
832341AE1AAA6A7D00B99B32 /* Libraries */,
|
||||
83CBBA001A601CBA00E9B192 /* Products */,
|
||||
@@ -369,6 +375,8 @@
|
||||
792C52632EB05B8F002F3F09 /* NativeToastManager.swift in Sources */,
|
||||
79B2CB732E7B954F00B51753 /* HealthKitManager.m in Sources */,
|
||||
79B2CB742E7B954F00B51753 /* HealthKitManager.swift in Sources */,
|
||||
B6B9273D2FD4F4A800C6391C /* BackgroundTaskBridge.m in Sources */,
|
||||
B6B9273B2FD4F4A800C6391C /* BackgroundTaskBridge.swift in Sources */,
|
||||
79B2CB702E7B954600B51753 /* OutLive-Bridging-Header.h in Sources */,
|
||||
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
|
||||
32476CAEFFCE691C1634B0A4 /* ExpoModulesProvider.swift in Sources */,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Expo
|
||||
import React
|
||||
import ReactAppDependencyProvider
|
||||
import BackgroundTasks
|
||||
|
||||
@UIApplicationMain
|
||||
public class AppDelegate: ExpoAppDelegate {
|
||||
@@ -13,6 +14,11 @@ public class AppDelegate: ExpoAppDelegate {
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool {
|
||||
// 在应用启动完成前注册后台任务
|
||||
if #available(iOS 13.0, *) {
|
||||
registerBackgroundTasks()
|
||||
}
|
||||
|
||||
let delegate = ReactNativeDelegate()
|
||||
let factory = ExpoReactNativeFactory(delegate: delegate)
|
||||
delegate.dependencyProvider = RCTAppDependencyProvider()
|
||||
@@ -32,6 +38,53 @@ public class AppDelegate: ExpoAppDelegate {
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
// MARK: - Background Task Registration
|
||||
|
||||
@available(iOS 13.0, *)
|
||||
private func registerBackgroundTasks() {
|
||||
let identifier = "com.anonymous.digitalpilates.task"
|
||||
|
||||
// 注册后台任务处理器
|
||||
// 必须在应用启动完成前注册,否则会崩溃
|
||||
BGTaskScheduler.shared.register(
|
||||
forTaskWithIdentifier: identifier,
|
||||
using: nil
|
||||
) { [weak self] task in
|
||||
// 尝试通知 BackgroundTaskBridge 处理任务
|
||||
// 如果 bridge 不可用,标记任务完成
|
||||
self?.handleBackgroundTask(task, identifier: identifier)
|
||||
}
|
||||
|
||||
NSLog("[AppDelegate] 后台任务已在应用启动时注册: \(identifier)")
|
||||
}
|
||||
|
||||
@available(iOS 13.0, *)
|
||||
private func handleBackgroundTask(_ task: BGTask, identifier: String) {
|
||||
// 尝试获取 BackgroundTaskBridge 实例来处理任务
|
||||
// 如果 React Native bridge 还未初始化,则直接完成任务
|
||||
guard let bridge = reactNativeFactory?.bridge,
|
||||
bridge.isValid else {
|
||||
NSLog("[AppDelegate] React Native bridge 未就绪,直接完成后台任务")
|
||||
task.setTaskCompleted(success: false)
|
||||
return
|
||||
}
|
||||
|
||||
// 通过 bridge 查找 BackgroundTaskBridge 模块
|
||||
DispatchQueue.main.async {
|
||||
if let module = bridge.module(for: BackgroundTaskBridge.self) as? BackgroundTaskBridge {
|
||||
// 通知 BackgroundTaskBridge 处理任务
|
||||
NotificationCenter.default.post(
|
||||
name: NSNotification.Name("BackgroundTaskBridge.handleTask"),
|
||||
object: nil,
|
||||
userInfo: ["task": task, "identifier": identifier]
|
||||
)
|
||||
} else {
|
||||
NSLog("[AppDelegate] BackgroundTaskBridge 模块未找到,完成后台任务")
|
||||
task.setTaskCompleted(success: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Linking API
|
||||
public override func application(
|
||||
_ app: UIApplication,
|
||||
|
||||
31
ios/OutLive/BackgroundTaskBridge.m
Normal file
31
ios/OutLive/BackgroundTaskBridge.m
Normal file
@@ -0,0 +1,31 @@
|
||||
#import <React/RCTBridgeModule.h>
|
||||
#import <React/RCTEventEmitter.h>
|
||||
|
||||
@interface RCT_EXTERN_MODULE(BackgroundTaskBridge, RCTEventEmitter)
|
||||
|
||||
RCT_EXTERN_METHOD(configure:(NSDictionary *)options
|
||||
resolver:(RCTPromiseResolveBlock)resolver
|
||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||
|
||||
RCT_EXTERN_METHOD(schedule:(NSDictionary *)options
|
||||
resolver:(RCTPromiseResolveBlock)resolver
|
||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||
|
||||
RCT_EXTERN_METHOD(complete:(NSNumber *)success
|
||||
rescheduleAfter:(NSNumber *_Nullable)rescheduleAfter
|
||||
resolver:(RCTPromiseResolveBlock)resolver
|
||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||
|
||||
RCT_EXTERN_METHOD(cancelAll:(RCTPromiseResolveBlock)resolver
|
||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||
|
||||
RCT_EXTERN_METHOD(getPendingRequests:(RCTPromiseResolveBlock)resolver
|
||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||
|
||||
RCT_EXTERN_METHOD(backgroundRefreshStatus:(RCTPromiseResolveBlock)resolver
|
||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||
|
||||
RCT_EXTERN_METHOD(simulateLaunch:(RCTPromiseResolveBlock)resolver
|
||||
rejecter:(RCTPromiseRejectBlock)rejecter)
|
||||
|
||||
@end
|
||||
420
ios/OutLive/BackgroundTaskBridge.swift
Normal file
420
ios/OutLive/BackgroundTaskBridge.swift
Normal file
@@ -0,0 +1,420 @@
|
||||
//
|
||||
// BackgroundTaskBridge.swift
|
||||
// OutLive
|
||||
//
|
||||
// Native bridge responsible for scheduling and relaying iOS background tasks to the JS runtime.
|
||||
//
|
||||
|
||||
import BackgroundTasks
|
||||
import Foundation
|
||||
import React
|
||||
import UIKit
|
||||
|
||||
@objc(BackgroundTaskBridge)
|
||||
class BackgroundTaskBridge: RCTEventEmitter {
|
||||
|
||||
private enum TaskKind: String {
|
||||
case processing
|
||||
case refresh
|
||||
}
|
||||
|
||||
private let queue = DispatchQueue(label: "com.anonymous.digitalpilates.background.bridge", qos: .utility)
|
||||
|
||||
private var hasListeners = false
|
||||
private var identifier: String?
|
||||
private var kind: TaskKind = .processing
|
||||
private var requiresNetworkConnectivity = false
|
||||
private var requiresExternalPower = false
|
||||
private var defaultDelay: TimeInterval = 60 * 30 // 30 minutes
|
||||
private var isRegistered = false
|
||||
private var currentTask: BGTask?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
// 监听来自 AppDelegate 的后台任务通知
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleTaskNotification(_:)),
|
||||
name: NSNotification.Name("BackgroundTaskBridge.handleTask"),
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
override static func requiresMainQueueSetup() -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override func startObserving() {
|
||||
hasListeners = true
|
||||
}
|
||||
|
||||
override func stopObserving() {
|
||||
hasListeners = false
|
||||
}
|
||||
|
||||
@objc
|
||||
private func handleTaskNotification(_ notification: Notification) {
|
||||
guard #available(iOS 13.0, *),
|
||||
let userInfo = notification.userInfo,
|
||||
let task = userInfo["task"] as? BGTask else {
|
||||
return
|
||||
}
|
||||
|
||||
NSLog("[BackgroundTaskBridge] 收到来自 AppDelegate 的后台任务")
|
||||
handle(task: task)
|
||||
}
|
||||
|
||||
override func supportedEvents() -> [String]! {
|
||||
[
|
||||
"BackgroundTaskBridge.execute",
|
||||
"BackgroundTaskBridge.expire"
|
||||
]
|
||||
}
|
||||
|
||||
@objc
|
||||
func configure(
|
||||
_ options: NSDictionary,
|
||||
resolver: @escaping RCTPromiseResolveBlock,
|
||||
rejecter: @escaping RCTPromiseRejectBlock
|
||||
) {
|
||||
guard #available(iOS 13.0, *) else {
|
||||
rejecter(
|
||||
"BACKGROUND_TASK_UNAVAILABLE",
|
||||
"BGTaskScheduler requires iOS 13 or later.",
|
||||
nil
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
guard let identifier = options["identifier"] as? String,
|
||||
!identifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
rejecter("INVALID_IDENTIFIER", "A non-empty identifier is required.", nil)
|
||||
return
|
||||
}
|
||||
|
||||
let taskTypeRaw = (options["taskType"] as? String)?.lowercased()
|
||||
let taskKind = TaskKind(rawValue: taskTypeRaw ?? "") ?? .processing
|
||||
|
||||
let requiresNetwork = (options["requiresNetworkConnectivity"] as? Bool) ?? false
|
||||
let requiresPower = (options["requiresExternalPower"] as? Bool) ?? false
|
||||
let defaultDelaySeconds = (options["defaultDelay"] as? NSNumber)?.doubleValue
|
||||
|
||||
queue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
self.identifier = identifier
|
||||
self.kind = taskKind
|
||||
self.requiresNetworkConnectivity = requiresNetwork
|
||||
self.requiresExternalPower = requiresPower
|
||||
|
||||
if let delay = defaultDelaySeconds, delay > 0 {
|
||||
self.defaultDelay = delay
|
||||
}
|
||||
|
||||
do {
|
||||
try self.registerTaskIfNeeded(identifier: identifier)
|
||||
try self.scheduleTask(after: self.defaultDelay)
|
||||
resolver([
|
||||
"identifier": identifier,
|
||||
"scheduled": true,
|
||||
"defaultDelay": self.defaultDelay
|
||||
])
|
||||
} catch {
|
||||
rejecter("BACKGROUND_TASK_ERROR", error.localizedDescription, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
func schedule(
|
||||
_ options: NSDictionary,
|
||||
resolver: @escaping RCTPromiseResolveBlock,
|
||||
rejecter: @escaping RCTPromiseRejectBlock
|
||||
) {
|
||||
guard #available(iOS 13.0, *) else {
|
||||
rejecter(
|
||||
"BACKGROUND_TASK_UNAVAILABLE",
|
||||
"BGTaskScheduler requires iOS 13 or later.",
|
||||
nil
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
guard let identifier = identifier else {
|
||||
rejecter("NOT_CONFIGURED", "Call configure() before scheduling.", nil)
|
||||
return
|
||||
}
|
||||
|
||||
let delay = (options["delay"] as? NSNumber)?.doubleValue
|
||||
let effectiveDelay = (delay?.isFinite == true && delay! >= 0) ? delay! : defaultDelay
|
||||
|
||||
queue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
do {
|
||||
try self.registerTaskIfNeeded(identifier: identifier)
|
||||
try self.scheduleTask(after: effectiveDelay)
|
||||
resolver([
|
||||
"identifier": identifier,
|
||||
"scheduled": true,
|
||||
"delay": effectiveDelay
|
||||
])
|
||||
} catch {
|
||||
rejecter("BACKGROUND_TASK_ERROR", error.localizedDescription, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
func complete(
|
||||
_ success: NSNumber,
|
||||
rescheduleAfter: NSNumber?,
|
||||
resolver: @escaping RCTPromiseResolveBlock,
|
||||
rejecter: @escaping RCTPromiseRejectBlock
|
||||
) {
|
||||
guard #available(iOS 13.0, *) else {
|
||||
rejecter(
|
||||
"BACKGROUND_TASK_UNAVAILABLE",
|
||||
"BGTaskScheduler requires iOS 13 or later.",
|
||||
nil
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let shouldRescheduleAfter = rescheduleAfter?.doubleValue ?? defaultDelay
|
||||
let completionSuccess = success.boolValue
|
||||
|
||||
queue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
if let task = self.currentTask {
|
||||
task.setTaskCompleted(success: completionSuccess)
|
||||
self.currentTask = nil
|
||||
}
|
||||
|
||||
do {
|
||||
try self.scheduleTask(after: shouldRescheduleAfter)
|
||||
resolver([
|
||||
"rescheduled": true,
|
||||
"delay": shouldRescheduleAfter,
|
||||
"success": completionSuccess
|
||||
])
|
||||
} catch {
|
||||
rejecter("BACKGROUND_TASK_ERROR", error.localizedDescription, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
func cancelAll(
|
||||
_ resolver: @escaping RCTPromiseResolveBlock,
|
||||
rejecter: @escaping RCTPromiseRejectBlock
|
||||
) {
|
||||
guard #available(iOS 13.0, *) else {
|
||||
rejecter(
|
||||
"BACKGROUND_TASK_UNAVAILABLE",
|
||||
"BGTaskScheduler requires iOS 13 or later.",
|
||||
nil
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
queue.async {
|
||||
BGTaskScheduler.shared.cancelAllTaskRequests()
|
||||
resolver(["cancelled": true])
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
func getPendingRequests(
|
||||
_ resolver: @escaping RCTPromiseResolveBlock,
|
||||
rejecter: @escaping RCTPromiseRejectBlock
|
||||
) {
|
||||
guard #available(iOS 13.0, *) else {
|
||||
rejecter(
|
||||
"BACKGROUND_TASK_UNAVAILABLE",
|
||||
"BGTaskScheduler requires iOS 13 or later.",
|
||||
nil
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
BGTaskScheduler.shared.getPendingTaskRequests { [weak self] requests in
|
||||
guard let self else { return }
|
||||
|
||||
let mapped = requests
|
||||
.filter { request in
|
||||
guard let identifier = self.identifier else {
|
||||
return true
|
||||
}
|
||||
return request.identifier == identifier
|
||||
}
|
||||
.map { request -> [String: Any] in
|
||||
var payload: [String: Any] = [
|
||||
"identifier": request.identifier
|
||||
]
|
||||
|
||||
if let date = request.earliestBeginDate {
|
||||
payload["earliestBegin"] = date.timeIntervalSince1970 * 1000
|
||||
} else {
|
||||
payload["earliestBegin"] = NSNull()
|
||||
}
|
||||
|
||||
if let processingRequest = request as? BGProcessingTaskRequest {
|
||||
payload["requiresNetworkConnectivity"] = processingRequest.requiresNetworkConnectivity
|
||||
payload["requiresExternalPower"] = processingRequest.requiresExternalPower
|
||||
payload["type"] = "processing"
|
||||
} else {
|
||||
payload["type"] = "refresh"
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
resolver(mapped)
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
func backgroundRefreshStatus(
|
||||
_ resolver: @escaping RCTPromiseResolveBlock,
|
||||
rejecter: @escaping RCTPromiseRejectBlock
|
||||
) {
|
||||
guard Thread.isMainThread else {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.backgroundRefreshStatus(resolver, rejecter: rejecter)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let status = UIApplication.shared.backgroundRefreshStatus
|
||||
switch status {
|
||||
case .available:
|
||||
resolver("available")
|
||||
case .denied:
|
||||
resolver("denied")
|
||||
case .restricted:
|
||||
resolver("restricted")
|
||||
@unknown default:
|
||||
resolver("unknown")
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
func simulateLaunch(
|
||||
_ resolver: @escaping RCTPromiseResolveBlock,
|
||||
rejecter: @escaping RCTPromiseRejectBlock
|
||||
) {
|
||||
guard hasListeners else {
|
||||
rejecter("NO_LISTENERS", "No JS listeners registered for background events.", nil)
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
self.sendEvent(
|
||||
withName: "BackgroundTaskBridge.execute",
|
||||
body: [
|
||||
"identifier": self.identifier ?? "simulated",
|
||||
"timestamp": Date().timeIntervalSince1970 * 1000,
|
||||
"simulated": true
|
||||
]
|
||||
)
|
||||
resolver(["simulated": true])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private helpers
|
||||
|
||||
@available(iOS 13.0, *)
|
||||
private func registerTaskIfNeeded(identifier: String) throws {
|
||||
guard !isRegistered else { return }
|
||||
|
||||
// 注意:任务标识符已在 AppDelegate 中注册
|
||||
// BGTaskScheduler 要求所有任务必须在应用启动完成前注册
|
||||
// 这里只标记为已注册,实际的任务处理将通过 AppDelegate 中的注册生效
|
||||
isRegistered = true
|
||||
NSLog("[BackgroundTaskBridge] 使用 AppDelegate 中预注册的后台任务: \(identifier)")
|
||||
}
|
||||
|
||||
@available(iOS 13.0, *)
|
||||
private func scheduleTask(after delay: TimeInterval) throws {
|
||||
guard let identifier else {
|
||||
throw NSError(
|
||||
domain: "BackgroundTaskBridge",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Identifier is missing. Configure the bridge first."]
|
||||
)
|
||||
}
|
||||
|
||||
let request: BGTaskRequest
|
||||
|
||||
switch kind {
|
||||
case .processing:
|
||||
let processing = BGProcessingTaskRequest(identifier: identifier)
|
||||
processing.requiresNetworkConnectivity = requiresNetworkConnectivity
|
||||
processing.requiresExternalPower = requiresExternalPower
|
||||
processing.earliestBeginDate = Date(timeIntervalSinceNow: delay)
|
||||
request = processing
|
||||
case .refresh:
|
||||
let refresh = BGAppRefreshTaskRequest(identifier: identifier)
|
||||
refresh.earliestBeginDate = Date(timeIntervalSinceNow: delay)
|
||||
request = refresh
|
||||
}
|
||||
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
} catch {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 13.0, *)
|
||||
private func handle(task: BGTask) {
|
||||
queue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
self.currentTask = task
|
||||
|
||||
task.expirationHandler = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.queue.async {
|
||||
self.currentTask = nil
|
||||
}
|
||||
|
||||
guard self.hasListeners else { return }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.sendEvent(
|
||||
withName: "BackgroundTaskBridge.expire",
|
||||
body: [
|
||||
"identifier": self.identifier ?? "",
|
||||
"timestamp": Date().timeIntervalSince1970 * 1000
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
guard self.hasListeners else {
|
||||
task.setTaskCompleted(success: false)
|
||||
self.currentTask = nil
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.sendEvent(
|
||||
withName: "BackgroundTaskBridge.execute",
|
||||
body: [
|
||||
"identifier": self.identifier ?? "",
|
||||
"timestamp": Date().timeIntervalSince1970 * 1000
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import AsyncStorage from '@/utils/kvStore';
|
||||
import { BackgroundTaskManager } from './backgroundTaskManager';
|
||||
import { BackgroundTaskManager } from './backgroundTaskManagerV2';
|
||||
|
||||
/**
|
||||
* 后台任务调试工具
|
||||
|
||||
522
services/backgroundTaskManagerV2.ts
Normal file
522
services/backgroundTaskManagerV2.ts
Normal file
@@ -0,0 +1,522 @@
|
||||
import { NativeEventEmitter, NativeModules, Platform, type EmitterSubscription } from 'react-native';
|
||||
|
||||
import { listChallenges } from '@/services/challengesApi';
|
||||
import { resyncFastingNotifications } from '@/services/fastingNotifications';
|
||||
import { store } from '@/store';
|
||||
import { selectActiveFastingPlan, selectActiveFastingSchedule } from '@/store/fastingSlice';
|
||||
import { getWaterIntakeFromHealthKit } from '@/utils/health';
|
||||
import AsyncStorage from '@/utils/kvStore';
|
||||
import { log } from '@/utils/logger';
|
||||
import { ChallengeNotificationHelpers, WaterNotificationHelpers } from '@/utils/notificationHelpers';
|
||||
import { getWaterGoalFromStorage } from '@/utils/userPreferences';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export const BACKGROUND_TASK_IDENTIFIER = 'com.anonymous.digitalpilates.task';
|
||||
const DEFAULT_RESCHEDULE_INTERVAL_SECONDS = 60 * 30; // 30 minutes
|
||||
const BACKGROUND_EVENT = 'BackgroundTaskBridge.execute';
|
||||
const EXPIRATION_EVENT = 'BackgroundTaskBridge.expire';
|
||||
|
||||
const NativeBackgroundModule = NativeModules.BackgroundTaskBridge;
|
||||
|
||||
|
||||
const isIosBackgroundModuleAvailable = Platform.OS === 'ios' && NativeBackgroundModule;
|
||||
|
||||
// 检查通知权限
|
||||
async function checkNotificationPermissions(): Promise<boolean> {
|
||||
try {
|
||||
const Notifications = await import('expo-notifications');
|
||||
const { status } = await Notifications.getPermissionsAsync();
|
||||
return status === 'granted';
|
||||
} catch (error) {
|
||||
console.error('检查通知权限失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 执行喝水提醒后台任务
|
||||
async function executeWaterReminderTask(): Promise<void> {
|
||||
try {
|
||||
console.log('执行喝水提醒后台任务...');
|
||||
|
||||
let state;
|
||||
try {
|
||||
state = store.getState();
|
||||
} catch (error) {
|
||||
console.log('无法获取 Redux state,使用本地存储:', error);
|
||||
const dailyGoal = await getWaterGoalFromStorage();
|
||||
if (!dailyGoal || dailyGoal <= 0) {
|
||||
console.log('没有设置喝水目标,跳过喝水提醒');
|
||||
return;
|
||||
}
|
||||
await sendSimpleWaterReminder();
|
||||
return;
|
||||
}
|
||||
|
||||
const waterStats = state.water?.todayStats;
|
||||
const userProfile = state.user?.profile;
|
||||
|
||||
let dailyGoal = waterStats?.dailyGoal ?? 0;
|
||||
if (!dailyGoal || dailyGoal <= 0) {
|
||||
dailyGoal = await getWaterGoalFromStorage();
|
||||
}
|
||||
|
||||
if (!dailyGoal || dailyGoal <= 0) {
|
||||
console.log('没有设置喝水目标,跳过喝水提醒');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentHour = new Date().getHours();
|
||||
const userName = userProfile?.name || '朋友';
|
||||
|
||||
const todayRange = {
|
||||
startDate: dayjs().startOf('day').toDate().toISOString(),
|
||||
endDate: dayjs().endOf('day').toDate().toISOString()
|
||||
};
|
||||
|
||||
let totalAmount = waterStats?.totalAmount ?? 0;
|
||||
let completionRate = waterStats?.completionRate ?? (dailyGoal > 0 ? (totalAmount / dailyGoal) * 100 : 0);
|
||||
|
||||
try {
|
||||
const healthKitRecords = await getWaterIntakeFromHealthKit(todayRange);
|
||||
if (Array.isArray(healthKitRecords) && healthKitRecords.length > 0) {
|
||||
totalAmount = healthKitRecords.reduce((sum: number, record: unknown) => {
|
||||
if (record && typeof record === 'object' && 'value' in record) {
|
||||
const { value } = record as { value?: number | string };
|
||||
const numericValue = Number(value ?? 0);
|
||||
return Number.isFinite(numericValue) ? sum + numericValue : sum;
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
completionRate = Math.min((totalAmount / dailyGoal) * 100, 100);
|
||||
} else {
|
||||
console.log('HealthKit 未返回今日饮水记录,使用应用内缓存数据');
|
||||
}
|
||||
} catch (healthKitError) {
|
||||
console.error('从HealthKit获取饮水记录失败,使用应用内缓存数据:', healthKitError);
|
||||
}
|
||||
|
||||
const todayWaterStats = {
|
||||
totalAmount,
|
||||
dailyGoal,
|
||||
completionRate: Number.isFinite(completionRate) ? completionRate : 0
|
||||
};
|
||||
|
||||
const notificationSent = await WaterNotificationHelpers.checkWaterGoalAndNotify(
|
||||
userName,
|
||||
todayWaterStats,
|
||||
currentHour
|
||||
);
|
||||
|
||||
if (notificationSent) {
|
||||
console.log('后台喝水提醒通知已发送');
|
||||
await AsyncStorage.setItem('@last_background_water_check', Date.now().toString());
|
||||
} else {
|
||||
console.log('无需发送后台喝水提醒通知');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('执行喝水提醒后台任务失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeChallengeReminderTask(): Promise<void> {
|
||||
try {
|
||||
console.log('执行挑战鼓励提醒后台任务...');
|
||||
|
||||
let userName = '朋友';
|
||||
try {
|
||||
const state = store.getState();
|
||||
const normalizedUserName = state.user?.profile?.name?.trim();
|
||||
userName = normalizedUserName && normalizedUserName.length > 0 ? normalizedUserName : '朋友';
|
||||
} catch (error) {
|
||||
console.log('无法获取用户名,使用默认值:', error);
|
||||
}
|
||||
|
||||
const challenges = await listChallenges();
|
||||
const joinedChallenges = challenges.filter((challenge) => challenge.isJoined && challenge.progress);
|
||||
|
||||
if (!joinedChallenges.length) {
|
||||
console.log('没有加入的挑战或挑战没有进度,跳过挑战提醒');
|
||||
return;
|
||||
}
|
||||
|
||||
const todayKey = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const eligibleChallenges = [];
|
||||
for (const challenge of joinedChallenges) {
|
||||
const progress = challenge.progress;
|
||||
if (!progress || progress.checkedInToday) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const storageKey = `@challenge_encouragement_sent:${challenge.id}`;
|
||||
const lastSent = await AsyncStorage.getItem(storageKey);
|
||||
if (lastSent === todayKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
eligibleChallenges.push(challenge);
|
||||
}
|
||||
|
||||
if (eligibleChallenges.length > 0) {
|
||||
const randomIndex = Math.floor(Math.random() * eligibleChallenges.length);
|
||||
const selectedChallenge = eligibleChallenges[randomIndex];
|
||||
|
||||
try {
|
||||
await ChallengeNotificationHelpers.sendEncouragementNotification({
|
||||
userName,
|
||||
challengeTitle: selectedChallenge.title,
|
||||
challengeId: selectedChallenge.id,
|
||||
});
|
||||
|
||||
const storageKey = `@challenge_encouragement_sent:${selectedChallenge.id}`;
|
||||
await AsyncStorage.setItem(storageKey, todayKey);
|
||||
|
||||
console.log(`已随机选择并发送挑战鼓励通知: ${selectedChallenge.title}`);
|
||||
} catch (notificationError) {
|
||||
console.error('发送挑战鼓励通知失败:', notificationError);
|
||||
}
|
||||
} else {
|
||||
console.log('没有符合条件的挑战需要发送鼓励通知');
|
||||
}
|
||||
|
||||
console.log('挑战鼓励提醒后台任务完成');
|
||||
} catch (error) {
|
||||
console.error('执行挑战鼓励提醒后台任务失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 执行断食通知后台任务
|
||||
async function executeFastingNotificationTask(): Promise<void> {
|
||||
try {
|
||||
console.log('执行断食通知后台任务...');
|
||||
|
||||
let state;
|
||||
try {
|
||||
state = store.getState();
|
||||
} catch (error) {
|
||||
console.log('无法获取 Redux state,跳过断食通知任务:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeSchedule = selectActiveFastingSchedule(state);
|
||||
const activePlan = selectActiveFastingPlan(state);
|
||||
|
||||
if (!activeSchedule || !activePlan) {
|
||||
console.log('没有激活的断食计划,跳过断食通知任务');
|
||||
return;
|
||||
}
|
||||
|
||||
const end = dayjs(activeSchedule.endISO);
|
||||
if (end.isBefore(dayjs())) {
|
||||
console.log('断食计划已结束,跳过断食通知任务');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('正在同步断食通知...', {
|
||||
planId: activePlan.id,
|
||||
start: activeSchedule.startISO,
|
||||
end: activeSchedule.endISO,
|
||||
});
|
||||
|
||||
await resyncFastingNotifications({
|
||||
schedule: activeSchedule,
|
||||
plan: activePlan,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
console.log('断食通知后台同步完成');
|
||||
} catch (error) {
|
||||
console.error('执行断食通知后台任务失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 发送测试通知以验证后台任务执行
|
||||
async function sendTestNotification(): Promise<void> {
|
||||
try {
|
||||
console.log('发送后台任务测试通知...');
|
||||
|
||||
const Notifications = await import('expo-notifications');
|
||||
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: '后台任务测试',
|
||||
body: `后台任务正在执行中... 时间: ${new Date().toLocaleTimeString()}`,
|
||||
data: {
|
||||
type: 'background_task_test',
|
||||
timestamp: Date.now()
|
||||
}
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
|
||||
console.log('后台任务测试通知发送成功');
|
||||
await AsyncStorage.setItem('@last_background_test_notification', Date.now().toString());
|
||||
} catch (error) {
|
||||
console.error('发送测试通知失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendSimpleWaterReminder(): Promise<void> {
|
||||
try {
|
||||
const userName = '朋友';
|
||||
const Notifications = await import('expo-notifications');
|
||||
|
||||
const notificationId = await Notifications.scheduleNotificationAsync({
|
||||
content: {
|
||||
title: '💧 该喝水啦!',
|
||||
body: `${userName},记得补充水分,保持身体健康~`,
|
||||
data: {
|
||||
type: 'water_reminder',
|
||||
url: '/statistics'
|
||||
},
|
||||
sound: 'default',
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
|
||||
console.log('简单喝水提醒已发送,ID:', notificationId);
|
||||
} catch (error) {
|
||||
console.error('发送简单喝水提醒失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeBackgroundTasks(): Promise<void> {
|
||||
console.log('开始执行后台任务...');
|
||||
|
||||
try {
|
||||
const hasPermission = await checkNotificationPermissions();
|
||||
if (!hasPermission) {
|
||||
console.log('没有通知权限,跳过后台任务');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const state = store.getState();
|
||||
if (!state) {
|
||||
console.log('Redux store 未初始化,跳过后台任务');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('无法访问 Redux store,跳过后台任务:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
const testNotificationsEnabled = await AsyncStorage.getItem('@background_test_notifications_enabled') === 'true';
|
||||
if (testNotificationsEnabled) {
|
||||
await sendTestNotification();
|
||||
}
|
||||
|
||||
await executeWaterReminderTask();
|
||||
await executeChallengeReminderTask();
|
||||
await executeFastingNotificationTask();
|
||||
|
||||
console.log('后台任务执行完成');
|
||||
} catch (error) {
|
||||
console.error('执行后台任务失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export class BackgroundTaskManagerV2 {
|
||||
private static instance: BackgroundTaskManagerV2;
|
||||
|
||||
private isInitialized = false;
|
||||
private executingPromise: Promise<void> | null = null;
|
||||
private eventSubscription?: EmitterSubscription;
|
||||
private expirationSubscription?: EmitterSubscription;
|
||||
|
||||
static getInstance(): BackgroundTaskManagerV2 {
|
||||
if (!BackgroundTaskManagerV2.instance) {
|
||||
BackgroundTaskManagerV2.instance = new BackgroundTaskManagerV2();
|
||||
}
|
||||
return BackgroundTaskManagerV2.instance;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isIosBackgroundModuleAvailable) {
|
||||
log.warn('[BackgroundTaskManagerV2] iOS 原生后台模块不可用,跳过初始化');
|
||||
this.isInitialized = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const emitter = new NativeEventEmitter(NativeBackgroundModule);
|
||||
|
||||
this.eventSubscription = emitter.addListener(BACKGROUND_EVENT, (payload) => {
|
||||
log.info('[BackgroundTaskManagerV2] 收到后台任务事件', payload);
|
||||
this.handleBackgroundExecution();
|
||||
});
|
||||
|
||||
this.expirationSubscription = emitter.addListener(EXPIRATION_EVENT, (payload) => {
|
||||
log.warn('[BackgroundTaskManagerV2] 后台任务在完成前即将过期', payload);
|
||||
});
|
||||
|
||||
try {
|
||||
await NativeBackgroundModule.configure({
|
||||
identifier: BACKGROUND_TASK_IDENTIFIER,
|
||||
taskType: 'processing',
|
||||
requiresNetworkConnectivity: false,
|
||||
requiresExternalPower: false,
|
||||
defaultDelay: DEFAULT_RESCHEDULE_INTERVAL_SECONDS,
|
||||
});
|
||||
this.isInitialized = true;
|
||||
log.info('[BackgroundTaskManagerV2] 已初始化并注册 iOS 后台任务');
|
||||
} catch (error: any) {
|
||||
// BGTaskSchedulerErrorDomain 错误码 1 表示后台任务功能不可用
|
||||
// 这在模拟器上是正常的,因为模拟器不完全支持后台任务
|
||||
const errorMessage = error?.message || String(error);
|
||||
const isBGTaskUnavailable = errorMessage.includes('BGTaskSchedulerErrorDomain') &&
|
||||
errorMessage.includes('错误1');
|
||||
|
||||
if (isBGTaskUnavailable) {
|
||||
log.warn('[BackgroundTaskManagerV2] 后台任务功能在当前环境不可用(模拟器限制),将在真机上正常工作');
|
||||
this.removeListeners();
|
||||
this.isInitialized = false;
|
||||
// 不抛出错误,因为这是预期行为
|
||||
return;
|
||||
}
|
||||
|
||||
log.error('[BackgroundTaskManagerV2] 初始化失败', error);
|
||||
this.removeListeners();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleBackgroundExecution(): Promise<void> {
|
||||
if (this.executingPromise) {
|
||||
log.info('[BackgroundTaskManagerV2] 已有后台任务在执行,忽略重复触发');
|
||||
return;
|
||||
}
|
||||
|
||||
this.executingPromise = executeBackgroundTasks()
|
||||
.then(async () => {
|
||||
if (isIosBackgroundModuleAvailable) {
|
||||
try {
|
||||
await NativeBackgroundModule.complete(true, DEFAULT_RESCHEDULE_INTERVAL_SECONDS);
|
||||
} catch (error) {
|
||||
log.error('[BackgroundTaskManagerV2] 标记后台任务成功完成失败', error);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(async (error) => {
|
||||
log.error('[BackgroundTaskManagerV2] 后台任务执行失败', error);
|
||||
if (isIosBackgroundModuleAvailable) {
|
||||
try {
|
||||
await NativeBackgroundModule.complete(false, DEFAULT_RESCHEDULE_INTERVAL_SECONDS);
|
||||
} catch (completionError) {
|
||||
log.error('[BackgroundTaskManagerV2] 标记后台任务失败状态时出错', completionError);
|
||||
}
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.executingPromise = null;
|
||||
});
|
||||
|
||||
await this.executingPromise;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!isIosBackgroundModuleAvailable) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await NativeBackgroundModule.cancelAll();
|
||||
} catch (error) {
|
||||
log.error('[BackgroundTaskManagerV2] 停止后台任务失败', error);
|
||||
} finally {
|
||||
this.removeListeners();
|
||||
this.isInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
private removeListeners(): void {
|
||||
this.eventSubscription?.remove();
|
||||
this.expirationSubscription?.remove();
|
||||
this.eventSubscription = undefined;
|
||||
this.expirationSubscription = undefined;
|
||||
}
|
||||
|
||||
async getStatus(): Promise<string> {
|
||||
if (!isIosBackgroundModuleAvailable) {
|
||||
return Platform.OS;
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await NativeBackgroundModule.backgroundRefreshStatus();
|
||||
return status;
|
||||
} catch (error) {
|
||||
log.error('[BackgroundTaskManagerV2] 获取后台任务状态失败', error);
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
async checkStatus(): Promise<string> {
|
||||
const status = await this.getStatus();
|
||||
switch (status) {
|
||||
case 'available':
|
||||
return '可用';
|
||||
case 'restricted':
|
||||
return '受限制';
|
||||
case 'denied':
|
||||
return '被拒绝';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
}
|
||||
|
||||
async triggerTaskForTesting(): Promise<void> {
|
||||
if (!isIosBackgroundModuleAvailable) {
|
||||
await executeBackgroundTasks();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await NativeBackgroundModule.simulateLaunch();
|
||||
} catch (error) {
|
||||
log.error('[BackgroundTaskManagerV2] 模拟后台任务触发失败', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async testBackgroundTask(): Promise<void> {
|
||||
await this.triggerTaskForTesting();
|
||||
}
|
||||
|
||||
async getLastBackgroundCheckTime(): Promise<number | null> {
|
||||
try {
|
||||
const lastCheck = await AsyncStorage.getItem('@last_background_water_check');
|
||||
return lastCheck ? parseInt(lastCheck, 10) : null;
|
||||
} catch (error) {
|
||||
console.error('获取最后后台检查时间失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getPendingRequests(): Promise<any[]> {
|
||||
if (!isIosBackgroundModuleAvailable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const requests = await NativeBackgroundModule.getPendingRequests();
|
||||
return Array.isArray(requests) ? requests : [];
|
||||
} catch (error) {
|
||||
log.error('[BackgroundTaskManagerV2] 获取待处理的后台任务请求失败', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type BackgroundTaskEvent = {
|
||||
taskId: string;
|
||||
timestamp: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const BackgroundTaskManager = BackgroundTaskManagerV2;
|
||||
@@ -72,88 +72,198 @@ export async function analyzeWorkoutAndSendNotification(workout: WorkoutData): P
|
||||
}
|
||||
}
|
||||
|
||||
interface WorkoutMessageConfig {
|
||||
emoji: string;
|
||||
titleTemplate: string;
|
||||
bodyTemplate: (params: {
|
||||
workoutType: string;
|
||||
durationMinutes: number;
|
||||
calories: number;
|
||||
distanceKm?: string;
|
||||
averageHeartRate?: number;
|
||||
mets?: number;
|
||||
}) => string;
|
||||
encouragement: string;
|
||||
dataExtractor?: (workout: WorkoutData, metrics: any) => Record<string, any>;
|
||||
}
|
||||
|
||||
const WORKOUT_MESSAGES: Record<string, WorkoutMessageConfig> = {
|
||||
running: {
|
||||
emoji: '🏃♂️',
|
||||
titleTemplate: '跑步完成!',
|
||||
bodyTemplate: ({ durationMinutes, calories, averageHeartRate }) => {
|
||||
let body = `您完成了${durationMinutes}分钟的跑步`;
|
||||
if (calories > 0) {
|
||||
body += `,消耗${calories}千卡`;
|
||||
}
|
||||
if (averageHeartRate) {
|
||||
body += `(平均心率${averageHeartRate}次/分)`;
|
||||
}
|
||||
return body + '!';
|
||||
},
|
||||
encouragement: '坚持就是胜利!💪',
|
||||
dataExtractor: (workout, metrics) => ({
|
||||
heartRateContext: metrics.averageHeartRate ? 'provided' : 'none'
|
||||
})
|
||||
},
|
||||
cycling: {
|
||||
emoji: '🚴♂️',
|
||||
titleTemplate: '骑行完成!',
|
||||
bodyTemplate: ({ durationMinutes, calories, distanceKm }) => {
|
||||
let body = `${durationMinutes}分钟骑行`;
|
||||
if (distanceKm) {
|
||||
body += `,行程${distanceKm}公里`;
|
||||
}
|
||||
if (calories > 0) {
|
||||
body += `,消耗${calories}千卡`;
|
||||
}
|
||||
return body + '!';
|
||||
},
|
||||
encouragement: '追寻风的自由!🌟'
|
||||
},
|
||||
swimming: {
|
||||
emoji: '🏊♂️',
|
||||
titleTemplate: '游泳完成!',
|
||||
bodyTemplate: ({ durationMinutes }) => {
|
||||
return `水中${durationMinutes}分钟的锻炼完成!`;
|
||||
},
|
||||
encouragement: '全身肌肉都得到了锻炼!💦'
|
||||
},
|
||||
yoga: {
|
||||
emoji: '🧘♀️',
|
||||
titleTemplate: '瑜伽完成!',
|
||||
bodyTemplate: ({ durationMinutes }) => {
|
||||
return `${durationMinutes}分钟的瑜伽练习`;
|
||||
},
|
||||
encouragement: '身心合一,平静致远!🌸'
|
||||
},
|
||||
functionalstrengthtraining: {
|
||||
emoji: '💪',
|
||||
titleTemplate: '力量训练完成!',
|
||||
bodyTemplate: ({ durationMinutes, calories, mets }) => {
|
||||
let body = `${durationMinutes}分钟力量训练`;
|
||||
if (calories > 0) {
|
||||
body += `,消耗${calories}千卡`;
|
||||
}
|
||||
if (mets && mets > 6) {
|
||||
body += '(高强度)';
|
||||
}
|
||||
return body + '!';
|
||||
},
|
||||
encouragement: '肌肉正在变得更强壮!🔥',
|
||||
dataExtractor: (workout, metrics) => ({
|
||||
strengthLevel: metrics?.mets && metrics.mets > 6 ? 'high' : 'moderate'
|
||||
})
|
||||
},
|
||||
traditionalstrengthtraining: {
|
||||
emoji: '💪',
|
||||
titleTemplate: '力量训练完成!',
|
||||
bodyTemplate: ({ durationMinutes, calories, mets }) => {
|
||||
let body = `${durationMinutes}分钟力量训练`;
|
||||
if (calories > 0) {
|
||||
body += `,消耗${calories}千卡`;
|
||||
}
|
||||
if (mets && mets > 6) {
|
||||
body += '(高强度)';
|
||||
}
|
||||
return body + '!';
|
||||
},
|
||||
encouragement: '肌肉正在变得更强壮!🔥',
|
||||
dataExtractor: (workout, metrics) => ({
|
||||
strengthLevel: metrics?.mets && metrics.mets > 6 ? 'high' : 'moderate'
|
||||
})
|
||||
},
|
||||
highintensityintervaltraining: {
|
||||
emoji: '🔥',
|
||||
titleTemplate: 'HIIT训练完成!',
|
||||
bodyTemplate: ({ durationMinutes, calories }) => {
|
||||
let body = `${durationMinutes}分钟高强度间歇训练`;
|
||||
if (calories > 0) {
|
||||
body += `,消耗${calories}千卡`;
|
||||
}
|
||||
return body + '!';
|
||||
},
|
||||
encouragement: '心肺功能显著提升!⚡',
|
||||
dataExtractor: (workout, metrics) => ({
|
||||
hiitCompleted: true
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
function getWorkoutMessage(workoutTypeString?: string): WorkoutMessageConfig | null {
|
||||
if (!workoutTypeString) return null;
|
||||
|
||||
const normalizedType = workoutTypeString.toLowerCase();
|
||||
if (normalizedType.includes('strength')) {
|
||||
return WORKOUT_MESSAGES.traditionalstrengthtraining;
|
||||
}
|
||||
|
||||
return WORKOUT_MESSAGES[normalizedType] || null;
|
||||
}
|
||||
|
||||
function generateEncouragementMessage(
|
||||
workout: WorkoutData,
|
||||
metrics: any
|
||||
): WorkoutEncouragementMessage {
|
||||
const workoutType = getWorkoutTypeDisplayName(workout.workoutActivityTypeString);
|
||||
const durationMinutes = Math.round(workout.duration / 60);
|
||||
if (!workout) {
|
||||
return {
|
||||
title: '锻炼完成!',
|
||||
body: '恭喜您完成锻炼!',
|
||||
data: {}
|
||||
};
|
||||
}
|
||||
|
||||
const workoutType = getWorkoutTypeDisplayName(workout.workoutActivityTypeString) || '锻炼';
|
||||
const durationMinutes = workout.duration ? Math.round(workout.duration / 60) : 0;
|
||||
const calories = workout.totalEnergyBurned ? Math.round(workout.totalEnergyBurned) : 0;
|
||||
const distanceKm = workout.totalDistance && workout.totalDistance > 0
|
||||
? (workout.totalDistance / 1000).toFixed(2)
|
||||
: undefined;
|
||||
|
||||
// 基于锻炼类型和指标生成个性化消息
|
||||
let title = '锻炼完成!';
|
||||
const messageConfig = getWorkoutMessage(workout.workoutActivityTypeString);
|
||||
let title = '🎯 锻炼完成!';
|
||||
let body = '';
|
||||
let data: Record<string, any> = {};
|
||||
const data: Record<string, any> = {};
|
||||
|
||||
switch (workout.workoutActivityTypeString?.toLowerCase()) {
|
||||
case 'running':
|
||||
title = '🏃♂️ 跑步完成!';
|
||||
body = `太棒了!您刚刚完成了${durationMinutes}分钟的跑步,消耗了约${calories}千卡热量。`;
|
||||
if (metrics.averageHeartRate) {
|
||||
body += `平均心率${metrics.averageHeartRate}次/分。`;
|
||||
if (messageConfig) {
|
||||
title = `${messageConfig.emoji} ${messageConfig.titleTemplate}`;
|
||||
body = messageConfig.bodyTemplate({
|
||||
workoutType,
|
||||
durationMinutes,
|
||||
calories,
|
||||
distanceKm,
|
||||
averageHeartRate: metrics?.averageHeartRate,
|
||||
mets: metrics?.mets
|
||||
});
|
||||
body += messageConfig.encouragement;
|
||||
|
||||
if (messageConfig.dataExtractor) {
|
||||
Object.assign(data, messageConfig.dataExtractor(workout, metrics));
|
||||
}
|
||||
body += '坚持运动让身体更健康!💪';
|
||||
break;
|
||||
|
||||
case 'cycling':
|
||||
title = '🚴♂️ 骑行完成!';
|
||||
body = `骑行${durationMinutes}分钟完成!消耗了约${calories}千卡热量。`;
|
||||
if (workout.totalDistance) {
|
||||
const distanceKm = (workout.totalDistance / 1000).toFixed(2);
|
||||
body += `骑行距离${distanceKm}公里。`;
|
||||
} else {
|
||||
body = `${workoutType} ${durationMinutes}分钟完成!`;
|
||||
if (calories > 0) {
|
||||
body += `消耗${calories}千卡热量。`;
|
||||
}
|
||||
body += '享受骑行的自由吧!🌟';
|
||||
break;
|
||||
|
||||
case 'swimming':
|
||||
title = '🏊♂️ 游泳完成!';
|
||||
body = `游泳${durationMinutes}分钟完成!消耗了约${calories}千卡热量。`;
|
||||
body += '全身运动效果极佳,继续保持!💦';
|
||||
break;
|
||||
|
||||
case 'yoga':
|
||||
title = '🧘♀️ 瑜伽完成!';
|
||||
body = `${durationMinutes}分钟的瑜伽练习完成!提升了柔韧性和内心平静。`;
|
||||
body += '继续保持这份宁静!🌸';
|
||||
break;
|
||||
|
||||
case 'functionalstrengthtraining':
|
||||
case 'traditionalstrengthtraining':
|
||||
title = '💪 力量训练完成!';
|
||||
body = `力量训练${durationMinutes}分钟完成!消耗了约${calories}千卡热量。`;
|
||||
if (metrics.mets && metrics.mets > 6) {
|
||||
body += '高强度训练,效果显著!🔥';
|
||||
}
|
||||
body += '肌肉正在变得更强壮!';
|
||||
break;
|
||||
|
||||
case 'highintensityintervaltraining':
|
||||
title = '🔥 HIIT训练完成!';
|
||||
body = `高强度间歇训练${durationMinutes}分钟完成!消耗了约${calories}千卡热量。`;
|
||||
body += '心肺功能得到有效提升,您的努力值得称赞!⚡';
|
||||
break;
|
||||
|
||||
default:
|
||||
title = '🎯 锻炼完成!';
|
||||
body = `${workoutType}${durationMinutes}分钟完成!消耗了约${calories}千卡热量。`;
|
||||
body += '坚持运动,健康生活!🌟';
|
||||
break;
|
||||
body += '坚持运动,收获健康!🌟';
|
||||
}
|
||||
|
||||
// 添加心率区间分析(如果有心率数据)
|
||||
if (metrics.heartRateZones && metrics.heartRateZones.length > 0) {
|
||||
if (metrics?.heartRateZones && Array.isArray(metrics.heartRateZones) && metrics.heartRateZones.length > 0) {
|
||||
try {
|
||||
const dominantZone = metrics.heartRateZones.reduce((prev: any, current: any) =>
|
||||
current.durationMinutes > prev.durationMinutes ? current : prev
|
||||
current?.durationMinutes > prev?.durationMinutes ? current : prev
|
||||
);
|
||||
|
||||
if (dominantZone.durationMinutes > 5) {
|
||||
if (dominantZone?.durationMinutes > 5) {
|
||||
data.heartRateZone = dominantZone.key;
|
||||
data.heartRateZoneLabel = dominantZone.label;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('心率区间分析失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加锻炼强度评估
|
||||
if (metrics.mets) {
|
||||
if (metrics?.mets) {
|
||||
data.intensity = metrics.mets < 3 ? 'low' : metrics.mets < 6 ? 'moderate' : 'high';
|
||||
}
|
||||
|
||||
|
||||
101
utils/health.ts
101
utils/health.ts
@@ -1647,6 +1647,7 @@ const WORKOUT_TYPE_LABELS: Record<string, string> = {
|
||||
cycling: '骑行',
|
||||
swimming: '游泳',
|
||||
yoga: '瑜伽',
|
||||
pilates: '普拉提',
|
||||
functionalstrengthtraining: '功能性力量训练',
|
||||
traditionalstrengthtraining: '传统力量训练',
|
||||
crosstraining: '交叉训练',
|
||||
@@ -1654,7 +1655,6 @@ const WORKOUT_TYPE_LABELS: Record<string, string> = {
|
||||
highintensityintervaltraining: '高强度间歇训练',
|
||||
flexibility: '柔韧性训练',
|
||||
cooldown: '放松运动',
|
||||
pilates: '普拉提',
|
||||
dance: '舞蹈',
|
||||
danceinspiredtraining: '舞蹈训练',
|
||||
cardiodance: '有氧舞蹈',
|
||||
@@ -1664,11 +1664,72 @@ const WORKOUT_TYPE_LABELS: Record<string, string> = {
|
||||
underwaterdiving: '水下潜水',
|
||||
pickleball: '匹克球',
|
||||
americanfootball: '美式橄榄球',
|
||||
australianfootball: '澳式橄榄球',
|
||||
archery: '射箭',
|
||||
badminton: '羽毛球',
|
||||
baseball: '棒球',
|
||||
basketball: '篮球',
|
||||
tennis: '网球',
|
||||
bowling: '保龄球',
|
||||
boxing: '拳击',
|
||||
climbing: '攀岩',
|
||||
cricket: '板球',
|
||||
curling: '冰壶',
|
||||
elliptical: '椭圆机',
|
||||
equestriansports: '马术',
|
||||
fencing: '击剑',
|
||||
fishing: '钓鱼',
|
||||
golf: '高尔夫',
|
||||
gymnastics: '体操',
|
||||
handball: '手球',
|
||||
hiking: '徒步',
|
||||
hockey: '曲棍球',
|
||||
hunting: '狩猎',
|
||||
lacrosse: '长曲棍球',
|
||||
martialarts: '武术',
|
||||
mindandbody: '身心运动',
|
||||
mixedmetaboliccardiotraining: '混合代谢有氧训练',
|
||||
paddlesports: '桨类运动',
|
||||
play: '自由活动',
|
||||
preparationandrecovery: '准备与恢复',
|
||||
racquetball: '壁球',
|
||||
rowing: '划船',
|
||||
rugby: '橄榄球',
|
||||
sailing: '帆船',
|
||||
skatingsports: '滑冰运动',
|
||||
snowsports: '雪上运动',
|
||||
soccer: '足球',
|
||||
softball: '垒球',
|
||||
squash: '壁球',
|
||||
stairclimbing: '爬楼梯',
|
||||
surfing: '冲浪',
|
||||
surfingsports: '冲浪运动',
|
||||
tabletennis: '乒乓球',
|
||||
tennis: '网球',
|
||||
trackandfield: '田径',
|
||||
volleyball: '排球',
|
||||
waterfitness: '水中健身',
|
||||
watersports: '水上运动',
|
||||
weighttraining: '重量训练',
|
||||
wrestling: '摔跤',
|
||||
barre: '芭蕾杆训练',
|
||||
corebTraining: '核心训练',
|
||||
jumprope: '跳绳',
|
||||
kickboxing: '踢拳',
|
||||
taichi: '太极',
|
||||
taichichuan: '太极拳',
|
||||
nordicwalking: '北欧式行走',
|
||||
frisbee: '飞盘',
|
||||
ultimatefrisbee: '极限飞盘',
|
||||
mountainbiking: '山地自行车',
|
||||
roadcycling: '公路骑行',
|
||||
virtualrunning: '虚拟跑步',
|
||||
virtualcycling: '虚拟骑行',
|
||||
trailrunning: '越野跑',
|
||||
treadmillrunning: '跑步机跑步',
|
||||
trackrunning: '场地跑',
|
||||
openwaterswimming: '公开水域游泳',
|
||||
poolswimming: '游泳池游泳',
|
||||
apneadiving: '自由潜',
|
||||
functionalStrengthTraining: '功能性力量训练',
|
||||
other: '其他运动',
|
||||
};
|
||||
@@ -1730,6 +1791,42 @@ export function getWorkoutTypeDisplayName(workoutType: WorkoutActivityType | str
|
||||
return '放松运动';
|
||||
case WorkoutActivityType.Tennis:
|
||||
return '网球';
|
||||
case WorkoutActivityType.Basketball:
|
||||
return '篮球';
|
||||
case WorkoutActivityType.Soccer:
|
||||
return '足球';
|
||||
case WorkoutActivityType.Baseball:
|
||||
return '棒球';
|
||||
case WorkoutActivityType.Volleyball:
|
||||
return '排球';
|
||||
case WorkoutActivityType.Dance:
|
||||
return '舞蹈';
|
||||
case WorkoutActivityType.DanceInspiredTraining:
|
||||
return '舞蹈训练';
|
||||
case WorkoutActivityType.Elliptical:
|
||||
return '椭圆机';
|
||||
case WorkoutActivityType.Rowing:
|
||||
return '划船';
|
||||
case WorkoutActivityType.StairClimbing:
|
||||
return '爬楼梯';
|
||||
case WorkoutActivityType.Hiking:
|
||||
return '徒步';
|
||||
case WorkoutActivityType.Climbing:
|
||||
return '攀岩';
|
||||
case WorkoutActivityType.MindAndBody:
|
||||
return '身心运动';
|
||||
case WorkoutActivityType.MartialArts:
|
||||
return '武术';
|
||||
case WorkoutActivityType.Golf:
|
||||
return '高尔夫';
|
||||
case WorkoutActivityType.Boxing:
|
||||
return '拳击';
|
||||
case WorkoutActivityType.SnowSports:
|
||||
return '雪上运动';
|
||||
case WorkoutActivityType.SurfingSports:
|
||||
return '冲浪运动';
|
||||
case WorkoutActivityType.WaterFitness:
|
||||
return '水中健身';
|
||||
case WorkoutActivityType.Other:
|
||||
return '其他运动';
|
||||
default:
|
||||
|
||||
Reference in New Issue
Block a user