Commit a02eddf8e92a4c52d9f4a04290deec1ebcfe9e30

Authored by 权海
1 parent 18d3aed7

feat(ui):优化计算耗时、首页刷新

... ... @@ -23,6 +23,9 @@ import '../health_sleep_calculator.dart';
class AppleHealthRawDataCoreService {
static const int defaultLookbackDays = 183;
static const int defaultReadChunkDays = 7;
// Re-read enough history to rebuild the previous completed sleep when
// HealthKit delivers its stages after a newer sleep result was stored.
static const int sleepReconciliationDays = 2;
static String _executionEngineType = 'main';
static void setExecutionEngineType(String value) {
... ... @@ -341,9 +344,11 @@ class AppleHealthRawDataCoreService {
);
final restingHeartRateStartTime = heartRateStartTime;
final sleepStartTime = math.max(
latestSleepResultTime == null
? requestedStartTime
: latestSleepResultTime - Duration.secondsPerDay,
forceStartTime ??
(latestSleepResultTime == null
? requestedStartTime
: latestSleepResultTime -
sleepReconciliationDays * Duration.secondsPerDay),
earliestStartTime,
);
... ... @@ -434,7 +439,6 @@ class AppleHealthRawDataCoreService {
final sleepResults = await _calculateAndStoreSleepResults(
userId: userId,
sleepIntervals: sleepIntervals,
latestSleepResultTime: latestSleepResultTime,
);
_scheduleResultUpload('upload sleep results', _uploadSleepResults);
final storedResult = newResult.copyWith(
... ... @@ -1814,14 +1818,13 @@ class AppleHealthRawDataCoreService {
Future<List<HealthRawSleepResult>> _calculateAndStoreSleepResults({
required int userId,
required List<HealthKitRawDataPoint> sleepIntervals,
required int? latestSleepResultTime,
}) async {
if (sleepIntervals.isEmpty) return const <HealthRawSleepResult>[];
final days = {
for (final interval in sleepIntervals) _localDay(interval.endTime),
}.toList()
..sort((a, b) => a.compareTo(b));
final results = <HealthRawSleepResult>[];
final candidates = <HealthRawSleepResult>[];
for (final day in days) {
final calculation = HealthSleepCalculator.calculateDay(
day: day,
... ... @@ -1832,11 +1835,7 @@ class AppleHealthRawDataCoreService {
final state = calculation.state;
if (merged == null || score == null || state == null) continue;
if (!calculation.hasValidSleep) continue;
if (latestSleepResultTime != null &&
merged.endTime <= latestSleepResultTime) {
continue;
}
results.add(
candidates.add(
HealthRawSleepResult(
userId: userId,
date: merged.endTime,
... ... @@ -1850,11 +1849,45 @@ class AppleHealthRawDataCoreService {
),
);
}
if (candidates.isEmpty) return const <HealthRawSleepResult>[];
final earliestDate =
candidates.map((result) => result.date).reduce(math.min);
final latestDate = candidates.map((result) => result.date).reduce(math.max);
final existingByDate = {
for (final result in await _localStore.querySleepResults(
userId: userId,
startTime: earliestDate,
endTime: latestDate,
))
result.date: result,
};
final changedResults = candidates
.where(
(candidate) => !_sameSleepResult(
existingByDate[candidate.date],
candidate,
),
)
.toList();
await _localStore.upsertSleepResults(
userId: userId,
results: results,
results: candidates,
);
return results;
return changedResults;
}
bool _sameSleepResult(
HealthRawSleepResult? existing,
HealthRawSleepResult candidate,
) {
return existing != null &&
existing.userId == candidate.userId &&
existing.startDate == candidate.startDate &&
existing.sleepScore == candidate.sleepScore &&
existing.sleepState == candidate.sleepState &&
existing.inBedMinutes == candidate.inBedMinutes &&
existing.awakMinutes == candidate.awakMinutes &&
existing.sleepMinutes == candidate.sleepMinutes;
}
List<HealthRawHrvStressPoint> _filterNewHrvStressPoints(
... ... @@ -2242,7 +2275,7 @@ class HealthRawStressLocalStore {
final db = await _database(userId);
final rows = await db.query(
hrvResultsTable,
where: 'uploaded IS NULL OR uploaded != 1',
where: 'uploaded = 0',
orderBy: 'raw_end_time ASC',
limit: limit,
);
... ... @@ -2271,7 +2304,7 @@ class HealthRawStressLocalStore {
final db = await _database(userId);
final rows = await db.query(
realtimeStressResultsTable,
where: 'uploaded IS NULL OR uploaded != 1',
where: 'uploaded = 0',
orderBy: 'raw_end_time ASC',
limit: limit,
);
... ... @@ -2307,7 +2340,7 @@ class HealthRawStressLocalStore {
final db = await _database(userId);
final rows = await db.query(
dailyStressResultsTable,
where: 'uploaded IS NULL OR uploaded != 1',
where: 'uploaded = 0',
orderBy: 'date ASC',
limit: limit,
);
... ... @@ -2336,7 +2369,7 @@ class HealthRawStressLocalStore {
final db = await _database(userId);
final rows = await db.query(
sleepResultsTable,
where: 'uploaded IS NULL OR uploaded != 1',
where: 'uploaded = 0',
orderBy: 'date ASC',
limit: limit,
);
... ... @@ -2561,7 +2594,7 @@ class HealthRawStressLocalStore {
final db = await factory.openDatabase(
path,
options: OpenDatabaseOptions(
version: 10,
version: 11,
onCreate: (db, version) async {
await _createTables(db);
},
... ... @@ -2594,6 +2627,9 @@ class HealthRawStressLocalStore {
if (oldVersion < 10) {
await _addPushSendTimeColumns(db);
}
if (oldVersion < 11) {
await _createIndexes(db);
}
},
),
);
... ... @@ -2647,6 +2683,7 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable (
''');
await _createDailyStressTable(db);
await _createSleepResultsTable(db);
await _createIndexes(db);
}
Future<void> _createDailyStressTable(DatabaseExecutor db) async {
... ... @@ -2686,6 +2723,39 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
''');
}
Future<void> _createIndexes(DatabaseExecutor db) async {
// Range reads already use each table's INTEGER PRIMARY KEY. These indexes
// cover the pending-upload and latest-notification queries instead.
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_hrv_results_pending '
'ON $hrvResultsTable(uploaded, raw_end_time)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_realtime_stress_results_pending '
'ON $realtimeStressResultsTable(uploaded, raw_end_time)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_daily_stress_results_pending '
'ON $dailyStressResultsTable(uploaded, date)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_sleep_results_pending '
'ON $sleepResultsTable(uploaded, date)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_hrv_results_push_send_time '
'ON $hrvResultsTable(push_send_time)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_realtime_stress_results_push_send_time '
'ON $realtimeStressResultsTable(push_send_time)',
);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_sleep_results_push_send_time '
'ON $sleepResultsTable(push_send_time)',
);
}
Future<void> _addUpdateTimeColumns(DatabaseExecutor db) async {
for (final table in const [
hrvResultsTable,
... ... @@ -2919,7 +2989,7 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
final rows = await db.query(
table,
columns: ['uploaded'],
where: 'uploaded != 1',
where: 'uploaded = 0',
limit: 1,
);
return rows.isNotEmpty;
... ... @@ -3217,6 +3287,8 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
'sleep_minutes',
],
);
// Recalculation/backfill must not rewrite unchanged rows or upload cursors.
if (!valueChanged && existingRow['date_key'] == row['date_key']) return;
await db.update(
sleepResultsTable,
<String, Object?>{
... ...
... ... @@ -67,13 +67,10 @@ class HuaweiHealthRawStressCalculator {
userId: userId,
hrvStressPoints: hrvStressPoints,
realtimeStressPoints: realtimeStressPoints,
dailyStressPoints: calculateDailyStressPoints(
userId: userId,
realtimePoints: realtimeStressPoints,
startTime: startTime,
endTime: endTime,
dataTime: endTime,
),
// OHOS calculates daily stress after realtime points are stored, so it
// can include existing points for every affected day. Calculating it
// here would duplicate CPU work and be incomplete for partial syncs.
dailyStressPoints: const <HealthRawDailyStressPoint>[],
);
}
... ...
... ... @@ -290,6 +290,23 @@ class OHOSHealthRawDataCoreService {
'${syncResults.fold<int>(0, (sum, result) => sum + result.storedCount)} '
'elapsedMs=${syncElapsed.inMilliseconds}',
);
if (willSyncRawData &&
syncSnapshot != null &&
!syncSnapshot.hasNewCalculationData) {
_logInfo(
'$_calculateLogMarker skip_no_new_base_data userId=$userId '
'checkedDataTypes=hr,hrv,sleep',
);
// Calculation and notifications require new base data. Keep the upload
// retry path active so a previous failed result upload is not stranded.
_scheduleResultUpload();
return HealthRawStressCalculationResult(
userId: userId,
hrvStressPoints: const <HealthRawHrvStressPoint>[],
realtimeStressPoints: const <HealthRawRealtimeStressPoint>[],
dailyStressPoints: const <HealthRawDailyStressPoint>[],
);
}
final calculationStartTime = DateTime.now();
_publishCalculationEvent(
... ... @@ -394,25 +411,26 @@ class OHOSHealthRawDataCoreService {
var hasExistingSleep = false;
try {
final contextStopwatch = Stopwatch()..start();
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 latestRawHrvDataTime = await _latestOhosRawDataTime(
HealthDataUploadType.hrv.type,
);
final latestRawHrDataTime = await _latestOhosRawDataTime(
HealthDataUploadType.heartRate.type,
);
final latestRawSleepDataTime = await _latestOhosRawDataTime(
OhosHealthRawDataType.sleepAnalysis,
);
// These reads have no dependencies. Start them together so separate
// SQLite databases/platform channels do not extend the critical path.
final context = await Future.wait<int?>([
_localStore.latestHrvSourceStartTime(userId),
_localStore.latestRealtimeSourceStartTime(userId),
_localStore.latestHrvRawEndTime(userId),
_localStore.latestRealtimeRawEndTime(userId),
_localStore.latestSleepResultTime(userId),
_latestOhosRawDataTime(HealthDataUploadType.hrv.type),
_latestOhosRawDataTime(HealthDataUploadType.heartRate.type),
_latestOhosRawDataTime(OhosHealthRawDataType.sleepAnalysis),
]);
final hrvContextStart = context[0];
final realtimeContextStart = context[1];
final latestHrvRawEndTime = context[2];
final latestRealtimeRawEndTime = context[3];
final latestSleepResultTime = context[4];
final latestRawHrvDataTime = context[5];
final latestRawHrDataTime = context[6];
final latestRawSleepDataTime = context[7];
_profileLog(
'calculate_contextQuery_finish userId=$userId '
'elapsedMs=${contextStopwatch.elapsedMilliseconds}',
... ... @@ -500,14 +518,48 @@ class OHOSHealthRawDataCoreService {
'realtimeRecomputeStartTime=$realtimeRecomputeStartTime',
);
// Raw types are independent. Create every future before awaiting one so
// their reads overlap; stress dependency is enforced only below.
final hrvFetchStopwatch = Stopwatch()..start();
final hrvPoints = await _fetchRawDataForCalculation(
final heartRateFetchStopwatch = Stopwatch()..start();
final restingHeartRateFetchStopwatch = Stopwatch()..start();
final sleepFetchStopwatch = Stopwatch()..start();
final workoutFetchStopwatch = Stopwatch()..start();
final hrvPointsFuture = _fetchRawDataForCalculation(
HealthDataUploadType.hrv.type,
hrvStartTime,
effectiveEndTime,
rawDataSnapshot: rawDataSnapshot,
readChunkDays: readChunkDays,
);
final heartRatePointsFuture = _fetchRawDataForCalculation(
HealthDataUploadType.heartRate.type,
heartRateStartTime,
effectiveEndTime,
rawDataSnapshot: rawDataSnapshot,
readChunkDays: readChunkDays,
);
final restingHeartRatePointsFuture = _fetchRawDataForCalculation(
HealthDataUploadType.restingHeartRate.type,
heartRateStartTime,
effectiveEndTime,
rawDataSnapshot: rawDataSnapshot,
readChunkDays: readChunkDays,
);
final sleepIntervalsFuture = _fetchSleepIntervalsForCalculation(
sleepStartTime,
effectiveEndTime,
rawDataSnapshot: rawDataSnapshot,
readChunkDays: readChunkDays,
);
final workoutIntervalsFuture = _fetchWorkoutIntervalsForCalculation(
heartRateStartTime,
effectiveEndTime,
rawDataSnapshot: rawDataSnapshot,
readChunkDays: readChunkDays,
);
final hrvPoints = await hrvPointsFuture;
_profileLog(
'calculate_fetchRaw_finish userId=$userId '
'name=hrv dataType=${HealthDataUploadType.hrv.type} '
... ... @@ -515,14 +567,7 @@ class OHOSHealthRawDataCoreService {
'count=${hrvPoints.length} '
'elapsedMs=${hrvFetchStopwatch.elapsedMilliseconds}',
);
final heartRateFetchStopwatch = Stopwatch()..start();
final heartRatePoints = await _fetchRawDataForCalculation(
HealthDataUploadType.heartRate.type,
heartRateStartTime,
effectiveEndTime,
rawDataSnapshot: rawDataSnapshot,
readChunkDays: readChunkDays,
);
final heartRatePoints = await heartRatePointsFuture;
_profileLog(
'calculate_fetchRaw_finish userId=$userId '
'name=heartRate dataType=${HealthDataUploadType.heartRate.type} '
... ... @@ -530,14 +575,7 @@ class OHOSHealthRawDataCoreService {
'count=${heartRatePoints.length} '
'elapsedMs=${heartRateFetchStopwatch.elapsedMilliseconds}',
);
final restingHeartRateFetchStopwatch = Stopwatch()..start();
final restingHeartRatePoints = await _fetchRawDataForCalculation(
HealthDataUploadType.restingHeartRate.type,
heartRateStartTime,
effectiveEndTime,
rawDataSnapshot: rawDataSnapshot,
readChunkDays: readChunkDays,
);
final restingHeartRatePoints = await restingHeartRatePointsFuture;
_profileLog(
'calculate_fetchRaw_finish userId=$userId '
'name=restingHeartRate '
... ... @@ -546,26 +584,14 @@ class OHOSHealthRawDataCoreService {
'count=${restingHeartRatePoints.length} '
'elapsedMs=${restingHeartRateFetchStopwatch.elapsedMilliseconds}',
);
final sleepFetchStopwatch = Stopwatch()..start();
final sleepIntervals = await _fetchSleepIntervalsForCalculation(
sleepStartTime,
effectiveEndTime,
rawDataSnapshot: rawDataSnapshot,
readChunkDays: readChunkDays,
);
final sleepIntervals = await sleepIntervalsFuture;
_profileLog(
'calculate_fetchRaw_finish userId=$userId name=sleep '
'startTime=$sleepStartTime endTime=$effectiveEndTime '
'count=${sleepIntervals.length} '
'elapsedMs=${sleepFetchStopwatch.elapsedMilliseconds}',
);
final workoutFetchStopwatch = Stopwatch()..start();
final workoutIntervals = await _fetchWorkoutIntervalsForCalculation(
heartRateStartTime,
effectiveEndTime,
rawDataSnapshot: rawDataSnapshot,
readChunkDays: readChunkDays,
);
final workoutIntervals = await workoutIntervalsFuture;
_profileLog(
'calculate_fetchRaw_finish userId=$userId name=workout '
'startTime=$heartRateStartTime endTime=$effectiveEndTime '
... ... @@ -589,6 +615,14 @@ class OHOSHealthRawDataCoreService {
'needSleep=${_needsNewResult(latestRawSleepTime, latestSleepResultTime)}',
);
// Sleep has no result dependency on either stress calculation. Run its
// CPU work alongside the HRV -> realtime-stress isolate; only storage is
// sequenced later to avoid concurrent writes to the result database.
final sleepCalculationStopwatch = Stopwatch()..start();
final sleepCalculationFuture = _calculateSleepResults(
userId: userId,
sleepIntervals: sleepIntervals,
);
final isolateStopwatch = Stopwatch()..start();
final result = await Isolate.run(
() => HuaweiHealthRawStressCalculator(userId: userId).calculate(
... ... @@ -671,15 +705,14 @@ class OHOSHealthRawDataCoreService {
'count=${dailyStressPoints.length} '
'elapsedMs=${dailyStopwatch.elapsedMilliseconds}',
);
final sleepStopwatch = Stopwatch()..start();
final sleepResults = await _calculateAndStoreSleepResults(
final sleepResults = await sleepCalculationFuture;
await _storeSleepResults(
userId: userId,
sleepIntervals: sleepIntervals,
latestSleepResultTime: latestSleepResultTime,
results: sleepResults,
);
_profileLog(
'calculate_sleep_finish userId=$userId count=${sleepResults.length} '
'elapsedMs=${sleepStopwatch.elapsedMilliseconds}',
'elapsedMs=${sleepCalculationStopwatch.elapsedMilliseconds}',
);
_logInfo(
'calculate_daily_sleep_stored userId=$userId '
... ... @@ -1440,31 +1473,59 @@ class OHOSHealthRawDataCoreService {
return dailyStressPoints;
}
Future<List<HealthRawSleepResult>> _calculateAndStoreSleepResults({
Future<List<HealthRawSleepResult>> _calculateSleepResults({
required int userId,
required List<HealthKitRawDataPoint> sleepIntervals,
required int? latestSleepResultTime,
}) async {
_logInfo(
'$_sleepCalcLogMarker start userId=$userId '
'sleepIntervals=${sleepIntervals.length} '
'latestSleepResultTime=$latestSleepResultTime',
'sleepIntervals=${sleepIntervals.length}',
);
if (sleepIntervals.isEmpty) {
_logInfo('$_sleepCalcLogMarker no_raw_sleep userId=$userId');
return const <HealthRawSleepResult>[];
}
final stopwatch = Stopwatch()..start();
final results = await Isolate.run(
() => _calculateSleepResultsSync(
userId: userId,
sleepIntervals: sleepIntervals,
),
debugName: 'OHOSHealthSleepCalculator',
);
_profileLog(
'sleep_isolate_finish userId=$userId '
'input=${sleepIntervals.length} result=${results.length} '
'elapsedMs=${stopwatch.elapsedMilliseconds}',
);
return results;
}
Future<void> _storeSleepResults({
required int userId,
required List<HealthRawSleepResult> results,
}) async {
final sleepStoreStopwatch = Stopwatch()..start();
await _localStore.upsertSleepResults(userId: userId, results: results);
_profileLog(
'sleep_store_finish userId=$userId stored=${results.length} '
'elapsedMs=${sleepStoreStopwatch.elapsedMilliseconds}',
);
_logInfo(
'$_sleepCalcLogMarker stored userId=$userId stored=${results.length}',
);
}
static List<HealthRawSleepResult> _calculateSleepResultsSync({
required int userId,
required List<HealthKitRawDataPoint> sleepIntervals,
}) {
final days = {
for (final interval in sleepIntervals) _localDay(interval.endTime),
}.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 dayStopwatch = Stopwatch()..start();
final calculation = HealthSleepCalculator.calculateDay(
day: day,
sleepIntervals: sleepIntervals,
... ... @@ -1473,49 +1534,13 @@ class OHOSHealthRawDataCoreService {
final score = calculation.score;
final state = calculation.state;
if (merged == null || score == null || state == null) {
_profileLog(
'sleep_day_finish userId=$userId '
'date=${_dateKeyFromDateTime(day)} hasResult=false '
'reason=missing_score_or_state '
'elapsedMs=${dayStopwatch.elapsedMilliseconds}',
);
_logInfo(
'$_sleepCalcLogMarker skip_invalid userId=$userId '
'date=${_dateKeyFromDateTime(day)} '
'reason=missing_score_or_state '
'score=$score state=${state?.value}',
);
continue;
}
if (!calculation.hasValidSleep) {
_profileLog(
'sleep_day_finish userId=$userId '
'date=${_dateKeyFromDateTime(day)} hasResult=false '
'reason=no_valid_sleep '
'elapsedMs=${dayStopwatch.elapsedMilliseconds}',
);
_logInfo(
'$_sleepCalcLogMarker skip_invalid userId=$userId '
'date=${_dateKeyFromDateTime(day)} reason=no_valid_sleep '
'durationSeconds=${calculation.durationSeconds}',
);
continue;
}
if (latestSleepResultTime != null &&
merged.endTime <= latestSleepResultTime) {
_profileLog(
'sleep_day_finish userId=$userId '
'date=${_dateKeyFromDateTime(day)} hasResult=false '
'reason=existing mergedEnd=${merged.endTime} '
'elapsedMs=${dayStopwatch.elapsedMilliseconds}',
);
_logInfo(
'$_sleepCalcLogMarker skip_existing userId=$userId '
'date=${_dateKeyFromDateTime(day)} mergedEnd=${merged.endTime} '
'latestSleepResultTime=$latestSleepResultTime',
);
continue;
}
// A newer result does not prove that older days were calculated.
// Reconcile every day in the input; the store preserves unchanged uploads.
results.add(
HealthRawSleepResult(
userId: userId,
... ... @@ -1529,28 +1554,7 @@ class OHOSHealthRawDataCoreService {
uploaded: false,
),
);
_profileLog(
'sleep_day_finish userId=$userId '
'date=${_dateKeyFromDateTime(day)} hasResult=true '
'sleepMinutes=${calculation.summary.sleepMinutes} '
'elapsedMs=${dayStopwatch.elapsedMilliseconds}',
);
_logInfo(
'$_sleepCalcLogMarker result userId=$userId '
'date=${_dateKeyFromDateTime(day)} start=${merged.startTime} '
'end=${merged.endTime} score=$score state=${state.value} '
'sleepMinutes=${calculation.summary.sleepMinutes}',
);
}
final sleepStoreStopwatch = Stopwatch()..start();
await _localStore.upsertSleepResults(userId: userId, results: results);
_profileLog(
'sleep_store_finish userId=$userId stored=${results.length} '
'elapsedMs=${sleepStoreStopwatch.elapsedMilliseconds}',
);
_logInfo(
'$_sleepCalcLogMarker stored userId=$userId stored=${results.length}',
);
return results;
}
... ... @@ -1626,15 +1630,30 @@ class OHOSHealthRawDataCoreService {
}) async {
final rawDataSource = _rawDataSource;
if (rawDataSnapshot != null && rawDataSource is OhosHealthRawDataSource) {
final points = rawDataSource.getRawDataFromSnapshot(
snapshot: rawDataSnapshot,
dataType: dataType,
startTime: startTime,
endTime: endTime,
)..sort((a, b) => a.endTime.compareTo(b.endTime));
// The snapshot is complete for this fetch, but it does not include the
// older local context required for baselines and smoothing. Overlay the
// fetched values on the indexed local range before calculating.
final localPoints = await rawDataSource.getRawData(
dataType,
startTime,
endTime,
);
final pointsByTime = <int, HealthKitRawDataPoint>{
for (final point in localPoints) point.endTime: point,
for (final point in rawDataSource.getRawDataFromSnapshot(
snapshot: rawDataSnapshot,
dataType: dataType,
startTime: startTime,
endTime: endTime,
))
point.endTime: point,
};
final points = pointsByTime.values.toList()
..sort((a, b) => a.endTime.compareTo(b.endTime));
_logInfo(
'$_calculateLogMarker raw_snapshot_hit dataType=$dataType '
'startTime=$startTime endTime=$endTime count=${points.length}',
'$_calculateLogMarker raw_local_snapshot_merged '
'dataType=$dataType startTime=$startTime endTime=$endTime '
'local=${localPoints.length} merged=${points.length}',
);
return points;
}
... ... @@ -1669,24 +1688,35 @@ class OHOSHealthRawDataCoreService {
required OhosHealthRawDataMemorySnapshot? rawDataSnapshot,
required int readChunkDays,
}) async {
// The snapshot contains only this sync's response, not all persisted sleep.
// Read the bounded local range once and overlay the snapshot so calculation
// is complete even while the snapshot is still being written in background.
final groups = await _rawDataSource.getRawSleepData(startTime, endTime);
final pointsByStart = <int, HealthKitRawDataPoint>{
for (final point in groups.expand((group) => group.sleepDataPoints))
point.startTime: point,
};
final rawDataSource = _rawDataSource;
if (rawDataSnapshot != null && rawDataSource is OhosHealthRawDataSource) {
final points = rawDataSource.getRawSleepIntervalsFromSnapshot(
for (final point in rawDataSource.getRawSleepIntervalsFromSnapshot(
snapshot: rawDataSnapshot,
startTime: startTime,
endTime: endTime,
)..sort((a, b) => a.endTime.compareTo(b.endTime));
_logInfo(
'$_calculateLogMarker sleep_snapshot_hit '
'startTime=$startTime endTime=$endTime count=${points.length}',
);
return points;
)) {
// OHOS raw sleep uses from_time as its unique key.
pointsByStart[point.startTime] = point;
}
}
return _fetchSleepIntervalsInChunks(
startTime,
endTime,
readChunkDays: readChunkDays,
final points = pointsByStart.values
.where(
(point) => point.endTime >= startTime && point.startTime <= endTime)
.toList()
..sort((a, b) => a.endTime.compareTo(b.endTime));
_logInfo(
'$_calculateLogMarker sleep_local_snapshot_merged '
'startTime=$startTime endTime=$endTime count=${points.length}',
);
return points;
}
Future<List<HealthKitRawWorkoutDataPoint>> _fetchWorkoutIntervalsInChunks(
... ... @@ -1715,14 +1745,25 @@ class OHOSHealthRawDataCoreService {
}) async {
final rawDataSource = _rawDataSource;
if (rawDataSnapshot != null && rawDataSource is OhosHealthRawDataSource) {
final points = rawDataSource.getRawWorkoutDataFromSnapshot(
snapshot: rawDataSnapshot,
startTime: startTime,
endTime: endTime,
)..sort((a, b) => a.endTime.compareTo(b.endTime));
final localPoints = await rawDataSource.getRawWorkoutData(
startTime,
endTime,
);
final pointsByStart = <int, HealthKitRawWorkoutDataPoint>{
for (final point in localPoints) point.startTime: point,
for (final point in rawDataSource.getRawWorkoutDataFromSnapshot(
snapshot: rawDataSnapshot,
startTime: startTime,
endTime: endTime,
))
point.startTime: point,
};
final points = pointsByStart.values.toList()
..sort((a, b) => a.endTime.compareTo(b.endTime));
_logInfo(
'$_calculateLogMarker workout_snapshot_hit '
'startTime=$startTime endTime=$endTime count=${points.length}',
'$_calculateLogMarker workout_local_snapshot_merged '
'startTime=$startTime endTime=$endTime '
'local=${localPoints.length} merged=${points.length}',
);
return points;
}
... ...
... ... @@ -89,12 +89,16 @@ class OhosHealthRawDataCalculationSyncSnapshot {
const OhosHealthRawDataCalculationSyncSnapshot({
required this.results,
required this.rawData,
required this.newCalculationDataTypes,
this.activityGoal,
this.storeFuture,
});
final List<OhosHealthRawDataSyncResult> results;
final OhosHealthRawDataMemorySnapshot rawData;
final Set<int> newCalculationDataTypes;
final V2ActivityTarget? activityGoal;
final Future<void>? storeFuture;
bool get hasNewCalculationData => newCalculationDataTypes.isNotEmpty;
}
... ...
... ... @@ -310,6 +310,7 @@ class OhosHealthRawDataSyncService {
final rawData = OhosHealthRawDataMemorySnapshot(
results: results,
);
final newCalculationDataTypes = await _newCalculationDataTypes(results);
final pageCount = results.fold<int>(
0,
(sum, result) => sum + result.pageCount,
... ... @@ -335,7 +336,8 @@ class OhosHealthRawDataSyncService {
'calculation_sync_snapshot_finish '
'dataTypes=${resolvedDataTypes.join(',')} '
'pageCount=$pageCount fetchedCount=$fetchedCount '
'snapshotCount=${rawData.length}',
'snapshotCount=${rawData.length} '
'newCalculationDataTypes=${newCalculationDataTypes.join(',')}',
);
_log(
'$timingLogMarker fetch_all_finish '
... ... @@ -356,6 +358,7 @@ class OhosHealthRawDataSyncService {
return OhosHealthRawDataCalculationSyncSnapshot(
results: results,
rawData: rawData,
newCalculationDataTypes: newCalculationDataTypes,
activityGoal: activityGoal,
storeFuture: storeFuture,
);
... ... @@ -385,6 +388,48 @@ class OhosHealthRawDataSyncService {
}
}
Future<Set<int>> _newCalculationDataTypes(
List<OhosHealthRawDataSyncResult> results,
) async {
final triggerDataTypes = <int>{
HealthDataUploadType.heartRate.type,
HealthDataUploadType.hrv.type,
OhosHealthRawDataType.sleepAnalysis,
};
final types = <int>{};
for (final result in results) {
final dataType = result.dataType;
if (!triggerDataTypes.contains(dataType) || result.rawItems.isEmpty) {
continue;
}
final incomingTimes = result.rawItems
.map((item) => _calculationTimestamp(dataType, item))
.toSet();
if (incomingTimes.isEmpty) continue;
final startTime = incomingTimes.reduce(math.min);
final endTime = incomingTimes.reduce(math.max);
final localItems = await _localStore.queryRawData(
dataType: dataType,
startTime: startTime,
endTime: endTime,
);
final existingTimes = localItems
.map((item) => _calculationTimestamp(dataType, item))
.toSet();
if (incomingTimes.any((time) => !existingTimes.contains(time))) {
types.add(dataType);
}
}
return Set<int>.unmodifiable(types);
}
int _calculationTimestamp(int dataType, OhosHealthRawDataItem item) {
if (dataType == OhosHealthRawDataType.sleepAnalysis) {
return _numPayload(item.payload, 'to_time') ?? item.dataTime;
}
return _numPayload(item.payload, 'time') ?? item.dataTime;
}
Future<V2ActivityTarget?> _fetchActivityGoalForCalculationSync() async {
final stopwatch = Stopwatch()..start();
try {
... ... @@ -705,9 +750,18 @@ class OhosHealthRawDataSyncService {
for (final result in results) {
if (result.rawItems.isEmpty) continue;
final typeStopwatch = Stopwatch()..start();
// Requests finish out of order. Persist each table chronologically after
// every type/segment has been fetched, using its SQLite unique time key.
final sortedItems = [...result.rawItems]..sort((a, b) {
final keyCompare = _dedupeKeyTime(dataType: result.dataType, item: a)
.compareTo(_dedupeKeyTime(dataType: result.dataType, item: b));
return keyCompare != 0
? keyCompare
: a.dataTime.compareTo(b.dataTime);
});
final count = await _localStore.upsertRawDataBatch(
dataType: result.dataType,
items: result.rawItems,
items: sortedItems,
);
storedCount += count;
_log(
... ...
import 'dart:async';
import 'package:doublefeel_flutter/core/services/raw_data_service/health_raw_data_source.dart';
import 'package:doublefeel_flutter/core/services/raw_data_service/health_raw_models.dart';
import 'package:doublefeel_flutter/core/services/raw_data_service/platform_ios/apple_health_raw_data_core_service.dart';
... ... @@ -10,6 +12,66 @@ import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
for (final hasSnapshotSleep in [false, true]) {
test(
'OHOS repairs September 6 sleep with newer result; '
'snapshot=$hasSnapshotSleep and raw write pending', () async {
int seconds(DateTime date) => date.millisecondsSinceEpoch ~/ 1000;
final start = seconds(DateTime(2026, 9, 5, 23));
final middle = seconds(DateTime(2026, 9, 6, 3));
final end = seconds(DateTime(2026, 9, 6, 7));
final gate = Completer<void>();
addTearDown(() {
if (!gate.isCompleted) gate.complete();
});
OhosHealthRawDataItem sleep(int from, int to) => OhosHealthRawDataItem(
dataType: 4,
dataTime: to,
payload: {'from_time': from, 'to_time': to},
);
final rawStore = _FakeOhosHealthRawDataLocalStore(
latestDataTime: end,
storeGate: gate.future,
itemsByDataType: {
OhosHealthRawDataType.sleepAnalysis: [
sleep(start, hasSnapshotSleep ? middle : end),
],
},
);
final store = _FakeHealthRawStressLocalStore()
..latestSleepTime = seconds(DateTime(2026, 9, 7, 7));
final service = OHOSHealthRawDataCoreService(
rawDataSource: OhosHealthRawDataSource(
syncService: OhosHealthRawDataSyncService(
remoteDataSource: _HistoricalBackfillRemoteDataSource(
hrvItems: const [],
heartRateItems: const [],
sleepItems: hasSnapshotSleep ? [sleep(middle, end)] : const [],
),
localStore: rawStore,
),
),
localStore: store,
userIdProvider: () => 42,
uploadResultsAfterCalculation: false,
healthReadAuthorizationChecker: () async => true,
);
final result = await service.startCoreCaculate(
endTime: seconds(DateTime(2026, 9, 7, 12)),
);
expect(result.sleepResults, hasLength(1));
expect(result.sleepResults.single.date, end);
expect(result.sleepResults.single.startDate, start);
expect(result.sleepResults.single.sleepMinutes, 8 * 60);
expect(store.sleepResults.single.date, end);
expect(rawStore.sleepQueryCount, 1);
expect(gate.isCompleted, isFalse);
expect(rawStore.storedBatches, isEmpty);
});
}
test('OHOS core calculates and stores all Huawei result tables', () async {
final day = DateTime.now().subtract(const Duration(days: 7));
final base =
... ... @@ -515,10 +577,12 @@ class _HistoricalBackfillRemoteDataSource
_HistoricalBackfillRemoteDataSource({
required this.hrvItems,
required this.heartRateItems,
this.sleepItems = const [],
});
final List<OhosHealthRawDataItem> hrvItems;
final List<OhosHealthRawDataItem> heartRateItems;
final List<OhosHealthRawDataItem> sleepItems;
final calls = <_RemoteRawCall>[];
@override
... ... @@ -534,6 +598,9 @@ class _HistoricalBackfillRemoteDataSource
endTime: endTime,
),
);
if (dataType == OhosHealthRawDataType.sleepAnalysis) {
return OhosHealthRawDataPage(items: sleepItems);
}
if (dataType == 1) return OhosHealthRawDataPage(items: hrvItems);
if (dataType == 2) return OhosHealthRawDataPage(items: heartRateItems);
return const OhosHealthRawDataPage(items: <OhosHealthRawDataItem>[]);
... ... @@ -559,6 +626,7 @@ class _RemoteRawCall {
class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore {
_FakeOhosHealthRawDataLocalStore({
this.storeGate,
int? latestDataTime,
Map<int, int>? latestDataTimeByType,
Map<int, List<OhosHealthRawDataItem>>? itemsByDataType,
... ... @@ -567,6 +635,8 @@ class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore {
_itemsByDataType =
itemsByDataType ?? const <int, List<OhosHealthRawDataItem>>{};
final Future<void>? storeGate;
int sleepQueryCount = 0;
final int? _latestDataTime;
final Map<int, int> _latestDataTimeByType;
final Map<int, List<OhosHealthRawDataItem>> _itemsByDataType;
... ... @@ -583,13 +653,15 @@ class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore {
required int startTime,
required int endTime,
}) async {
if (dataType == OhosHealthRawDataType.sleepAnalysis) sleepQueryCount++;
return [
...(_itemsByDataType[dataType] ?? const <OhosHealthRawDataItem>[]),
...storedBatches.expand((batch) => batch),
]
.where(
(item) =>
item.dataType == dataType &&
(dataType == OhosHealthRawDataType.sleepAnalysis ||
item.dataType == dataType) &&
item.dataTime >= startTime &&
item.dataTime <= endTime,
)
... ... @@ -609,6 +681,7 @@ class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore {
required int dataType,
required List<OhosHealthRawDataItem> items,
}) async {
if (storeGate != null) await storeGate;
storedBatches.add(items);
return items.length;
}
... ... @@ -683,6 +756,7 @@ class _FakeHealthRawStressLocalStore extends HealthRawStressLocalStore {
final realtimeStressPoints = <HealthRawRealtimeStressPoint>[];
final dailyStressPoints = <HealthRawDailyStressPoint>[];
final sleepResults = <HealthRawSleepResult>[];
int? latestSleepTime;
@override
Future<void> ensureReadable(int userId) async {}
... ... @@ -711,7 +785,7 @@ class _FakeHealthRawStressLocalStore extends HealthRawStressLocalStore {
@override
Future<int?> latestSleepResultTime(int userId) async {
return null;
return latestSleepTime;
}
@override
... ...
... ... @@ -11,6 +11,128 @@ import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('calculation snapshot waits for every segment and stores sorted batches',
() async {
final remote = _ControlledSnapshotRemote();
final local = _FakeOhosHealthRawDataLocalStore();
final service = OhosHealthRawDataSyncService(
remoteDataSource: remote,
localStore: local,
);
var snapshotReady = false;
final future = service.syncCalculationRawDataSnapshot(
startTime: _unixSeconds(DateTime(2026, 1, 30)),
endTime: _unixSeconds(DateTime(2026, 2, 22)),
dataTypes: [2, OhosHealthRawDataType.sleepAnalysis],
).then((snapshot) {
snapshotReady = true;
return snapshot;
});
await Future<void>.delayed(Duration.zero);
expect(remote.requests, hasLength(4));
// Newer ranges/types finish first; the oldest heart-rate range stays pending.
for (var i = remote.requests.length - 1; i > 0; i--) {
remote.complete(i);
await Future<void>.delayed(Duration.zero);
expect(snapshotReady, isFalse);
expect(local.storedBatches, isEmpty);
}
remote.complete(0);
final snapshot = await future;
await snapshot.storeFuture;
expect(snapshot.rawData.length, 8);
expect(
snapshot.newCalculationDataTypes,
containsAll(<int>[2, OhosHealthRawDataType.sleepAnalysis]),
);
expect(snapshot.results.fold<int>(0, (n, r) => n + r.pageCount), 4);
expect(local.storedBatches, hasLength(2));
expect(local.storedBatches.map((batch) => batch.length), [6, 2]);
for (final batch in local.storedBatches) {
final times = batch.map((item) => item.dataTime).toList();
expect(times, orderedEquals([...times]..sort()));
}
});
test('failed snapshot segment prevents partial batch storage', () async {
final remote = _ControlledSnapshotRemote();
final local = _FakeOhosHealthRawDataLocalStore();
final service = OhosHealthRawDataSyncService(
remoteDataSource: remote,
localStore: local,
);
final future = service.syncCalculationRawDataSnapshot(
startTime: _unixSeconds(DateTime(2026, 1, 30)),
endTime: _unixSeconds(DateTime(2026, 2, 22)),
dataTypes: [2],
);
final failure = expectLater(future, throwsStateError);
await Future<void>.delayed(Duration.zero);
expect(remote.requests, hasLength(3));
remote.complete(2);
remote.complete(1);
await Future<void>.delayed(Duration.zero);
expect(local.storedBatches, isEmpty);
remote.requests.first.response.completeError(StateError('oldest failed'));
await failure;
expect(local.storedBatches, isEmpty);
});
test('calculation snapshot ignores existing HR and sleep time keys',
() async {
const heartRateTime = 1769904001;
const sleepEndTime = 1769907600;
final remote = _FakeOhosHealthRawDataRemoteDataSource([
const OhosHealthRawDataPage(
items: [
OhosHealthRawDataItem(
dataType: 2,
dataTime: heartRateTime,
payload: {'time': heartRateTime, 'value': 70},
),
],
),
const OhosHealthRawDataPage(
items: [
OhosHealthRawDataItem(
dataType: OhosHealthRawDataType.sleepAnalysis,
dataTime: sleepEndTime,
payload: {'from_time': 1769886000, 'to_time': sleepEndTime},
),
],
),
]);
final local = _FakeOhosHealthRawDataLocalStore(
existingItems: const [
OhosHealthRawDataItem(
dataType: 2,
dataTime: heartRateTime,
payload: {'time': heartRateTime, 'value': 70},
),
OhosHealthRawDataItem(
dataType: OhosHealthRawDataType.sleepAnalysis,
dataTime: sleepEndTime,
payload: {'from_time': 1769886000, 'to_time': sleepEndTime},
),
],
);
final service = OhosHealthRawDataSyncService(
remoteDataSource: remote,
localStore: local,
);
final snapshot = await service.syncCalculationRawDataSnapshot(
startTime: 1769817600,
endTime: 1769907600,
dataTypes: const [2, OhosHealthRawDataType.sleepAnalysis],
);
expect(snapshot.newCalculationDataTypes, isEmpty);
expect(snapshot.hasNewCalculationData, isFalse);
});
test('syncRawData starts from latest local data time and stores fetched data',
() async {
final remote = _FakeOhosHealthRawDataRemoteDataSource([
... ... @@ -375,7 +497,8 @@ void main() {
expect(result.pageCount, 3);
});
test('syncRawData limits segment fetch concurrency to ten', () async {
test('syncRawData starts every segment within the two-month fetch range',
() async {
final start = _unixSeconds(DateTime(2026, 1));
final end = _unixSeconds(DateTime(2026, 4, 30));
final gate = Completer<void>();
... ... @@ -393,18 +516,18 @@ void main() {
startTime: start,
endTime: end,
);
for (var i = 0; i < 20 && remote.calls.length < 10; i += 1) {
for (var i = 0; i < 20 && remote.calls.length < 6; i += 1) {
await Future<void>.delayed(const Duration(milliseconds: 1));
}
expect(remote.calls, hasLength(10));
expect(remote.calls, hasLength(6));
gate.complete();
final result = await sync;
expect(result.segmentCount, 12);
expect(result.pageCount, 12);
expect(remote.calls, hasLength(12));
expect(result.segmentCount, 6);
expect(result.pageCount, 6);
expect(remote.calls, hasLength(6));
});
test('syncRawData joins duplicate in-flight syncs', () async {
... ... @@ -807,11 +930,14 @@ class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore {
_FakeOhosHealthRawDataLocalStore({
int? latestDataTime,
Map<int, int>? latestDataTimeByType,
List<OhosHealthRawDataItem> existingItems = const <OhosHealthRawDataItem>[],
}) : _latestDataTime = latestDataTime,
_latestDataTimeByType = latestDataTimeByType ?? const <int, int>{};
_latestDataTimeByType = latestDataTimeByType ?? const <int, int>{},
_existingItems = existingItems;
final int? _latestDataTime;
final Map<int, int> _latestDataTimeByType;
final List<OhosHealthRawDataItem> _existingItems;
final List<List<OhosHealthRawDataItem>> storedBatches =
<List<OhosHealthRawDataItem>>[];
V2ActivityTarget? activityGoal;
... ... @@ -836,8 +962,10 @@ class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore {
required int startTime,
required int endTime,
}) async {
return storedBatches
.expand((batch) => batch)
return <OhosHealthRawDataItem>[
..._existingItems,
...storedBatches.expand((batch) => batch),
]
.where(
(item) =>
item.dataType == dataType &&
... ... @@ -967,3 +1095,41 @@ int _dateKey(int seconds) {
final dateTime = DateTime.fromMillisecondsSinceEpoch(seconds * 1000);
return dateTime.year * 10000 + dateTime.month * 100 + dateTime.day;
}
class _ControlledSnapshotRemote implements OhosHealthRawDataRemoteDataSource {
final requests = <({
int dataType,
int start,
Completer<OhosHealthRawDataPage> response
})>[];
@override
Future<OhosHealthRawDataPage> fetchRawDataPage({
required int dataType,
required int startTime,
required int endTime,
}) {
final response = Completer<OhosHealthRawDataPage>();
requests.add((dataType: dataType, start: startTime, response: response));
return response.future;
}
void complete(int index) {
final request = requests[index];
request.response.complete(OhosHealthRawDataPage(items: [
for (final offset in [2, 1])
OhosHealthRawDataItem(
dataType: request.dataType,
dataTime: request.start + offset,
payload: {
'time': request.start + offset,
'from_time': request.start + offset,
'to_time': request.start + offset,
},
),
]));
}
@override
Future<V2ActivityTarget?> fetchActivityGoal() async => null;
}
... ...