Commit 81b8906787ed60d29cd749b2c114e03be9fa8707

Authored by 权海
1 parent dcd3e9d1

feat(ui):ohos 数据同步和算法完善

... ... @@ -9,6 +9,7 @@ import 'package:get/get.dart';
import '../../../../data/models/enums/app_enums.dart';
import '../../../../data/models/health/health_v2_models.dart';
import '../../../../l10n/l10n_extensions.dart';
import '../../../../pigeon/health_kit_raw_data_api.g.dart';
import '../../../config/app_environment_config.dart';
import '../../../logging/app_logger.dart';
... ... @@ -19,6 +20,7 @@ import '../health_raw_data_source.dart';
import '../health_raw_models.dart';
import '../health_sleep_calculator.dart';
import '../platform_ios/apple_health_raw_data_core_service.dart';
import '../platform_ios/apple_health_raw_local_notification.dart';
import 'huawei_health_raw_stress_calculator.dart';
import 'ohos_health_raw_data_sync_service.dart';
import 'ohos_health_raw_result_upload_service.dart';
... ... @@ -26,6 +28,9 @@ import 'ohos_health_raw_result_upload_service.dart';
class OHOSHealthRawDataCoreService {
static const int defaultLookbackDays = 183;
static const int defaultReadChunkDays = 7;
static const String _calculateLogMarker = '[OHOS_HEALTH_CALCULATE]';
static const String _dailyStressLogMarker = '[OHOS_DAILY_STRESS_CALC]';
static const String _sleepCalcLogMarker = '[OHOS_SLEEP_CALC]';
OHOSHealthRawDataCoreService({
HealthRawDataSource? rawDataSource,
... ... @@ -37,6 +42,7 @@ class OHOSHealthRawDataCoreService {
HarmonyApi? harmonyApi,
Future<bool> Function()? healthReadAuthorizationChecker,
OhosHealthRawResultUploadService? resultUploadService,
HealthRawLocalNotificationDispatcher? localNotificationDispatcher,
}) : _rawDataSource = rawDataSource ??
OhosHealthRawDataSource(
userIdProvider: userIdProvider,
... ... @@ -51,7 +57,9 @@ class OHOSHealthRawDataCoreService {
_harmonyApi = harmonyApi ??
(Get.isRegistered<HarmonyApi>() ? Get.find<HarmonyApi>() : null),
_healthReadAuthorizationChecker = healthReadAuthorizationChecker,
_resultUploadService = resultUploadService;
_resultUploadService = resultUploadService,
_localNotificationDispatcher = localNotificationDispatcher ??
HealthRawLocalNotificationDispatcher();
final HealthRawDataSource _rawDataSource;
final HealthRawStressLocalStore _localStore;
... ... @@ -62,6 +70,7 @@ class OHOSHealthRawDataCoreService {
final HarmonyApi? _harmonyApi;
final Future<bool> Function()? _healthReadAuthorizationChecker;
OhosHealthRawResultUploadService? _resultUploadService;
final HealthRawLocalNotificationDispatcher _localNotificationDispatcher;
final StreamController<HealthRawDataUpdatedEvent>
_healthDataUpdatedController =
StreamController<HealthRawDataUpdatedEvent>.broadcast();
... ... @@ -222,159 +231,214 @@ class OHOSHealthRawDataCoreService {
if (effectiveEndTime < requestedStartTime) {
throw ArgumentError.value(endTime, 'endTime');
}
final hasAuthorization = await _hasHealthReadAuthorization();
final syncResults = hasAuthorization
? await _syncCalculationRawDataIfNeeded(
startTime: requestedStartTime,
endTime: effectiveEndTime,
)
: const <OhosHealthRawDataSyncResult>[];
if (!hasAuthorization) {
_logInfo(
'skip OHOS raw data sync without health privacy authorization; '
'continue calculate and upload from local database',
);
}
final hasAuthorization = await _hasHealthReadAuthorizationSafely();
final syncResults = await _syncCalculationRawDataSafely(
hasAuthorization: hasAuthorization,
startTime: requestedStartTime,
endTime: effectiveEndTime,
);
_logInfo(
'calculate_sync_finish startTime=$requestedStartTime '
'endTime=$effectiveEndTime syncResults=$syncResults',
);
final hrvContextStart = await _localStore.latestHrvSourceStartTime(userId);
final realtimeContextStart =
await _localStore.latestRealtimeSourceStartTime(userId);
final latestHrvRawEndTime = await _localStore.latestHrvRawEndTime(userId);
final latestRealtimeRawEndTime =
await _localStore.latestRealtimeRawEndTime(userId);
final latestSleepResultTime = await _localStore.latestSleepResultTime(
userId,
);
final hrvStartTime = math.max(
hrvContextStart ?? requestedStartTime,
earliestStartTime,
);
final realtimeStartTime = math.max(
realtimeContextStart ?? requestedStartTime,
earliestStartTime,
);
final heartRateStartTime = math.max(
_minNullable(hrvStartTime, realtimeStartTime) ?? requestedStartTime,
earliestStartTime,
);
final sleepStartTime = math.max(
latestSleepResultTime == null
? requestedStartTime
: latestSleepResultTime - Duration.secondsPerDay,
earliestStartTime,
);
_logInfo(
'calculate_context userId=$userId '
'requestedStartTime=$requestedStartTime endTime=$effectiveEndTime '
'hrvContextStart=$hrvContextStart '
'realtimeContextStart=$realtimeContextStart '
'latestHrvRawEndTime=$latestHrvRawEndTime '
'latestRealtimeRawEndTime=$latestRealtimeRawEndTime '
'latestSleepResultTime=$latestSleepResultTime '
'hrvStartTime=$hrvStartTime heartRateStartTime=$heartRateStartTime '
'sleepStartTime=$sleepStartTime',
);
final hrvPoints = await _fetchRawDataInChunks(
HealthDataUploadType.hrv.type,
hrvStartTime,
effectiveEndTime,
readChunkDays: readChunkDays,
);
final heartRatePoints = await _fetchRawDataInChunks(
HealthDataUploadType.heartRate.type,
heartRateStartTime,
effectiveEndTime,
readChunkDays: readChunkDays,
);
final restingHeartRatePoints = await _fetchRawDataInChunks(
HealthDataUploadType.restingHeartRate.type,
heartRateStartTime,
effectiveEndTime,
readChunkDays: readChunkDays,
);
final sleepIntervals = await _fetchSleepIntervalsInChunks(
sleepStartTime,
effectiveEndTime,
readChunkDays: readChunkDays,
);
final workoutIntervals = await _fetchWorkoutIntervalsInChunks(
heartRateStartTime,
effectiveEndTime,
final storedResult = await _calculateAndStoreSafely(
userId: userId,
requestedStartTime: requestedStartTime,
effectiveEndTime: effectiveEndTime,
earliestStartTime: earliestStartTime,
readChunkDays: readChunkDays,
);
_logInfo(
'calculate_raw_counts userId=$userId '
'hrv=${hrvPoints.length} heartRate=${heartRatePoints.length} '
'restingHeartRate=${restingHeartRatePoints.length} '
'sleepIntervals=${sleepIntervals.length} '
'workoutIntervals=${workoutIntervals.length}',
);
final result = await Isolate.run(
() => HuaweiHealthRawStressCalculator(userId: userId).calculate(
hrvPoints: hrvPoints,
heartRatePoints: heartRatePoints,
restingHeartRatePoints: restingHeartRatePoints,
sleepIntervals: sleepIntervals,
workoutIntervals: workoutIntervals,
startTime: math.min(hrvStartTime, heartRateStartTime),
endTime: effectiveEndTime,
),
debugName: 'OHOSHealthRawStressCalculator',
);
_logInfo(
'calculate_result_counts userId=$userId '
'hrv=${result.hrvStressPoints.length} '
'realtime=${result.realtimeStressPoints.length}',
);
final newResult = result.copyWith(
hrvStressPoints: _filterNewHrvStressPoints(
result.hrvStressPoints,
latestHrvRawEndTime,
),
realtimeStressPoints: _filterNewRealtimeStressPoints(
result.realtimeStressPoints,
latestRealtimeRawEndTime,
),
);
_logInfo(
'calculate_new_result_counts userId=$userId '
'hrv=${newResult.hrvStressPoints.length} '
'realtime=${newResult.realtimeStressPoints.length}',
);
await _localStore.upsertResult(newResult);
_logInfo(
'calculate_result_stored userId=$userId '
'hrv=${newResult.hrvStressPoints.length} '
'realtime=${newResult.realtimeStressPoints.length}',
_scheduleResultUpload();
await _sendLocalNotificationsAfterCalculationSafely(
result: storedResult.result,
hasExistingHrv: storedResult.hasExistingHrv,
hasExistingSleep: storedResult.hasExistingSleep,
);
return storedResult.result;
}
final dailyStressPoints = await _calculateAndStoreDailyStressPoints(
userId: userId,
realtimePoints: newResult.realtimeStressPoints,
nowSeconds: effectiveEndTime,
);
final sleepResults = await _calculateAndStoreSleepResults(
Future<_OhosStoredCalculationResult> _calculateAndStoreSafely({
required int userId,
required int requestedStartTime,
required int effectiveEndTime,
required int earliestStartTime,
required int readChunkDays,
}) async {
var hasExistingHrv = false;
var hasExistingSleep = false;
final emptyResult = HealthRawStressCalculationResult(
userId: userId,
sleepIntervals: sleepIntervals,
latestSleepResultTime: latestSleepResultTime,
hrvStressPoints: const <HealthRawHrvStressPoint>[],
realtimeStressPoints: const <HealthRawRealtimeStressPoint>[],
dailyStressPoints: const <HealthRawDailyStressPoint>[],
sleepResults: const <HealthRawSleepResult>[],
);
_logInfo(
'calculate_daily_sleep_stored userId=$userId '
'daily=${dailyStressPoints.length} sleep=${sleepResults.length}',
);
if (_uploadResultsAfterCalculation) {
await _uploadResults();
try {
final hrvContextStart =
await _localStore.latestHrvSourceStartTime(userId);
final realtimeContextStart =
await _localStore.latestRealtimeSourceStartTime(userId);
final latestHrvRawEndTime = await _localStore.latestHrvRawEndTime(userId);
final latestRealtimeRawEndTime =
await _localStore.latestRealtimeRawEndTime(userId);
final latestSleepResultTime = await _localStore.latestSleepResultTime(
userId,
);
hasExistingHrv = latestHrvRawEndTime != null;
hasExistingSleep = latestSleepResultTime != null;
final hrvStartTime = math.max(
hrvContextStart ?? requestedStartTime,
earliestStartTime,
);
final realtimeStartTime = math.max(
realtimeContextStart ?? requestedStartTime,
earliestStartTime,
);
final heartRateStartTime = math.max(
_minNullable(hrvStartTime, realtimeStartTime) ?? requestedStartTime,
earliestStartTime,
);
final sleepStartTime = math.max(
latestSleepResultTime == null
? requestedStartTime
: latestSleepResultTime - Duration.secondsPerDay,
earliestStartTime,
);
_logInfo(
'calculate_context userId=$userId '
'requestedStartTime=$requestedStartTime endTime=$effectiveEndTime '
'hrvContextStart=$hrvContextStart '
'realtimeContextStart=$realtimeContextStart '
'latestHrvRawEndTime=$latestHrvRawEndTime '
'latestRealtimeRawEndTime=$latestRealtimeRawEndTime '
'latestSleepResultTime=$latestSleepResultTime '
'hrvStartTime=$hrvStartTime heartRateStartTime=$heartRateStartTime '
'sleepStartTime=$sleepStartTime',
);
final hrvPoints = await _fetchRawDataInChunks(
HealthDataUploadType.hrv.type,
hrvStartTime,
effectiveEndTime,
readChunkDays: readChunkDays,
);
final heartRatePoints = await _fetchRawDataInChunks(
HealthDataUploadType.heartRate.type,
heartRateStartTime,
effectiveEndTime,
readChunkDays: readChunkDays,
);
final restingHeartRatePoints = await _fetchRawDataInChunks(
HealthDataUploadType.restingHeartRate.type,
heartRateStartTime,
effectiveEndTime,
readChunkDays: readChunkDays,
);
final sleepIntervals = await _fetchSleepIntervalsInChunks(
sleepStartTime,
effectiveEndTime,
readChunkDays: readChunkDays,
);
final workoutIntervals = await _fetchWorkoutIntervalsInChunks(
heartRateStartTime,
effectiveEndTime,
readChunkDays: readChunkDays,
);
final latestRawHrvTime = _latestRawDataTime(hrvPoints);
final latestRawHrTime = _latestRawDataTime(heartRatePoints);
final latestRawSleepTime = _latestRawDataTime(sleepIntervals);
_logInfo(
'$_calculateLogMarker raw_counts userId=$userId '
'hrv=${hrvPoints.length} heartRate=${heartRatePoints.length} '
'restingHeartRate=${restingHeartRatePoints.length} '
'sleepIntervals=${sleepIntervals.length} '
'workoutIntervals=${workoutIntervals.length} '
'latestRawHrvTime=$latestRawHrvTime '
'latestRawHrTime=$latestRawHrTime '
'latestRawSleepTime=$latestRawSleepTime '
'needHrv=${_needsNewResult(latestRawHrvTime, latestHrvRawEndTime)} '
'needRealtime=${_needsNewResult(latestRawHrTime, latestRealtimeRawEndTime)} '
'needSleep=${_needsNewResult(latestRawSleepTime, latestSleepResultTime)}',
);
final result = await Isolate.run(
() => HuaweiHealthRawStressCalculator(userId: userId).calculate(
hrvPoints: hrvPoints,
heartRatePoints: heartRatePoints,
restingHeartRatePoints: restingHeartRatePoints,
sleepIntervals: sleepIntervals,
workoutIntervals: workoutIntervals,
startTime: math.min(hrvStartTime, heartRateStartTime),
endTime: effectiveEndTime,
),
debugName: 'OHOSHealthRawStressCalculator',
);
_logInfo(
'$_calculateLogMarker result_counts userId=$userId '
'hrv=${result.hrvStressPoints.length} '
'realtime=${result.realtimeStressPoints.length} '
'daily=${result.dailyStressPoints.length}',
);
final newResult = result.copyWith(
hrvStressPoints: _filterNewHrvStressPoints(
result.hrvStressPoints,
latestHrvRawEndTime,
),
realtimeStressPoints: _filterNewRealtimeStressPoints(
result.realtimeStressPoints,
latestRealtimeRawEndTime,
),
);
_logInfo(
'$_calculateLogMarker new_result_counts userId=$userId '
'latestHrvRawEndTime=$latestHrvRawEndTime '
'latestRealtimeRawEndTime=$latestRealtimeRawEndTime '
'hrv=${newResult.hrvStressPoints.length} '
'realtime=${newResult.realtimeStressPoints.length}',
);
await _localStore.upsertResult(newResult);
_logInfo(
'$_calculateLogMarker result_stored userId=$userId '
'hrv=${newResult.hrvStressPoints.length} '
'realtime=${newResult.realtimeStressPoints.length}',
);
final dailyStressPoints = await _calculateAndStoreDailyStressPoints(
userId: userId,
realtimePoints: newResult.realtimeStressPoints,
nowSeconds: effectiveEndTime,
);
final sleepResults = await _calculateAndStoreSleepResults(
userId: userId,
sleepIntervals: sleepIntervals,
latestSleepResultTime: latestSleepResultTime,
);
_logInfo(
'calculate_daily_sleep_stored userId=$userId '
'daily=${dailyStressPoints.length} sleep=${sleepResults.length}',
);
return _OhosStoredCalculationResult(
result: newResult.copyWith(
dailyStressPoints: dailyStressPoints,
sleepResults: sleepResults,
),
hasExistingHrv: hasExistingHrv,
hasExistingSleep: hasExistingSleep,
);
} catch (error, stackTrace) {
_logError(
'$_calculateLogMarker calculate_failed userId=$userId',
error,
stackTrace,
);
return _OhosStoredCalculationResult(
result: emptyResult,
hasExistingHrv: hasExistingHrv,
hasExistingSleep: hasExistingSleep,
);
}
return newResult.copyWith(
dailyStressPoints: dailyStressPoints,
sleepResults: sleepResults,
);
}
Stream<HealthKitRawDataPoint> streamRawData({
... ... @@ -562,6 +626,267 @@ class OHOSHealthRawDataCoreService {
}
}
void _scheduleResultUpload() {
if (!_uploadResultsAfterCalculation) return;
_logInfo('upload results scheduled');
unawaited(
_uploadResults().catchError((Object error, StackTrace stackTrace) {
_logError('upload results async failed', error, stackTrace);
return false;
}),
);
}
Future<void> _sendLocalNotificationsAfterCalculationSafely({
required HealthRawStressCalculationResult result,
required bool hasExistingHrv,
required bool hasExistingSleep,
}) async {
try {
await _sendLocalNotificationsAfterCalculation(
result: result,
hasExistingHrv: hasExistingHrv,
hasExistingSleep: hasExistingSleep,
);
} catch (error, stackTrace) {
_logError('send local notifications failed', error, stackTrace);
}
}
Future<void> _sendLocalNotificationsAfterCalculation({
required HealthRawStressCalculationResult result,
required bool hasExistingHrv,
required bool hasExistingSleep,
}) async {
_logInfo(
'local_notification_start userId=${result.userId} '
'hasExistingHrv=$hasExistingHrv hasExistingSleep=$hasExistingSleep '
'hrv=${result.hrvStressPoints.length} '
'realtime=${result.realtimeStressPoints.length} '
'sleep=${result.sleepResults.length}',
);
if (result.hrvStressPoints.isEmpty &&
result.realtimeStressPoints.isEmpty &&
result.sleepResults.isEmpty) {
_logInfo('local_notification_skip reason=empty_calculation_result');
return;
}
final latestRealtimeStressPoint = result.realtimeStressPoints.isEmpty
? null
: result.realtimeStressPoints.reduce(
(a, b) => a.rawEndTime >= b.rawEndTime ? a : b,
);
var notificationResult = result;
var realtimeWindow = const <HealthRawRealtimeStressPoint>[];
var realtimeSleepIntervals = const <HealthRawSleepInterval>[];
if (latestRealtimeStressPoint != null) {
if (latestRealtimeStressPoint.isWorkout ||
latestRealtimeStressPoint.isWorkoutRecovery) {
_logInfo(
'local_notification_realtime_suppressed_before_build '
'reason=workout_or_recovery '
'rawEndTime=${latestRealtimeStressPoint.rawEndTime}',
);
await _recordRealtimeStressTimeSafely(
userId: result.userId,
recordTime: latestRealtimeStressPoint.rawEndTime,
);
notificationResult = result.copyWith(
realtimeStressPoints: const <HealthRawRealtimeStressPoint>[],
);
} else {
realtimeWindow = await _queryRealtimeStressNotificationWindowSafely(
userId: result.userId,
latestRawEndTime: latestRealtimeStressPoint.rawEndTime,
);
realtimeSleepIntervals = await _queryRealtimeStressSleepIntervalsSafely(
userId: result.userId,
latestRawEndTime: latestRealtimeStressPoint.rawEndTime,
);
}
}
final record = await _readLocalNotificationRecordSafely(result.userId);
final notifications = HealthRawLocalNotificationBuilder(l10n).build(
result: notificationResult,
hasExistingHrv: hasExistingHrv,
hasExistingSleep: hasExistingSleep,
realtimeWindow: realtimeWindow,
realtimeSleepIntervals: realtimeSleepIntervals,
lastRealtimeStressPushSendTime: record.lastRealtimeStressTime,
record: record,
);
_logInfo(
'local_notification_built userId=${result.userId} '
'count=${notifications.length} '
'types=${notifications.map((e) => e.recordType.name).join(',')}',
);
if (notifications.isEmpty) return;
final sentNotifications = <HealthRawLocalNotification>[];
for (final notification in notifications) {
final sent = await _sendLocalNotificationSafely(
userId: result.userId,
notification: notification,
);
_logInfo(
'local_notification_send_finished userId=${result.userId} '
'sent=$sent type=${notification.recordType.name} '
'recordTime=${notification.recordTime}',
);
if (sent) {
sentNotifications.add(notification);
}
}
_scheduleRealtimeStressServerPush(sentNotifications);
}
Future<HealthRawLocalNotificationRecord> _readLocalNotificationRecordSafely(
int userId,
) async {
try {
return await _localNotificationDispatcher.readRecord(userId);
} catch (error, stackTrace) {
_logError('read local notification record failed', error, stackTrace);
return const HealthRawLocalNotificationRecord();
}
}
Future<void> _recordRealtimeStressTimeSafely({
required int userId,
required int recordTime,
}) async {
try {
await _localNotificationDispatcher.recordRealtimeStressTime(
userId: userId,
recordTime: recordTime,
);
} catch (error, stackTrace) {
_logError('record realtime stress push time failed', error, stackTrace);
}
}
Future<List<HealthRawRealtimeStressPoint>>
_queryRealtimeStressNotificationWindowSafely({
required int userId,
required int latestRawEndTime,
}) async {
try {
final points = await _localStore.queryRealtimeStressPoints(
userId: userId,
startTime: latestRawEndTime - Duration.secondsPerHour + 1,
endTime: latestRawEndTime,
);
_logInfo(
'local_notification_realtime_window userId=$userId '
'latestRawEndTime=$latestRawEndTime count=${points.length} '
'valid=${points.where((e) => e.result >= 1 && e.result <= 100).length}',
);
return points;
} catch (error, stackTrace) {
_logError(
'query realtime stress notification window failed',
error,
stackTrace,
);
return const <HealthRawRealtimeStressPoint>[];
}
}
Future<List<HealthRawSleepInterval>>
_queryRealtimeStressSleepIntervalsSafely({
required int userId,
required int latestRawEndTime,
}) async {
try {
final (startTime, endTime) = _dayRangeFromDateKey(
_dateKeyFromUnixSeconds(latestRawEndTime),
);
final sleepResults = await _localStore.querySleepResults(
userId: userId,
startTime: startTime,
endTime: endTime,
);
final intervals = sleepResults
.where((result) => result.startDate <= result.date)
.map(
(result) => (
startTime: result.startDate,
endTime: result.date,
),
)
.toList(growable: false);
_logInfo(
'local_notification_realtime_sleep_intervals userId=$userId '
'latestRawEndTime=$latestRawEndTime count=${intervals.length}',
);
return intervals;
} catch (error, stackTrace) {
_logError(
'query realtime stress sleep intervals failed',
error,
stackTrace,
);
return const <HealthRawSleepInterval>[];
}
}
Future<bool> _sendLocalNotificationSafely({
required int userId,
required HealthRawLocalNotification notification,
}) async {
try {
return await _localNotificationDispatcher.sendOne(
userId: userId,
notification: notification,
);
} catch (error, stackTrace) {
_logError('send local health notification failed', error, stackTrace);
return false;
}
}
void _scheduleRealtimeStressServerPush(
Iterable<HealthRawLocalNotification> sentNotifications,
) {
final realtimeNotifications = sentNotifications.where(
(notification) =>
notification.recordType ==
HealthRawLocalNotificationRecordType.realtimeStress &&
notification.currentState != null,
);
for (final notification in realtimeNotifications) {
unawaited(_pushRealtimeStressServerNotification(notification));
}
}
Future<void> _pushRealtimeStressServerNotification(
HealthRawLocalNotification notification,
) async {
final serverHealthApi = _serverHealthApi;
if (serverHealthApi == null) return;
try {
final result = await serverHealthApi.pushRealtimeStressNotification(
latestDataTime: notification.recordTime,
currentState: notification.currentState!,
);
if (result is AppFailure) {
_logError(
'push realtime stress server notification failed',
result.error,
StackTrace.current,
);
}
} catch (error, stackTrace) {
_logError(
'push realtime stress server notification failed',
error,
stackTrace,
);
}
}
Future<List<OhosHealthRawDataSyncResult>> _syncCalculationRawDataIfNeeded({
required int startTime,
required int endTime,
... ... @@ -577,6 +902,47 @@ class OHOSHealthRawDataCoreService {
);
}
Future<List<OhosHealthRawDataSyncResult>> _syncCalculationRawDataSafely({
required bool hasAuthorization,
required int startTime,
required int endTime,
}) async {
if (!hasAuthorization) {
_logInfo(
'$_calculateLogMarker sync_skipped reason=no_health_privacy_permission '
'startTime=$startTime endTime=$endTime',
);
return const <OhosHealthRawDataSyncResult>[];
}
try {
return await _syncCalculationRawDataIfNeeded(
startTime: startTime,
endTime: endTime,
);
} catch (error, stackTrace) {
_logError(
'$_calculateLogMarker sync_failed_continue '
'startTime=$startTime endTime=$endTime',
error,
stackTrace,
);
return const <OhosHealthRawDataSyncResult>[];
}
}
Future<bool> _hasHealthReadAuthorizationSafely() async {
try {
return await _hasHealthReadAuthorization();
} catch (error, stackTrace) {
_logError(
'$_calculateLogMarker privacy_authorization_failed_continue',
error,
stackTrace,
);
return false;
}
}
Future<bool> _hasHealthReadAuthorization() async {
final checker = _healthReadAuthorizationChecker;
if (checker != null) {
... ... @@ -594,6 +960,19 @@ class OHOSHealthRawDataCoreService {
};
}
int? _latestRawDataTime(List<HealthKitRawDataPoint> points) {
if (points.isEmpty) return null;
return points
.map((point) => point.endTime)
.reduce((a, b) => a >= b ? a : b);
}
bool _needsNewResult(int? latestRawTime, int? latestResultTime) {
if (latestRawTime == null) return false;
if (latestResultTime == null) return true;
return latestRawTime > latestResultTime;
}
bool _logPrivacyAuthorizationFailure(Object error) {
_logInfo('OHOS health privacy authorization check failed: $error');
return false;
... ... @@ -615,16 +994,31 @@ class OHOSHealthRawDataCoreService {
userId: userId,
dates: affectedDates,
);
_logInfo(
'$_dailyStressLogMarker start userId=$userId nowSeconds=$nowSeconds '
'incomingRealtime=${realtimePoints.length} '
'affectedDates=$affectedDates existingDates=$existingDates',
);
final dailyStressPoints = <HealthRawDailyStressPoint>[];
final emptyDates = <int>[];
for (final date in affectedDates) {
if (date != todayDate && existingDates.contains(date)) continue;
if (date != todayDate && existingDates.contains(date)) {
_logInfo(
'$_dailyStressLogMarker skip_existing userId=$userId date=$date',
);
continue;
}
final (startTime, endTime) = _dayRangeFromDateKey(date);
final dayRealtimePoints = await _localStore.queryRealtimeStressPoints(
userId: userId,
startTime: startTime,
endTime: endTime,
);
_logInfo(
'$_dailyStressLogMarker day_query userId=$userId date=$date '
'startTime=$startTime endTime=$endTime '
'dayRealtime=${dayRealtimePoints.length}',
);
final point = HuaweiHealthRawStressCalculator.calculateDailyStressPoints(
userId: userId,
realtimePoints: dayRealtimePoints,
... ... @@ -634,9 +1028,17 @@ class OHOSHealthRawDataCoreService {
).firstOrNull;
if (point == null) {
emptyDates.add(date);
_logInfo(
'$_dailyStressLogMarker no_result userId=$userId date=$date',
);
continue;
}
dailyStressPoints.add(point);
_logInfo(
'$_dailyStressLogMarker result userId=$userId date=$date '
'stressValue=${point.stressValue} stressScore=${point.stressScore} '
'state=${point.state.value}',
);
}
await _localStore.upsertDailyStressPoints(
userId: userId,
... ... @@ -646,6 +1048,10 @@ class OHOSHealthRawDataCoreService {
userId: userId,
dates: emptyDates,
);
_logInfo(
'$_dailyStressLogMarker stored userId=$userId '
'stored=${dailyStressPoints.length} deletedEmptyDates=$emptyDates',
);
return dailyStressPoints;
}
... ... @@ -654,12 +1060,24 @@ class OHOSHealthRawDataCoreService {
required List<HealthKitRawDataPoint> sleepIntervals,
required int? latestSleepResultTime,
}) async {
if (sleepIntervals.isEmpty) return const <HealthRawSleepResult>[];
_logInfo(
'$_sleepCalcLogMarker start userId=$userId '
'sleepIntervals=${sleepIntervals.length} '
'latestSleepResultTime=$latestSleepResultTime',
);
if (sleepIntervals.isEmpty) {
_logInfo('$_sleepCalcLogMarker no_raw_sleep userId=$userId');
return const <HealthRawSleepResult>[];
}
final days = {
for (final interval in sleepIntervals)
DateTime.fromMillisecondsSinceEpoch(interval.endTime * 1000)
}.map((date) => DateTime(date.year, date.month, date.day)).toList()
..sort((a, b) => a.compareTo(b));
_logInfo(
'$_sleepCalcLogMarker days userId=$userId '
'days=${days.map(_dateKeyFromDateTime).toList()}',
);
final results = <HealthRawSleepResult>[];
for (final day in days) {
final calculation = HealthSleepCalculator.calculateDay(
... ... @@ -669,10 +1087,30 @@ class OHOSHealthRawDataCoreService {
final merged = calculation.mergeSleepTimeRange;
final score = calculation.score;
final state = calculation.state;
if (merged == null || score == null || state == null) continue;
if (!calculation.hasValidSleep) continue;
if (merged == null || score == null || state == null) {
_logInfo(
'$_sleepCalcLogMarker skip_invalid userId=$userId '
'date=${_dateKeyFromDateTime(day)} '
'reason=missing_score_or_state '
'score=$score state=${state?.value}',
);
continue;
}
if (!calculation.hasValidSleep) {
_logInfo(
'$_sleepCalcLogMarker skip_invalid userId=$userId '
'date=${_dateKeyFromDateTime(day)} reason=no_valid_sleep '
'durationSeconds=${calculation.durationSeconds}',
);
continue;
}
if (latestSleepResultTime != null &&
merged.endTime <= latestSleepResultTime) {
_logInfo(
'$_sleepCalcLogMarker skip_existing userId=$userId '
'date=${_dateKeyFromDateTime(day)} mergedEnd=${merged.endTime} '
'latestSleepResultTime=$latestSleepResultTime',
);
continue;
}
results.add(
... ... @@ -688,8 +1126,17 @@ class OHOSHealthRawDataCoreService {
uploaded: false,
),
);
_logInfo(
'$_sleepCalcLogMarker result userId=$userId '
'date=${_dateKeyFromDateTime(day)} start=${merged.startTime} '
'end=${merged.endTime} score=$score state=${state.value} '
'sleepMinutes=${calculation.summary.sleepMinutes}',
);
}
await _localStore.upsertSleepResults(userId: userId, results: results);
_logInfo(
'$_sleepCalcLogMarker stored userId=$userId stored=${results.length}',
);
return results;
}
... ... @@ -774,6 +1221,10 @@ class OHOSHealthRawDataCoreService {
static int _dateKeyFromUnixSeconds(int seconds) {
final date = DateTime.fromMillisecondsSinceEpoch(seconds * 1000);
return _dateKeyFromDateTime(date);
}
static int _dateKeyFromDateTime(DateTime date) {
return date.year * 10000 + date.month * 100 + date.day;
}
... ... @@ -819,3 +1270,15 @@ class OHOSHealthRawDataCoreService {
}
}
}
class _OhosStoredCalculationResult {
const _OhosStoredCalculationResult({
required this.result,
required this.hasExistingHrv,
required this.hasExistingSleep,
});
final HealthRawStressCalculationResult result;
final bool hasExistingHrv;
final bool hasExistingSleep;
}
... ...
... ... @@ -281,10 +281,7 @@ class OhosHealthRawDataSyncService {
_unixSeconds(
_nowProvider().subtract(const Duration(days: defaultLookbackDays)),
);
final dayStartTime = _startOfLocalDay(baseStartTime);
final resolvedStartTime = _isDailyDataType(dataType)
? dayStartTime - Duration.secondsPerDay
: dayStartTime;
final resolvedStartTime = _startOfLocalDay(baseStartTime);
return resolvedStartTime > endTime ? endTime : resolvedStartTime;
}
... ... @@ -297,14 +294,6 @@ class OhosHealthRawDataSyncService {
return _unixSeconds(DateTime(dateTime.year, dateTime.month, dateTime.day));
}
bool _isDailyDataType(int dataType) {
return dataType == HuaweiHealthDataType.activity.dataType ||
dataType == HuaweiHealthDataType.exerciseDuration.dataType ||
dataType == HuaweiHealthDataType.standingDuration.dataType ||
dataType == HuaweiHealthDataType.stepCount.dataType ||
dataType == OhosHealthRawDataType.activitySummary;
}
List<OhosHealthRawDataFetchRange> _splitIntoFetchRanges({
required int dataType,
required int startTime,
... ...
import 'package:doublefeel_flutter/core/services/raw_data_service/health_sleep_calculator.dart';
import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
... ... @@ -11,4 +12,40 @@ void main() {
expect(HealthSleepCalculator.sleepState(85), HealthSleepState.great);
});
});
test('calculates overnight sleep with iOS-compatible sleep intervals', () {
final day = DateTime(2026, 8, 31);
final start = HealthSleepCalculator.unixSeconds(
day.subtract(const Duration(hours: 1)),
);
final deepEnd = HealthSleepCalculator.unixSeconds(
day.add(const Duration(hours: 2)),
);
final end = HealthSleepCalculator.unixSeconds(
day.add(const Duration(hours: 7)),
);
final calculation = HealthSleepCalculator.calculateDay(
day: day,
sleepIntervals: [
HealthKitRawDataPoint(
dataType: 4,
startTime: start,
endTime: deepEnd,
),
HealthKitRawDataPoint(
dataType: 3,
startTime: deepEnd,
endTime: end,
),
],
);
expect(calculation.hasValidSleep, isTrue);
expect(calculation.mergeSleepTimeRange?.startTime, start);
expect(calculation.mergeSleepTimeRange?.endTime, end);
expect(calculation.summary.sleepMinutes, 8 * 60);
expect(calculation.score, isNotNull);
expect(calculation.state, isNotNull);
});
}
... ...
... ... @@ -7,8 +7,7 @@ import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('OHOS core calculates and stores Huawei hrv realtime and daily results',
() async {
test('OHOS core calculates and stores all Huawei result tables', () async {
final day = DateTime.now().subtract(const Duration(days: 7));
final base =
DateTime(day.year, day.month, day.day, 10).millisecondsSinceEpoch ~/
... ... @@ -20,6 +19,18 @@ void main() {
1: [_point(1, base + 300, 60)],
2: [_point(2, base + 300, 80)],
},
sleepPoints: [
HealthKitRawDataPoint(
dataType: 4,
startTime: base - 8 * 60 * 60,
endTime: base - 4 * 60 * 60,
),
HealthKitRawDataPoint(
dataType: 3,
startTime: base - 4 * 60 * 60,
endTime: base - 1 * 60 * 60,
),
],
),
localStore: store,
userIdProvider: () => 42,
... ... @@ -28,7 +39,7 @@ void main() {
);
final result = await service.syncAndStore(
startTime: base,
startTime: base - 9 * 60 * 60,
endTime: base + 600,
readChunkDays: 1,
);
... ... @@ -37,16 +48,22 @@ void main() {
expect(result.hrvStressPoints.single.result, 50);
expect(result.realtimeStressPoints, hasLength(1));
expect(result.dailyStressPoints, hasLength(1));
expect(result.sleepResults, hasLength(1));
expect(store.hrvStressPoints.single.result, 50);
expect(store.realtimeStressPoints, hasLength(1));
expect(store.dailyStressPoints, hasLength(1));
expect(store.sleepResults, hasLength(1));
});
}
class _FakeHealthRawDataSource implements HealthRawDataSource {
_FakeHealthRawDataSource({required this.pointsByDataType});
_FakeHealthRawDataSource({
required this.pointsByDataType,
this.sleepPoints = const <HealthKitRawDataPoint>[],
});
final Map<int, List<HealthKitRawDataPoint>> pointsByDataType;
final List<HealthKitRawDataPoint> sleepPoints;
@override
Future<V2ActivityTarget?> getActivityGoal({bool refresh = true}) async {
... ... @@ -78,7 +95,15 @@ class _FakeHealthRawDataSource implements HealthRawDataSource {
int startTime,
int endTime,
) async {
return const <HealthKitRawSleepDataPoint>[];
return [
HealthKitRawSleepDataPoint(
dataType: 0,
sleepDataPoints: sleepPoints
.where((point) =>
point.endTime >= startTime && point.startTime <= endTime)
.toList(growable: false),
),
];
}
@override
... ... @@ -99,6 +124,7 @@ class _FakeHealthRawStressLocalStore extends HealthRawStressLocalStore {
final hrvStressPoints = <HealthRawHrvStressPoint>[];
final realtimeStressPoints = <HealthRawRealtimeStressPoint>[];
final dailyStressPoints = <HealthRawDailyStressPoint>[];
final sleepResults = <HealthRawSleepResult>[];
@override
Future<void> ensureReadable(int userId) async {}
... ... @@ -174,7 +200,9 @@ class _FakeHealthRawStressLocalStore extends HealthRawStressLocalStore {
Future<void> upsertSleepResults({
required int userId,
required Iterable<HealthRawSleepResult> results,
}) async {}
}) async {
sleepResults.addAll(results);
}
}
HealthKitRawDataPoint _point(int dataType, int time, double value) {
... ...
... ... @@ -74,8 +74,7 @@ void main() {
),
);
final expectedStart =
_unixSeconds(DateTime(fallback.year, fallback.month, fallback.day)) -
Duration.secondsPerDay;
_unixSeconds(DateTime(fallback.year, fallback.month, fallback.day));
final expectedEnd = now.millisecondsSinceEpoch ~/ 1000;
expect(result.startTime, expectedStart);
... ...