fix(ios): 修复HealthKit类型安全性并优化HRV通知频率
将HealthKit数据类型从强制解包改为可选类型,避免潜在的运行时崩溃。所有数据类型访问现在都通过guard语句进行安全检查,当类型不可用时返回明确的错误信息。同时修复了活动摘要日期计算错误,确保每个摘要使用正确的日期。 HRV压力通知的最小间隔从4小时缩短至2小时,并移除了每日一次的限制,允许更及时的压力状态提醒。 BREAKING CHANGE: HealthKit数据类型API现在可能返回"TYPE_NOT_AVAILABLE"错误,调用方需要处理此新错误类型
This commit is contained in:
@@ -23,21 +23,56 @@ class HealthKitManager: RCTEventEmitter {
|
||||
|
||||
/// For reading
|
||||
private struct ReadTypes {
|
||||
static let sleep = HKObjectType.categoryType(forIdentifier: .sleepAnalysis)!
|
||||
static let stepCount = HKObjectType.quantityType(forIdentifier: .stepCount)!
|
||||
static let heartRate = HKObjectType.quantityType(forIdentifier: .heartRate)!
|
||||
static let heartRateVariability = HKObjectType.quantityType(forIdentifier: .heartRateVariabilitySDNN)!
|
||||
static let activeEnergyBurned = HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)!
|
||||
static let basalEnergyBurned = HKObjectType.quantityType(forIdentifier: .basalEnergyBurned)!
|
||||
static let appleExerciseTime = HKObjectType.quantityType(forIdentifier: .appleExerciseTime)!
|
||||
static let appleStandTime = HKObjectType.categoryType(forIdentifier: .appleStandHour)!
|
||||
static let oxygenSaturation = HKObjectType.quantityType(forIdentifier: .oxygenSaturation)!
|
||||
static let activitySummary = HKObjectType.activitySummaryType()
|
||||
static let dietaryWater = HKObjectType.quantityType(forIdentifier: .dietaryWater)!
|
||||
static let workout = HKObjectType.workoutType()
|
||||
static var sleep: HKCategoryType? {
|
||||
return HKObjectType.categoryType(forIdentifier: .sleepAnalysis)
|
||||
}
|
||||
static var stepCount: HKQuantityType? {
|
||||
return HKObjectType.quantityType(forIdentifier: .stepCount)
|
||||
}
|
||||
static var heartRate: HKQuantityType? {
|
||||
return HKObjectType.quantityType(forIdentifier: .heartRate)
|
||||
}
|
||||
static var heartRateVariability: HKQuantityType? {
|
||||
return HKObjectType.quantityType(forIdentifier: .heartRateVariabilitySDNN)
|
||||
}
|
||||
static var activeEnergyBurned: HKQuantityType? {
|
||||
return HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)
|
||||
}
|
||||
static var basalEnergyBurned: HKQuantityType? {
|
||||
return HKObjectType.quantityType(forIdentifier: .basalEnergyBurned)
|
||||
}
|
||||
static var appleExerciseTime: HKQuantityType? {
|
||||
return HKObjectType.quantityType(forIdentifier: .appleExerciseTime)
|
||||
}
|
||||
static var appleStandTime: HKCategoryType? {
|
||||
return HKObjectType.categoryType(forIdentifier: .appleStandHour)
|
||||
}
|
||||
static var oxygenSaturation: HKQuantityType? {
|
||||
return HKObjectType.quantityType(forIdentifier: .oxygenSaturation)
|
||||
}
|
||||
static var activitySummary: HKActivitySummaryType {
|
||||
return HKObjectType.activitySummaryType()
|
||||
}
|
||||
static var dietaryWater: HKQuantityType? {
|
||||
return HKObjectType.quantityType(forIdentifier: .dietaryWater)
|
||||
}
|
||||
static var workout: HKWorkoutType {
|
||||
return HKObjectType.workoutType()
|
||||
}
|
||||
|
||||
static var all: Set<HKObjectType> {
|
||||
return [sleep, stepCount, heartRate, heartRateVariability, activeEnergyBurned, basalEnergyBurned, appleExerciseTime, appleStandTime, oxygenSaturation, activitySummary, dietaryWater, workout]
|
||||
var types: Set<HKObjectType> = [activitySummary, workout]
|
||||
if let sleep = sleep { types.insert(sleep) }
|
||||
if let stepCount = stepCount { types.insert(stepCount) }
|
||||
if let heartRate = heartRate { types.insert(heartRate) }
|
||||
if let heartRateVariability = heartRateVariability { types.insert(heartRateVariability) }
|
||||
if let activeEnergyBurned = activeEnergyBurned { types.insert(activeEnergyBurned) }
|
||||
if let basalEnergyBurned = basalEnergyBurned { types.insert(basalEnergyBurned) }
|
||||
if let appleExerciseTime = appleExerciseTime { types.insert(appleExerciseTime) }
|
||||
if let appleStandTime = appleStandTime { types.insert(appleStandTime) }
|
||||
if let oxygenSaturation = oxygenSaturation { types.insert(oxygenSaturation) }
|
||||
if let dietaryWater = dietaryWater { types.insert(dietaryWater) }
|
||||
return types
|
||||
}
|
||||
|
||||
static var workoutType: HKWorkoutType {
|
||||
@@ -47,11 +82,18 @@ class HealthKitManager: RCTEventEmitter {
|
||||
|
||||
/// For writing (if needed)
|
||||
private struct WriteTypes {
|
||||
static let bodyMass = HKObjectType.quantityType(forIdentifier: .bodyMass)!
|
||||
static let dietaryWater = HKObjectType.quantityType(forIdentifier: .dietaryWater)!
|
||||
static var bodyMass: HKQuantityType? {
|
||||
return HKObjectType.quantityType(forIdentifier: .bodyMass)
|
||||
}
|
||||
static var dietaryWater: HKQuantityType? {
|
||||
return HKObjectType.quantityType(forIdentifier: .dietaryWater)
|
||||
}
|
||||
|
||||
static var all: Set<HKSampleType> {
|
||||
return [bodyMass, dietaryWater]
|
||||
var types: Set<HKSampleType> = []
|
||||
if let bodyMass = bodyMass { types.insert(bodyMass) }
|
||||
if let dietaryWater = dietaryWater { types.insert(dietaryWater) }
|
||||
return types
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,15 +189,17 @@ class HealthKitManager: RCTEventEmitter {
|
||||
return
|
||||
}
|
||||
|
||||
let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis)!
|
||||
|
||||
guard let sleepType = ReadTypes.sleep else {
|
||||
rejecter("TYPE_NOT_AVAILABLE", "Sleep type is not available", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse options
|
||||
let startDate: Date
|
||||
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
|
||||
startDate = d
|
||||
} else {
|
||||
startDate = Calendar.current.date(byAdding: .day, value: -7, to: Date())!
|
||||
startDate = Calendar.current.date(byAdding: .day, value: -7, to: Date()) ?? Date()
|
||||
}
|
||||
|
||||
let endDate: Date
|
||||
@@ -231,7 +275,10 @@ class HealthKitManager: RCTEventEmitter {
|
||||
return
|
||||
}
|
||||
|
||||
let activeEnergyType = ReadTypes.activeEnergyBurned
|
||||
guard let activeEnergyType = ReadTypes.activeEnergyBurned else {
|
||||
rejecter("TYPE_NOT_AVAILABLE", "Active energy type is not available", nil)
|
||||
return
|
||||
}
|
||||
|
||||
let startDate: Date
|
||||
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
|
||||
@@ -312,7 +359,10 @@ class HealthKitManager: RCTEventEmitter {
|
||||
return
|
||||
}
|
||||
|
||||
let basalEnergyType = ReadTypes.basalEnergyBurned
|
||||
guard let basalEnergyType = ReadTypes.basalEnergyBurned else {
|
||||
rejecter("TYPE_NOT_AVAILABLE", "Basal energy type is not available", nil)
|
||||
return
|
||||
}
|
||||
|
||||
let startDate: Date
|
||||
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
|
||||
@@ -373,7 +423,10 @@ class HealthKitManager: RCTEventEmitter {
|
||||
return
|
||||
}
|
||||
|
||||
let exerciseType = ReadTypes.appleExerciseTime
|
||||
guard let exerciseType = ReadTypes.appleExerciseTime else {
|
||||
rejecter("TYPE_NOT_AVAILABLE", "Exercise time type is not available", nil)
|
||||
return
|
||||
}
|
||||
|
||||
let startDate: Date
|
||||
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
|
||||
@@ -454,7 +507,10 @@ class HealthKitManager: RCTEventEmitter {
|
||||
return
|
||||
}
|
||||
|
||||
let standType = ReadTypes.appleStandTime
|
||||
guard let standType = ReadTypes.appleStandTime else {
|
||||
rejecter("TYPE_NOT_AVAILABLE", "Stand time type is not available", nil)
|
||||
return
|
||||
}
|
||||
|
||||
let startDate: Date
|
||||
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
|
||||
@@ -571,9 +627,10 @@ class HealthKitManager: RCTEventEmitter {
|
||||
return
|
||||
}
|
||||
|
||||
let summaryData = summaries.map { summary in
|
||||
// 获取对应日期的 DateComponents
|
||||
let summaryDateComponents = calendar.dateComponents([.day, .month, .year], from: startDate)
|
||||
let summaryData = summaries.enumerated().map { (index, summary) in
|
||||
// 为每个 summary 计算正确的日期
|
||||
let summaryDate = calendar.date(byAdding: .day, value: index, to: startDate) ?? startDate
|
||||
let summaryDateComponents = calendar.dateComponents([.day, .month, .year], from: summaryDate)
|
||||
|
||||
return [
|
||||
"activeEnergyBurned": summary.activeEnergyBurned.doubleValue(for: HKUnit.kilocalorie()),
|
||||
@@ -607,7 +664,10 @@ class HealthKitManager: RCTEventEmitter {
|
||||
return
|
||||
}
|
||||
|
||||
let hrvType = ReadTypes.heartRateVariability
|
||||
guard let hrvType = ReadTypes.heartRateVariability else {
|
||||
rejecter("TYPE_NOT_AVAILABLE", "HRV type is not available", nil)
|
||||
return
|
||||
}
|
||||
|
||||
let startDate: Date
|
||||
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
|
||||
@@ -696,7 +756,10 @@ class HealthKitManager: RCTEventEmitter {
|
||||
return
|
||||
}
|
||||
|
||||
let oxygenType = ReadTypes.oxygenSaturation
|
||||
guard let oxygenType = ReadTypes.oxygenSaturation else {
|
||||
rejecter("TYPE_NOT_AVAILABLE", "Oxygen saturation type is not available", nil)
|
||||
return
|
||||
}
|
||||
|
||||
let startDate: Date
|
||||
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
|
||||
@@ -773,7 +836,10 @@ class HealthKitManager: RCTEventEmitter {
|
||||
return
|
||||
}
|
||||
|
||||
let heartRateType = ReadTypes.heartRate
|
||||
guard let heartRateType = ReadTypes.heartRate else {
|
||||
rejecter("TYPE_NOT_AVAILABLE", "Heart rate type is not available", nil)
|
||||
return
|
||||
}
|
||||
|
||||
let startDate: Date
|
||||
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
|
||||
@@ -850,7 +916,10 @@ class HealthKitManager: RCTEventEmitter {
|
||||
return
|
||||
}
|
||||
|
||||
let stepType = ReadTypes.stepCount
|
||||
guard let stepType = ReadTypes.stepCount else {
|
||||
rejecter("TYPE_NOT_AVAILABLE", "Step count type is not available", nil)
|
||||
return
|
||||
}
|
||||
|
||||
let startDate: Date
|
||||
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
|
||||
@@ -911,7 +980,10 @@ class HealthKitManager: RCTEventEmitter {
|
||||
return
|
||||
}
|
||||
|
||||
let stepType = ReadTypes.stepCount
|
||||
guard let stepType = ReadTypes.stepCount else {
|
||||
rejecter("TYPE_NOT_AVAILABLE", "Step count type is not available", nil)
|
||||
return
|
||||
}
|
||||
|
||||
let startDate: Date
|
||||
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
|
||||
@@ -1182,7 +1254,10 @@ class HealthKitManager: RCTEventEmitter {
|
||||
return
|
||||
}
|
||||
|
||||
let activeEnergyType = ReadTypes.activeEnergyBurned
|
||||
guard let activeEnergyType = ReadTypes.activeEnergyBurned else {
|
||||
rejecter("TYPE_NOT_AVAILABLE", "Active energy type is not available", nil)
|
||||
return
|
||||
}
|
||||
|
||||
let startDate: Date
|
||||
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
|
||||
@@ -1268,7 +1343,10 @@ class HealthKitManager: RCTEventEmitter {
|
||||
return
|
||||
}
|
||||
|
||||
let exerciseType = ReadTypes.appleExerciseTime
|
||||
guard let exerciseType = ReadTypes.appleExerciseTime else {
|
||||
rejecter("TYPE_NOT_AVAILABLE", "Exercise time type is not available", nil)
|
||||
return
|
||||
}
|
||||
|
||||
let startDate: Date
|
||||
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
|
||||
@@ -1354,7 +1432,10 @@ class HealthKitManager: RCTEventEmitter {
|
||||
return
|
||||
}
|
||||
|
||||
let standType = ReadTypes.appleStandTime
|
||||
guard let standType = ReadTypes.appleStandTime else {
|
||||
rejecter("TYPE_NOT_AVAILABLE", "Stand time type is not available", nil)
|
||||
return
|
||||
}
|
||||
|
||||
let startDate: Date
|
||||
if let startString = options["startDate"] as? String, let d = parseDate(from: startString) {
|
||||
@@ -1399,8 +1480,11 @@ class HealthKitManager: RCTEventEmitter {
|
||||
|
||||
// 为每个小时创建数据结构
|
||||
for hour in 0..<24 {
|
||||
let hourStart = calendar.date(byAdding: .hour, value: hour, to: calendar.startOfDay(for: startDate))!
|
||||
let hourEnd = calendar.date(byAdding: .hour, value: hour + 1, to: calendar.startOfDay(for: startDate))!
|
||||
guard let dayStart = calendar.startOfDay(for: startDate) as Date?,
|
||||
let hourStart = calendar.date(byAdding: .hour, value: hour, to: dayStart),
|
||||
let hourEnd = calendar.date(byAdding: .hour, value: hour + 1, to: dayStart) else {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查该小时是否有站立记录
|
||||
let standSamplesInHour = standSamples.filter { sample in
|
||||
@@ -1460,7 +1544,10 @@ class HealthKitManager: RCTEventEmitter {
|
||||
recordedAt = Date()
|
||||
}
|
||||
|
||||
let waterType = WriteTypes.dietaryWater
|
||||
guard let waterType = WriteTypes.dietaryWater else {
|
||||
rejecter("TYPE_NOT_AVAILABLE", "Water intake type is not available", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Create quantity sample
|
||||
let quantity = HKQuantity(unit: HKUnit.literUnit(with: .milli), doubleValue: amount)
|
||||
@@ -1505,7 +1592,10 @@ class HealthKitManager: RCTEventEmitter {
|
||||
return
|
||||
}
|
||||
|
||||
let waterType = HKObjectType.quantityType(forIdentifier: .dietaryWater)!
|
||||
guard let waterType = ReadTypes.dietaryWater else {
|
||||
rejecter("TYPE_NOT_AVAILABLE", "Water intake type is not available", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse date range
|
||||
let startDate: Date
|
||||
@@ -1600,7 +1690,7 @@ class HealthKitManager: RCTEventEmitter {
|
||||
startDate = d
|
||||
} else {
|
||||
// 默认获取最近30天的锻炼记录
|
||||
startDate = Calendar.current.date(byAdding: .day, value: -30, to: Date())!
|
||||
startDate = Calendar.current.date(byAdding: .day, value: -30, to: Date()) ?? Date()
|
||||
}
|
||||
|
||||
let endDate: Date
|
||||
@@ -1802,7 +1892,10 @@ func startSleepObserver(
|
||||
}
|
||||
|
||||
// 创建睡眠数据观察者
|
||||
let sleepType = ReadTypes.sleep
|
||||
guard let sleepType = ReadTypes.sleep else {
|
||||
rejecter("TYPE_NOT_AVAILABLE", "Sleep type is not available", nil)
|
||||
return
|
||||
}
|
||||
|
||||
sleepObserverQuery = HKObserverQuery(sampleType: sleepType, predicate: nil) { [weak self] (query, completionHandler, error) in
|
||||
if let error = error {
|
||||
@@ -1827,7 +1920,11 @@ func startSleepObserver(
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
healthStore.execute(sleepObserverQuery!)
|
||||
guard let query = sleepObserverQuery else {
|
||||
rejecter("QUERY_ERROR", "Failed to create sleep observer", nil)
|
||||
return
|
||||
}
|
||||
healthStore.execute(query)
|
||||
|
||||
resolver(["success": true])
|
||||
}
|
||||
@@ -1842,9 +1939,11 @@ func stopSleepObserver(
|
||||
sleepObserverQuery = nil
|
||||
|
||||
// 禁用后台传递
|
||||
healthStore.disableBackgroundDelivery(for: ReadTypes.sleep) { (success, error) in
|
||||
if let error = error {
|
||||
print("Failed to disable background delivery for sleep: \(error.localizedDescription)")
|
||||
if let sleepType = ReadTypes.sleep {
|
||||
healthStore.disableBackgroundDelivery(for: sleepType) { (success, error) in
|
||||
if let error = error {
|
||||
print("Failed to disable background delivery for sleep: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1879,7 +1978,11 @@ func startHRVObserver(
|
||||
hrvObserverQuery = nil
|
||||
}
|
||||
|
||||
let hrvType = ReadTypes.heartRateVariability
|
||||
guard let hrvType = ReadTypes.heartRateVariability else {
|
||||
rejecter("TYPE_NOT_AVAILABLE", "HRV type is not available", nil)
|
||||
return
|
||||
}
|
||||
|
||||
hrvObserverQuery = HKObserverQuery(sampleType: hrvType, predicate: nil) { [weak self] (_, completionHandler, error) in
|
||||
if let error = error {
|
||||
print("HRV observer error: \(error.localizedDescription)")
|
||||
@@ -1892,17 +1995,21 @@ func startHRVObserver(
|
||||
completionHandler()
|
||||
}
|
||||
|
||||
healthStore.enableBackgroundDelivery(for: hrvType, frequency: .immediate) { success, error in
|
||||
if let error = error {
|
||||
print("Failed to enable background delivery for HRV: \(error.localizedDescription)")
|
||||
} else if success {
|
||||
print("Background delivery for HRV enabled successfully")
|
||||
if let hrvType = ReadTypes.heartRateVariability {
|
||||
healthStore.enableBackgroundDelivery(for: hrvType, frequency: .immediate) { success, error in
|
||||
if let error = error {
|
||||
print("Failed to enable background delivery for HRV: \(error.localizedDescription)")
|
||||
} else if success {
|
||||
print("Background delivery for HRV enabled successfully")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let query = hrvObserverQuery {
|
||||
healthStore.execute(query)
|
||||
guard let query = hrvObserverQuery else {
|
||||
rejecter("QUERY_ERROR", "Failed to create HRV observer", nil)
|
||||
return
|
||||
}
|
||||
healthStore.execute(query)
|
||||
|
||||
resolver(["success": true])
|
||||
}
|
||||
@@ -1916,9 +2023,11 @@ func stopHRVObserver(
|
||||
healthStore.stop(query)
|
||||
hrvObserverQuery = nil
|
||||
|
||||
healthStore.disableBackgroundDelivery(for: ReadTypes.heartRateVariability) { success, error in
|
||||
if let error = error {
|
||||
print("Failed to disable background delivery for HRV: \(error.localizedDescription)")
|
||||
if let hrvType = ReadTypes.heartRateVariability {
|
||||
healthStore.disableBackgroundDelivery(for: hrvType) { success, error in
|
||||
if let error = error {
|
||||
print("Failed to disable background delivery for HRV: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.26</string>
|
||||
<string>1.0.27</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { logger } from '@/utils/logger';
|
||||
import AsyncStorage from '@/utils/kvStore';
|
||||
import dayjs from 'dayjs';
|
||||
import { NativeEventEmitter, NativeModules } from 'react-native';
|
||||
import { analyzeHRVData, fetchHRVWithStatus } from '@/utils/health';
|
||||
import AsyncStorage from '@/utils/kvStore';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { convertHrvToStressIndex, getStressLevelInfo, StressLevel } from '@/utils/stress';
|
||||
import { NativeEventEmitter, NativeModules } from 'react-native';
|
||||
import { sendHRVStressNotification } from './hrvNotificationService';
|
||||
|
||||
const { HealthKitManager } = NativeModules;
|
||||
|
||||
const HRV_EVENT_NAME = 'hrvUpdate';
|
||||
const HRV_NOTIFICATION_STATE_KEY = '@hrv_stress_notification_state';
|
||||
const MIN_NOTIFICATION_INTERVAL_HOURS = 4;
|
||||
const MIN_NOTIFICATION_INTERVAL_HOURS = 2;
|
||||
|
||||
interface HrvEventData {
|
||||
timestamp: number;
|
||||
@@ -137,19 +136,13 @@ class HRVMonitorService {
|
||||
const elapsed = now - state.lastSentAt;
|
||||
const minIntervalMs = MIN_NOTIFICATION_INTERVAL_HOURS * 60 * 60 * 1000;
|
||||
|
||||
// 只检查最小间隔时间(2小时),不再限制每天只能发送一次
|
||||
if (elapsed < minIntervalMs) {
|
||||
const hoursLeft = ((minIntervalMs - elapsed) / (1000 * 60 * 60)).toFixed(1);
|
||||
logger.info(`[HRVMonitor] Cooldown active, ${hoursLeft}h remaining`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastSentDay = dayjs(state.lastSentAt).format('YYYY-MM-DD');
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
if (lastSentDay === today && state.lastStressLevel !== 'high') {
|
||||
logger.info('[HRVMonitor] Already sent HRV notification today');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.warn('[HRVMonitor] Failed to read notification state:', error);
|
||||
|
||||
Reference in New Issue
Block a user