Commit c2b507de4769c821296904fd29e5ed89f35ce746

Authored by 权海
1 parent 5b5ca491

feat(ui):修复睡眠时间错误

... ... @@ -12,6 +12,7 @@
.swiftpm/
migrate_working_dir/
SDK/
ohos/
# IntelliJ related
*.iml
... ... @@ -34,6 +35,7 @@ SDK/
.pub/
/build/
# Symbolication related
app.*.symbols
... ...
... ... @@ -340,7 +340,7 @@ class OhosHealthRawDataSource implements HealthRawDataSource {
HealthKitRawDataPoint _sleepPointFromItem(OhosHealthRawDataItem item) {
return HealthKitRawDataPoint(
dataType: _appleSleepType(item.dataType),
dataType: item.dataType,
startTime: _intPayload(item.payload, 'from_time') ?? item.dataTime,
endTime: _intPayload(item.payload, 'to_time') ?? item.dataTime,
value: null,
... ... @@ -376,19 +376,6 @@ class OhosHealthRawDataSource implements HealthRawDataSource {
return point;
}
int _appleSleepType(int ohosSleepType) {
return switch (ohosSleepType) {
1 => 4, // deep
2 => 3, // light/core
3 => 5, // REM
4 => 2, // awake
5 => 1, // nap/asleep
6 => 0, // in bed
7 => 1, // manual sleep
_ => 1,
};
}
int? _intPayload(Map<String, Object?> payload, String key) {
final value = payload[key];
return value is num ? value.toInt() : null;
... ...
... ... @@ -19,7 +19,9 @@ class HealthSleepCalculator {
required DateTime day,
required List<HealthKitRawDataPoint> sleepIntervals,
}) {
final intervals = completeSleepIntervalsForDay(day, sleepIntervals);
final intervals = mergeSameTypeSleepIntervals(
completeSleepIntervalsForDay(day, sleepIntervals),
);
final mergedInterval = mergeContinuousSleepIntervals(intervals);
final windowStart = mergedInterval?.startTime ?? unixSeconds(day);
final windowEnd = mergedInterval?.endTime ??
... ... @@ -109,6 +111,59 @@ class HealthSleepCalculator {
);
}
static List<HealthKitRawDataPoint> mergeSameTypeSleepIntervals(
List<HealthKitRawDataPoint> sleepIntervals,
) {
if (sleepIntervals.length < 2) return sleepIntervals;
final byType = <int, List<HealthKitRawDataPoint>>{};
for (final interval in sleepIntervals) {
byType.putIfAbsent(interval.dataType, () => []).add(interval);
}
final merged = <HealthKitRawDataPoint>[];
for (final entry in byType.entries) {
final intervals = entry.value
..sort((a, b) {
final startCompare = a.startTime.compareTo(b.startTime);
if (startCompare != 0) return startCompare;
return a.endTime.compareTo(b.endTime);
});
var start = intervals.first.startTime;
var end = intervals.first.endTime;
for (final interval in intervals.skip(1)) {
if (interval.startTime <= end + _sleepContinuityToleranceSeconds) {
end = math.max(end, interval.endTime);
continue;
}
merged.add(
HealthKitRawDataPoint(
dataType: entry.key,
startTime: start,
endTime: end,
),
);
start = interval.startTime;
end = interval.endTime;
}
merged.add(
HealthKitRawDataPoint(
dataType: entry.key,
startTime: start,
endTime: end,
),
);
}
return merged
..sort((a, b) {
final startCompare = a.startTime.compareTo(b.startTime);
if (startCompare != 0) return startCompare;
final endCompare = a.endTime.compareTo(b.endTime);
if (endCompare != 0) return endCompare;
return a.dataType.compareTo(b.dataType);
});
}
static HealthSleepSummary sleepSummary(
List<HealthKitRawDataPoint> intervals,
int windowStart,
... ...
... ... @@ -241,12 +241,14 @@ class OHOSHealthRawDataCoreService {
final hasAuthorization = await _hasHealthReadAuthorizationSafely();
final syncResults = await _syncCalculationRawDataSafely(
hasAuthorization: hasAuthorization,
startTime: requestedStartTime,
startTime: forceStartTime,
endTime: effectiveEndTime,
);
final syncedRawStartTime = _earliestStoredTime(syncResults);
_logInfo(
'calculate_sync_finish startTime=$requestedStartTime '
'endTime=$effectiveEndTime syncResults=$syncResults',
'endTime=$effectiveEndTime syncedRawStartTime=$syncedRawStartTime '
'syncResults=$syncResults',
);
final storedResult = await _calculateAndStoreSafely(
... ... @@ -254,6 +256,7 @@ class OHOSHealthRawDataCoreService {
requestedStartTime: requestedStartTime,
effectiveEndTime: effectiveEndTime,
earliestStartTime: earliestStartTime,
syncedRawStartTime: syncedRawStartTime,
readChunkDays: readChunkDays,
);
_scheduleResultUpload();
... ... @@ -270,6 +273,7 @@ class OHOSHealthRawDataCoreService {
required int requestedStartTime,
required int effectiveEndTime,
required int earliestStartTime,
required int? syncedRawStartTime,
required int readChunkDays,
}) async {
var hasExistingHrv = false;
... ... @@ -288,27 +292,36 @@ class OHOSHealthRawDataCoreService {
hasExistingHrv = latestHrvRawEndTime != null;
hasExistingSleep = latestSleepResultTime != null;
final calculationStartTime = math.max(
_minNullable(requestedStartTime, syncedRawStartTime) ??
requestedStartTime,
earliestStartTime,
);
final hrvStartTime = math.max(
hrvContextStart ?? requestedStartTime,
_minNullable(hrvContextStart, calculationStartTime) ??
calculationStartTime,
earliestStartTime,
);
final realtimeStartTime = math.max(
realtimeContextStart ?? requestedStartTime,
_minNullable(realtimeContextStart, calculationStartTime) ??
calculationStartTime,
earliestStartTime,
);
final heartRateStartTime = math.max(
_minNullable(hrvStartTime, realtimeStartTime) ?? requestedStartTime,
_minNullable(hrvStartTime, realtimeStartTime) ?? calculationStartTime,
earliestStartTime,
);
final sleepAnchorTime = latestSleepResultTime == null
? calculationStartTime
: latestSleepResultTime - Duration.secondsPerDay;
final sleepStartTime = math.max(
latestSleepResultTime == null
? requestedStartTime
: latestSleepResultTime - Duration.secondsPerDay,
_minNullable(sleepAnchorTime, syncedRawStartTime) ?? sleepAnchorTime,
earliestStartTime,
);
_logInfo(
'calculate_context userId=$userId '
'requestedStartTime=$requestedStartTime endTime=$effectiveEndTime '
'syncedRawStartTime=$syncedRawStartTime '
'hrvContextStart=$hrvContextStart '
'realtimeContextStart=$realtimeContextStart '
'latestHrvRawEndTime=$latestHrvRawEndTime '
... ... @@ -385,10 +398,12 @@ class OHOSHealthRawDataCoreService {
hrvStressPoints: _filterNewHrvStressPoints(
result.hrvStressPoints,
latestHrvRawEndTime,
recomputeStartTime: syncedRawStartTime,
),
realtimeStressPoints: _filterNewRealtimeStressPoints(
result.realtimeStressPoints,
latestRealtimeRawEndTime,
recomputeStartTime: syncedRawStartTime,
),
);
_logInfo(
... ... @@ -884,7 +899,7 @@ class OHOSHealthRawDataCoreService {
}
Future<List<OhosHealthRawDataSyncResult>> _syncCalculationRawDataIfNeeded({
required int startTime,
required int? startTime,
required int endTime,
}) async {
final rawDataSource = _rawDataSource;
... ... @@ -899,7 +914,7 @@ class OHOSHealthRawDataCoreService {
Future<List<OhosHealthRawDataSyncResult>> _syncCalculationRawDataSafely({
required bool hasAuthorization,
required int startTime,
required int? startTime,
required int endTime,
}) async {
if (!hasAuthorization) {
... ... @@ -1002,9 +1017,8 @@ class OHOSHealthRawDataCoreService {
for (final date in affectedDates) {
if (date != todayDate && existingDates.contains(date)) {
_logInfo(
'$_dailyStressLogMarker skip_existing userId=$userId date=$date',
'$_dailyStressLogMarker refresh_existing userId=$userId date=$date',
);
continue;
}
final (startTime, endTime) = _dayRangeFromDateKey(date);
final dayRealtimePoints = await _localStore.queryRealtimeStressPoints(
... ... @@ -1139,24 +1153,48 @@ class OHOSHealthRawDataCoreService {
List<HealthRawHrvStressPoint> _filterNewHrvStressPoints(
List<HealthRawHrvStressPoint> points,
int? latestRawEndTime,
) {
if (latestRawEndTime == null) return points;
int? latestRawEndTime, {
int? recomputeStartTime,
}) {
if (latestRawEndTime == null && recomputeStartTime == null) return points;
return points
.where((point) => point.rawEndTime > latestRawEndTime)
.where(
(point) =>
(latestRawEndTime == null ||
point.rawEndTime > latestRawEndTime) ||
(recomputeStartTime != null &&
point.rawEndTime >= recomputeStartTime),
)
.toList();
}
List<HealthRawRealtimeStressPoint> _filterNewRealtimeStressPoints(
List<HealthRawRealtimeStressPoint> points,
int? latestRawEndTime,
) {
if (latestRawEndTime == null) return points;
int? latestRawEndTime, {
int? recomputeStartTime,
}) {
if (latestRawEndTime == null && recomputeStartTime == null) return points;
return points
.where((point) => point.rawEndTime > latestRawEndTime)
.where(
(point) =>
(latestRawEndTime == null ||
point.rawEndTime > latestRawEndTime) ||
(recomputeStartTime != null &&
point.rawEndTime >= recomputeStartTime),
)
.toList();
}
int? _earliestStoredTime(List<OhosHealthRawDataSyncResult> syncResults) {
int? earliest;
for (final result in syncResults) {
final time = result.earliestStoredTime;
if (time == null) continue;
earliest = earliest == null ? time : math.min(earliest, time);
}
return earliest;
}
Future<List<HealthKitRawDataPoint>> _fetchRawDataInChunks(
int dataType,
int startTime,
... ...
... ... @@ -476,7 +476,7 @@ CREATE TABLE IF NOT EXISTS $table (
if (_isDailyDataType(dataType)) {
return keyTime >= _startOfLocalDay(latestTime) - Duration.secondsPerDay;
}
return keyTime > latestTime;
return true;
}
int _dedupeKeyTime({
... ...
import 'dart:math' as math;
import 'package:doublefeel_flutter/core/logging/app_logger.dart';
import 'package:doublefeel_flutter/core/result/app_result.dart';
import 'package:doublefeel_flutter/data/models/enums/app_enums.dart';
... ... @@ -30,6 +32,7 @@ class OhosHealthRawDataSyncService {
_logSink = logSink;
static const int defaultLookbackDays = 183;
static const int incrementalBackfillDays = 7;
static const int heartRateFetchChunkDays = 10;
static const int defaultFetchChunkDays = 30;
static const String logMarker = '[OHOS_HEALTH_RAW_SYNC]';
... ... @@ -355,6 +358,7 @@ class OhosHealthRawDataSyncService {
segmentCount: fetched.segmentCount,
pageCount: pageCount,
storedCount: storedCount,
earliestStoredTime: storedCount > 0 ? fetched.earliestItemTime : null,
);
}
... ... @@ -364,11 +368,21 @@ class OhosHealthRawDataSyncService {
required int endTime,
}) async {
final latestDataTime = await _localStore.latestDataTime(dataType: dataType);
final baseStartTime = latestDataTime ??
requestedStartTime ??
_unixSeconds(
_nowProvider().subtract(const Duration(days: defaultLookbackDays)),
);
final int baseStartTime;
if (latestDataTime != null) {
final backfillStart = math.max(
0,
latestDataTime - incrementalBackfillDays * Duration.secondsPerDay,
);
baseStartTime = requestedStartTime == null
? backfillStart
: math.min(requestedStartTime, backfillStart);
} else {
baseStartTime = requestedStartTime ??
_unixSeconds(
_nowProvider().subtract(const Duration(days: defaultLookbackDays)),
);
}
final resolvedStartTime = _startOfLocalDay(baseStartTime);
return resolvedStartTime > endTime ? endTime : resolvedStartTime;
}
... ... @@ -735,6 +749,17 @@ class _FetchedRawDataSync {
final List<_FetchedRawDataSegment> segments;
int get segmentCount => segments.length;
int? get earliestItemTime {
int? earliest;
for (final segment in segments) {
for (final item in segment.page.items) {
final keyTime = _dedupeKeyTime(dataType: dataType, item: item);
earliest = earliest == null ? keyTime : math.min(earliest, keyTime);
}
}
return earliest;
}
}
class _FetchedRawDataSegment {
... ... @@ -757,6 +782,7 @@ class OhosHealthRawDataSyncResult {
this.segmentCount = 1,
required this.pageCount,
required this.storedCount,
this.earliestStoredTime,
});
final int dataType;
... ... @@ -765,6 +791,7 @@ class OhosHealthRawDataSyncResult {
final int segmentCount;
final int pageCount;
final int storedCount;
final int? earliestStoredTime;
@override
String toString() {
... ... @@ -774,11 +801,30 @@ class OhosHealthRawDataSyncResult {
'endTime=$endTime, '
'segmentCount=$segmentCount, '
'pageCount=$pageCount, '
'storedCount=$storedCount'
'storedCount=$storedCount, '
'earliestStoredTime=$earliestStoredTime'
')';
}
}
int _dedupeKeyTime({
required int dataType,
required OhosHealthRawDataItem item,
}) {
if (dataType == OhosHealthRawDataType.sleepAnalysis ||
dataType == OhosHealthRawDataType.workout) {
return _numPayload(item.payload, 'from_time') ??
_numPayload(item.payload, 'start_time') ??
item.dataTime;
}
return _numPayload(item.payload, 'time') ?? item.dataTime;
}
int? _numPayload(Map<String, Object?> payload, String key) {
final value = payload[key];
return value is num ? value.toInt() : null;
}
int _dateKeyFromUnixSeconds(int seconds) {
final dateTime = DateTime.fromMillisecondsSinceEpoch(seconds * 1000);
return _dateKeyFromDateTime(dateTime);
... ...
... ... @@ -141,7 +141,7 @@ class LocalHealthDataConvert {
avgHr: shouldIncludeHeartRate
? _averageOrNull(
allSleepHr.map((e) => e.value).whereType<num>().toList(),
)
)?.floor()
: null,
maxHr: shouldIncludeHeartRate
? _maxOrNull(
... ... @@ -234,7 +234,7 @@ class LocalHealthDataConvert {
avgHr: shouldIncludeHeartRate
? _averageOrNull(
allSleepHr.map((e) => e.value).whereType<num>().toList(),
)
)?.floor()
: null,
maxHr: shouldIncludeHeartRate
? _maxOrNull(
... ...
... ... @@ -38,14 +38,14 @@ class HmSleepDataItem {
num? fromTime;
num? toTime;
/// 0:未知
/// 1:深睡
/// 2:浅睡
/// 3:REM
/// 4:清醒
/// 5:午睡(零星小睡)
/// 6:卧床
/// 7:睡眠(手工)
/// Apple Health/iOS-compatible sleep stage.
/// 0:卧床
/// 1:睡眠(未指定)
/// 2:清醒
/// 3:核心睡眠
/// 4:深睡
/// 5:REM
num? dataType;
Map<String, dynamic> toJson() {
... ...
... ... @@ -5,12 +5,12 @@
"name": "auto",
"type": "HarmonyOS",
"material": {
"certpath": "/Users/liuhongzhe/.ohos/config/debug_ohos_WWjJA4uV69RmLVhj0o_rA0VEMyuWGcKp7RSWudtmCR0=.cer",
"certpath": "/Users/quanhai/Developer/doublefeel_flutter/.ohos/config/debug_ohos_WWjJA4uV69RmLVhj0o_rA0VEMyuWGcKp7RSWudtmCR0=.cer",
"keyAlias": "debugKey",
"keyPassword": "00000019084446C3F235D890CFE9AC6015D14270D13D370ACF44FEDD89097E1ABBBDE3810C8FEBFFFD",
"profile": "/Users/liuhongzhe/.ohos/config/debug_ohos_WWjJA4uV69RmLVhj0o_rA0VEMyuWGcKp7RSWudtmCR0=.p7b",
"profile": "/Users/quanhai/Developer/doublefeel_flutter/.ohos/config/debug_ohos_WWjJA4uV69RmLVhj0o_rA0VEMyuWGcKp7RSWudtmCR0=.p7b",
"signAlg": "SHA256withECDSA",
"storeFile": "/Users/liuhongzhe/.ohos/config/debug_ohos_WWjJA4uV69RmLVhj0o_rA0VEMyuWGcKp7RSWudtmCR0=.p12",
"storeFile": "/Users/quanhai/Developer/doublefeel_flutter/.ohos/config/debug_ohos_WWjJA4uV69RmLVhj0o_rA0VEMyuWGcKp7RSWudtmCR0=.p12",
"storePassword": "00000019D85309732E0C3572D434400BA13D429AE5A182B54C1EA6F2C9D7DB5AE81BD9A64F12ACC681"
}
},
... ... @@ -18,33 +18,33 @@
"name": "release",
"type": "HarmonyOS",
"material": {
"storeFile": "/Users/liuhongzhe/Work/DD/Project/GitLab/doublefeel_flutter/ohos/keystore/doublefeel.p12",
"storeFile": "/Users/quanhai/Developer/doublefeel_flutter/ohos/keystore/doublefeel.p12",
"storePassword": "0000001E8F02C32C2D5431F31FC368F020D891F18E6DC45A5913C60CACC1074B11282B3527F3E7D420F00B21B13A",
"keyAlias": "DoubleFeel",
"keyPassword": "0000001ECC963DD8029FC0957D403D334E6A864BA98BD1ABF0F96E41AEB1002C3CBAFE12680A33902C1D5C61E7C9",
"signAlg": "SHA256withECDSA",
"profile": "/Users/liuhongzhe/Work/DD/Project/GitLab/doublefeel_flutter/ohos/keystore/release/DoubleFeel_hm_releaseRelease.p7b",
"certpath": "/Users/liuhongzhe/Work/DD/Project/GitLab/doublefeel_flutter/ohos/keystore/release/DoubleFeelWatch_Release.cer"
"profile": "/Users/quanhai/Developer/doublefeel_flutter/ohos/keystore/release/DoubleFeel_hm_releaseRelease.p7b",
"certpath": "/Users/quanhai/Developer/doublefeel_flutter/ohos/keystore/release/DoubleFeelWatch_Release.cer"
}
},
{
"name": "debug",
"type": "HarmonyOS",
"material": {
"storeFile": "/Users/liuhongzhe/Work/DD/Project/GitLab/doublefeel_flutter/ohos/keystore/doublefeel.p12",
"storeFile": "/Users/quanhai/Developer/doublefeel_flutter/ohos/keystore/doublefeel.p12",
"storePassword": "0000001E1BE8542E4E9F7969905217A5421D89506648518A33A93BE1045A511094FF10805EC4686172CC000ED9FA",
"keyAlias": "DoubleFeel",
"keyPassword": "0000001EFBAD9B2C408962C5BF2044ED8A57E4E192D171CAC3EBFF8D648684E52A690DFD6B8BDD7DC28AF8D6174C",
"signAlg": "SHA256withECDSA",
"profile": "/Users/liuhongzhe/Work/DD/Project/GitLab/doublefeel_flutter/ohos/keystore/debug/DoubleFeel_hm_debugDebug.p7b",
"certpath": "/Users/liuhongzhe/Work/DD/Project/GitLab/doublefeel_flutter/ohos/keystore/debug/DoubleFeel_hm_debug.cer"
"profile": "/Users/quanhai/Developer/doublefeel_flutter/ohos/keystore/debug/DoubleFeel_hm_debugDebug.p7b",
"certpath": "/Users/quanhai/Developer/doublefeel_flutter/ohos/keystore/debug/DoubleFeel_hm_debug.cer"
}
}
],
"products": [
{
"name": "default",
"signingConfig": "auto",
"signingConfig": "debug",
"targetSdkVersion": "6.0.0(20)",
"compatibleSdkVersion": "6.0.0(20)",
"runtimeOS": "HarmonyOS",
... ...
... ... @@ -48,4 +48,82 @@ void main() {
expect(calculation.score, isNotNull);
expect(calculation.state, isNotNull);
});
test('deduplicates overlapping intervals with the same sleep type', () {
final day = DateTime(2026, 8, 31);
final start = HealthSleepCalculator.unixSeconds(day);
final firstEnd = start + const Duration(hours: 1).inSeconds;
final overlapStart = start + const Duration(minutes: 30).inSeconds;
final secondEnd = start + const Duration(minutes: 90).inSeconds;
final deepEnd = start + const Duration(hours: 2).inSeconds;
final calculation = HealthSleepCalculator.calculateDay(
day: day,
sleepIntervals: [
HealthKitRawDataPoint(
dataType: 3,
startTime: start,
endTime: firstEnd,
),
HealthKitRawDataPoint(
dataType: 3,
startTime: overlapStart,
endTime: secondEnd,
),
HealthKitRawDataPoint(
dataType: 4,
startTime: secondEnd,
endTime: deepEnd,
),
],
);
expect(calculation.summary.coreMinutes, 90);
expect(calculation.summary.deepMinutes, 30);
expect(calculation.summary.sleepMinutes, 120);
expect(calculation.durationSeconds, const Duration(hours: 2).inSeconds);
});
test('excludes awake intervals from provided OHOS sleep sample', () {
final day = DateTime.fromMillisecondsSinceEpoch(1788399300 * 1000);
final calculation = HealthSleepCalculator.calculateDay(
day: DateTime(day.year, day.month, day.day),
sleepIntervals: [
_sleepPoint(1, 1788398340, 1788399300),
_sleepPoint(2, 1788375120, 1788375480),
_sleepPoint(1, 1788375480, 1788378420),
_sleepPoint(4, 1788378420, 1788379200),
_sleepPoint(2, 1788379200, 1788379380),
_sleepPoint(1, 1788379380, 1788381300),
_sleepPoint(4, 1788381300, 1788383100),
_sleepPoint(1, 1788383100, 1788384660),
_sleepPoint(4, 1788384660, 1788385020),
_sleepPoint(1, 1788385020, 1788385740),
_sleepPoint(4, 1788385740, 1788386460),
_sleepPoint(1, 1788386460, 1788387480),
_sleepPoint(5, 1788387480, 1788389640),
_sleepPoint(1, 1788389640, 1788391620),
_sleepPoint(4, 1788391620, 1788393660),
_sleepPoint(5, 1788393660, 1788394320),
_sleepPoint(1, 1788394320, 1788395760),
_sleepPoint(5, 1788395760, 1788396300),
_sleepPoint(1, 1788396300, 1788397680),
_sleepPoint(2, 1788397680, 1788398340),
],
);
expect(calculation.summary.awakeMinutes, 20);
expect(calculation.summary.sleepMinutes, 6 * 60 + 23);
expect(calculation.durationSeconds,
const Duration(hours: 6, minutes: 23).inSeconds);
});
}
HealthKitRawDataPoint _sleepPoint(int dataType, int startTime, int endTime) {
return HealthKitRawDataPoint(
dataType: dataType,
startTime: startTime,
endTime: endTime,
);
}
... ...
... ... @@ -27,8 +27,13 @@ void main() {
endTime: base - 4 * 60 * 60,
),
HealthKitRawDataPoint(
dataType: 3,
dataType: 2,
startTime: base - 4 * 60 * 60,
endTime: base - 3 * 60 * 60,
),
HealthKitRawDataPoint(
dataType: 3,
startTime: base - 3 * 60 * 60,
endTime: base - 1 * 60 * 60,
),
],
... ... @@ -54,6 +59,193 @@ void main() {
expect(store.realtimeStressPoints, hasLength(1));
expect(store.dailyStressPoints, hasLength(1));
expect(store.sleepResults, hasLength(1));
expect(store.sleepResults.single.awakMinutes, 60);
expect(store.sleepResults.single.sleepMinutes, 6 * 60);
});
test('OHOS core excludes awake sleep stages from stored sleep duration',
() async {
final store = _FakeHealthRawStressLocalStore();
final service = OHOSHealthRawDataCoreService(
rawDataSource: _FakeHealthRawDataSource(
pointsByDataType: const {},
sleepPoints: [
HealthKitRawDataPoint(
dataType: 1,
startTime: 1788398340,
endTime: 1788399300,
),
HealthKitRawDataPoint(
dataType: 2,
startTime: 1788375120,
endTime: 1788375480,
),
HealthKitRawDataPoint(
dataType: 1,
startTime: 1788375480,
endTime: 1788378420,
),
HealthKitRawDataPoint(
dataType: 4,
startTime: 1788378420,
endTime: 1788379200,
),
HealthKitRawDataPoint(
dataType: 2,
startTime: 1788379200,
endTime: 1788379380,
),
HealthKitRawDataPoint(
dataType: 1,
startTime: 1788379380,
endTime: 1788381300,
),
HealthKitRawDataPoint(
dataType: 4,
startTime: 1788381300,
endTime: 1788383100,
),
HealthKitRawDataPoint(
dataType: 1,
startTime: 1788383100,
endTime: 1788384660,
),
HealthKitRawDataPoint(
dataType: 4,
startTime: 1788384660,
endTime: 1788385020,
),
HealthKitRawDataPoint(
dataType: 1,
startTime: 1788385020,
endTime: 1788385740,
),
HealthKitRawDataPoint(
dataType: 4,
startTime: 1788385740,
endTime: 1788386460,
),
HealthKitRawDataPoint(
dataType: 1,
startTime: 1788386460,
endTime: 1788387480,
),
HealthKitRawDataPoint(
dataType: 5,
startTime: 1788387480,
endTime: 1788389640,
),
HealthKitRawDataPoint(
dataType: 1,
startTime: 1788389640,
endTime: 1788391620,
),
HealthKitRawDataPoint(
dataType: 4,
startTime: 1788391620,
endTime: 1788393660,
),
HealthKitRawDataPoint(
dataType: 5,
startTime: 1788393660,
endTime: 1788394320,
),
HealthKitRawDataPoint(
dataType: 1,
startTime: 1788394320,
endTime: 1788395760,
),
HealthKitRawDataPoint(
dataType: 5,
startTime: 1788395760,
endTime: 1788396300,
),
HealthKitRawDataPoint(
dataType: 1,
startTime: 1788396300,
endTime: 1788397680,
),
HealthKitRawDataPoint(
dataType: 2,
startTime: 1788397680,
endTime: 1788398340,
),
],
),
localStore: store,
userIdProvider: () => 42,
uploadResultsAfterCalculation: false,
healthReadAuthorizationChecker: () async => false,
);
final result = await service.syncAndStore(
startTime: 1788375120,
endTime: 1788399300,
readChunkDays: 1,
);
expect(result.sleepResults, hasLength(1));
expect(store.sleepResults.single.awakMinutes, 20);
expect(store.sleepResults.single.sleepMinutes, 6 * 60 + 23);
});
test('OHOS core recalculates daily stress for synced historical backfill',
() async {
final backfillDay = DateTime(2026, 8, 30);
final backfillBase =
backfillDay.add(const Duration(hours: 10)).millisecondsSinceEpoch ~/
1000;
final latestBase = DateTime(2026, 9, 3, 10).millisecondsSinceEpoch ~/ 1000;
final syncLocalStore =
_FakeOhosHealthRawDataLocalStore(latestDataTime: latestBase);
final remote = _HistoricalBackfillRemoteDataSource(
hrvItems: [_rawItem(1, backfillBase, 45)],
heartRateItems: [
for (var index = 0; index < 10; index++)
_rawItem(2, backfillBase + index * 6 * 60, 100),
],
);
final store = _FakeHealthRawStressLocalStore()
..hrvStressPoints.add(_existingHrvResult(latestBase))
..realtimeStressPoints.add(_existingRealtimeResult(latestBase));
final service = OHOSHealthRawDataCoreService(
rawDataSource: OhosHealthRawDataSource(
syncService: OhosHealthRawDataSyncService(
remoteDataSource: remote,
localStore: syncLocalStore,
),
),
localStore: store,
userIdProvider: () => 42,
uploadResultsAfterCalculation: false,
healthReadAuthorizationChecker: () async => true,
);
final result = await service.startCoreCaculate(
endTime: latestBase + Duration.secondsPerHour,
readChunkDays: 1,
);
expect(
remote.calls
.where((call) => call.dataType == 1 || call.dataType == 2)
.map((call) => _dateKey(call.startTime)),
everyElement(lessThanOrEqualTo(20260830)),
);
expect(
store.hrvStressPoints.any((point) => point.rawEndTime == backfillBase),
isTrue,
);
expect(
store.realtimeStressPoints.any(
(point) => _dateKey(point.rawEndTime) == 20260830,
),
isTrue,
);
expect(result.dailyStressPoints.map((point) => point.date),
contains(20260830));
expect(
store.dailyStressPoints.map((point) => point.date), contains(20260830));
});
test('OHOS core aborts calculation and shows debug toast when sync fails',
... ... @@ -112,10 +304,63 @@ class _FailingOhosHealthRawDataRemoteDataSource
}
}
class _HistoricalBackfillRemoteDataSource
implements OhosHealthRawDataRemoteDataSource {
_HistoricalBackfillRemoteDataSource({
required this.hrvItems,
required this.heartRateItems,
});
final List<OhosHealthRawDataItem> hrvItems;
final List<OhosHealthRawDataItem> heartRateItems;
final calls = <_RemoteRawCall>[];
@override
Future<OhosHealthRawDataPage> fetchRawDataPage({
required int dataType,
required int startTime,
required int endTime,
}) async {
calls.add(
_RemoteRawCall(
dataType: dataType,
startTime: startTime,
endTime: endTime,
),
);
if (dataType == 1) return OhosHealthRawDataPage(items: hrvItems);
if (dataType == 2) return OhosHealthRawDataPage(items: heartRateItems);
return const OhosHealthRawDataPage(items: <OhosHealthRawDataItem>[]);
}
@override
Future<V2ActivityTarget?> fetchActivityGoal() async {
return null;
}
}
class _RemoteRawCall {
const _RemoteRawCall({
required this.dataType,
required this.startTime,
required this.endTime,
});
final int dataType;
final int startTime;
final int endTime;
}
class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore {
_FakeOhosHealthRawDataLocalStore({int? latestDataTime})
: _latestDataTime = latestDataTime;
final int? _latestDataTime;
final storedBatches = <List<OhosHealthRawDataItem>>[];
@override
Future<int?> latestDataTime({required int dataType}) async {
return null;
return _latestDataTime;
}
@override
... ... @@ -124,7 +369,15 @@ class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore {
required int startTime,
required int endTime,
}) async {
return const <OhosHealthRawDataItem>[];
return storedBatches
.expand((batch) => batch)
.where(
(item) =>
item.dataType == dataType &&
item.dataTime >= startTime &&
item.dataTime <= endTime,
)
.toList(growable: false);
}
@override
... ... @@ -140,6 +393,7 @@ class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore {
required int dataType,
required List<OhosHealthRawDataItem> items,
}) async {
storedBatches.add(items);
return items.length;
}
}
... ... @@ -301,3 +555,47 @@ HealthKitRawDataPoint _point(int dataType, int time, double value) {
value: value,
);
}
OhosHealthRawDataItem _rawItem(int dataType, int time, double value) {
return OhosHealthRawDataItem(
dataType: dataType,
dataTime: time,
payload: <String, Object?>{
'time': time,
'value': value,
'is_asleep': 0,
},
);
}
HealthRawHrvStressPoint _existingHrvResult(int time) {
return HealthRawHrvStressPoint(
userId: 42,
rawEndTime: time,
rawHrv: 50,
result: 50,
sourceStartTime: time,
sourceEndTime: time,
state: HealthRawStressState.normal,
baselineHrv: 50,
baselineAwakeHrv: 50,
baselineSleepHrv: null,
baselineRestingHr: 65,
);
}
HealthRawRealtimeStressPoint _existingRealtimeResult(int time) {
return HealthRawRealtimeStressPoint(
userId: 42,
rawEndTime: time,
rawHr: 80,
result: 50,
sourceStartTime: time,
sourceEndTime: time,
);
}
int _dateKey(int seconds) {
final date = DateTime.fromMillisecondsSinceEpoch(seconds * 1000);
return date.year * 10000 + date.month * 100 + date.day;
}
... ...
... ... @@ -45,6 +45,58 @@ void main() {
expect(points.single.appleExerciseTime, 1800);
expect(points.single.appleStandHours, 2);
});
test('OHOS sleep data keeps server iOS-compatible sleep stages', () async {
const start = 1800000000;
const deepEnd = start + 3600;
const awakeEnd = deepEnd + 600;
const remEnd = awakeEnd + 1800;
final local = _FakeOhosHealthRawDataLocalStore(
itemsByDataType: {
OhosHealthRawDataType.sleepAnalysis: const [
OhosHealthRawDataItem(
dataType: 4,
dataTime: deepEnd,
payload: {
'data_type': 4,
'from_time': start,
'to_time': deepEnd,
},
),
OhosHealthRawDataItem(
dataType: 2,
dataTime: awakeEnd,
payload: {
'data_type': 2,
'from_time': deepEnd,
'to_time': awakeEnd,
},
),
OhosHealthRawDataItem(
dataType: 5,
dataTime: remEnd,
payload: {
'data_type': 5,
'from_time': awakeEnd,
'to_time': remEnd,
},
),
],
},
);
final syncService = OhosHealthRawDataSyncService(
remoteDataSource: const _FakeOhosHealthRawDataRemoteDataSource(),
localStore: local,
);
final dataSource = OhosHealthRawDataSource(syncService: syncService);
final groups = await dataSource.getRawSleepData(start, remEnd);
final points = groups.single.sleepDataPoints;
expect(points.map((point) => point.dataType), [4, 2, 5]);
expect(points.first.startTime, start);
expect(points.first.endTime, deepEnd);
});
}
class _FakeOhosHealthRawDataRemoteDataSource
... ...
... ... @@ -47,6 +47,36 @@ void main() {
expect(local.storedBatches.map((e) => e.length), [2]);
});
test('syncRawData backfills recent days when local data already exists',
() async {
final latest = _unixSeconds(DateTime(2026, 9, 3, 10));
final remote = _FakeOhosHealthRawDataRemoteDataSource([
const OhosHealthRawDataPage(
items: [
OhosHealthRawDataItem(
dataType: 1,
dataTime: 1788019232,
payload: {'time': 1788019232, 'value': 33},
),
],
),
]);
final local = _FakeOhosHealthRawDataLocalStore(latestDataTime: latest);
final service = OhosHealthRawDataSyncService(
remoteDataSource: remote,
localStore: local,
);
final result = await service.syncRawData(
dataType: HuaweiHealthDataType.hrv.dataType,
endTime: _unixSeconds(DateTime(2026, 9, 3, 11)),
);
expect(_dateKey(remote.calls.single.startTime), 20260827);
expect(result.storedCount, 1);
expect(result.earliestStoredTime, 1788019232);
});
test('syncRawData falls back to half-year lookback when local data is empty',
() async {
final now = DateTime(2026, 8, 13, 12);
... ... @@ -483,7 +513,7 @@ void main() {
HmSleepDataItem(
fromTime: 1787850000,
toTime: 1787870000,
dataType: 100,
dataType: 4,
),
HmSleepDataItem(
fromTime: 1787880000,
... ... @@ -502,9 +532,10 @@ void main() {
expect(client.sleepCalls.single.startDate, 20260801);
expect(client.sleepCalls.single.endDate, 20260802);
expect(page.items, hasLength(1));
expect(page.items.single.dataType, 100);
expect(page.items.single.dataType, 4);
expect(page.items.single.dataTime, 1787870000);
expect(page.items.single.payload['from_time'], 1787850000);
expect(page.items.single.payload['data_type'], 4);
});
test('Harmony remote data source calls dedicated workout API', () async {
... ...
... ... @@ -161,6 +161,53 @@ void main() {
);
});
test('floors average sleep heart rate from result statistics', () {
final day = DateTime(2026, 7, 16);
final sleepStart = DateTime(2026, 7, 15, 23);
final sleepEnd = DateTime(2026, 7, 16, 7);
final result = HealthRawSleepResult(
userId: 1,
date: LocalHealthDataConvert.unixSeconds(sleepEnd),
startDate: LocalHealthDataConvert.unixSeconds(sleepStart),
sleepScore: 85,
sleepState: 1,
inBedMinutes: 480,
awakMinutes: 80,
sleepMinutes: 400,
);
final statistics = LocalHealthDataConvert.sleepStatisticsFromResults(
dateRangeType: 3,
days: [day],
previousDays: const [],
sleepResults: [result],
heartRate: [
HealthKitRawDataPoint(
dataType: 1,
startTime: LocalHealthDataConvert.unixSeconds(sleepStart),
endTime: LocalHealthDataConvert.unixSeconds(
sleepStart.add(const Duration(hours: 1)),
),
value: 60,
),
HealthKitRawDataPoint(
dataType: 1,
startTime: LocalHealthDataConvert.unixSeconds(
sleepStart.add(const Duration(hours: 1)),
),
endTime: LocalHealthDataConvert.unixSeconds(
sleepStart.add(const Duration(hours: 2)),
),
value: 61,
),
],
);
expect(statistics.avgHr, 60);
expect(statistics.maxHr, 61);
expect(statistics.minHr, 60);
});
test('v2 health sleep values prefer sleep statistics trend result', () {
final day = DateTime(2026, 7, 16);
final result = HealthRawSleepResult(
... ... @@ -503,6 +550,58 @@ void main() {
const Duration(minutes: 40).inSeconds,
);
});
test('sleep statistics and v2 health data deduplicate same sleep type', () {
final day = DateTime(2026, 7, 16);
final start = DateTime(2026, 7, 16);
final firstEnd = start.add(const Duration(hours: 1));
final overlapStart = start.add(const Duration(minutes: 30));
final secondEnd = start.add(const Duration(minutes: 90));
final deepEnd = start.add(const Duration(hours: 2));
final intervals = [
HealthKitRawDataPoint(
dataType: 3,
startTime: LocalHealthDataConvert.unixSeconds(start),
endTime: LocalHealthDataConvert.unixSeconds(firstEnd),
),
HealthKitRawDataPoint(
dataType: 3,
startTime: LocalHealthDataConvert.unixSeconds(overlapStart),
endTime: LocalHealthDataConvert.unixSeconds(secondEnd),
),
HealthKitRawDataPoint(
dataType: 4,
startTime: LocalHealthDataConvert.unixSeconds(secondEnd),
endTime: LocalHealthDataConvert.unixSeconds(deepEnd),
),
];
final statistics = LocalHealthDataConvert.sleepStatistics(
dateRangeType: 3,
days: [day],
previousDays: const [],
sleepIntervals: intervals,
heartRate: const [],
);
final v2HealthData = LocalHealthDataConvert.v2HealthData(
day: day,
hrvPoints: const [],
activity: const [],
sleepingHeartRate: const [],
restingHeartRate: const [],
sleepIntervals: intervals,
);
expect(
statistics.sleepTrendList?.single.totalTime,
const Duration(hours: 2).inSeconds,
);
expect(
statistics.avgSleepDuration,
const Duration(hours: 2).inSeconds,
);
expect(v2HealthData.sleepDuration, const Duration(hours: 2).inSeconds);
});
});
group('HealthRawDailyStressCalculator', () {
... ...