Commit 0a340fd3b6341aa5e7f36d7a38b0f7a7e50d2502

Authored by 权海
1 parent c187bd36

feat(ui):修复长时间不回调,触发计算时推送多条通知

(cherry picked from commit 85d12a8b)

# Conflicts:
#	lib/core/services/raw_data_service/platform_ios/apple_health_raw_local_notification.dart
#	test/core/services/health_raw_data_core_service_test.dart
#	test/core/services/health_raw_local_notification_test.dart
... ... @@ -4,7 +4,7 @@ import 'dart:io';
import 'package:path_provider/path_provider.dart';
import '../../../../l10n/gen/app_localizations.dart';
import '../../../platform/pigeon_api_facade.dart';
import '../../../../pigeon/platform_api.g.dart';
import '../health_raw_models.dart';
const healthRawTodayLink = 'doublefeel://flutter/home?tab=today';
... ... @@ -35,6 +35,8 @@ enum HealthRawLocalNotificationRecordType {
realtimeStress,
}
typedef HealthRawSleepInterval = ({int startTime, int endTime});
class HealthRawLocalNotificationBuilder {
const HealthRawLocalNotificationBuilder(this.l10n);
... ... @@ -49,24 +51,50 @@ class HealthRawLocalNotificationBuilder {
required bool hasExistingSleep,
required List<HealthRawRealtimeStressPoint> realtimeWindow,
required HealthRawLocalNotificationRecord? record,
List<HealthRawSleepInterval> realtimeSleepIntervals = const [],
int? lastRealtimeStressPushSendTime,
int? notificationBuildTime,
}) {
return [
final buildTime =
notificationBuildTime ?? DateTime.now().millisecondsSinceEpoch ~/ 1000;
final latestRealtimeStressPoint = _latestRealtimeStressPoint(
result.realtimeStressPoints,
);
final resolvedRealtimeSleepIntervals =
realtimeSleepIntervals.isEmpty && latestRealtimeStressPoint != null
? [_defaultSleepInterval(latestRealtimeStressPoint.rawEndTime)]
: realtimeSleepIntervals;
return _latestNotificationsByRecordType([
if (_sleepNotification(result.sleepResults, hasExistingSleep)
case final notification?)
notification,
if (_hrvNotification(result.hrvStressPoints, hasExistingHrv)
case final notification?)
notification,
..._hrvNotifications(result.hrvStressPoints, hasExistingHrv),
if (_realtimeStressNotification(
realtimeWindow,
record,
latestRealtimeStressPoint: _latestRealtimeStressPoint(
result.realtimeStressPoints,
),
latestRealtimeStressPoint: latestRealtimeStressPoint,
sleepIntervals: resolvedRealtimeSleepIntervals,
lastPushSendTime: lastRealtimeStressPushSendTime,
notificationBuildTime: buildTime,
)
case final notification?)
notification,
];
]);
}
List<HealthRawLocalNotification> _latestNotificationsByRecordType(
List<HealthRawLocalNotification> notifications,
) {
if (notifications.length < 2) return notifications;
final latestByType =
<HealthRawLocalNotificationRecordType, HealthRawLocalNotification>{};
for (final notification in notifications) {
final existing = latestByType[notification.recordType];
if (existing == null || notification.recordTime > existing.recordTime) {
latestByType[notification.recordType] = notification;
}
}
return latestByType.values.toList(growable: false);
}
HealthRawLocalNotification? _sleepNotification(
... ... @@ -89,33 +117,37 @@ class HealthRawLocalNotificationBuilder {
);
}
HealthRawLocalNotification? _hrvNotification(
List<HealthRawLocalNotification> _hrvNotifications(
List<HealthRawHrvStressPoint> hrvPoints,
bool hasExistingHrv,
) {
if (!hasExistingHrv) return null;
if (hrvPoints.isEmpty) return null;
if (!hasExistingHrv) return const <HealthRawLocalNotification>[];
if (hrvPoints.isEmpty) return const <HealthRawLocalNotification>[];
final sorted = [...hrvPoints]
..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime));
final latest = sorted.last;
return HealthRawLocalNotification(
title: l10n.healthLocalNotificationHrvTitle(
latest.result.floor(),
_stressStateLabel(latest.state),
_timeText(latest.rawEndTime),
),
content: _hrvContent(latest),
link: healthRawHrvChangeLink,
recordType: HealthRawLocalNotificationRecordType.hrv,
recordTime: latest.rawEndTime,
);
return [
for (final point in sorted)
HealthRawLocalNotification(
title: l10n.healthLocalNotificationHrvTitle(
point.result.floor(),
_stressStateLabel(point.state),
_timeText(point.rawEndTime),
),
content: _hrvContent(point),
link: healthRawHrvChangeLink,
recordType: HealthRawLocalNotificationRecordType.hrv,
recordTime: point.rawEndTime,
),
];
}
HealthRawLocalNotification? _realtimeStressNotification(
List<HealthRawRealtimeStressPoint> realtimeWindow,
HealthRawLocalNotificationRecord? record, {
required HealthRawRealtimeStressPoint? latestRealtimeStressPoint,
required List<HealthRawSleepInterval> sleepIntervals,
required int? lastPushSendTime,
required int notificationBuildTime,
}) {
if (latestRealtimeStressPoint == null) return null;
if (latestRealtimeStressPoint.isWorkout ||
... ... @@ -123,16 +155,20 @@ class HealthRawLocalNotificationBuilder {
return null;
}
final latest = latestRealtimeStressPoint;
if (_sleepIntervalContaining(latest.rawEndTime, sleepIntervals) != null) {
return null;
}
final valid = realtimeWindow
.where((e) => e.result >= 1 && e.result <= 100)
.toList()
..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime));
if (valid.length < _realtimeMinPointCount) return null;
final latest = latestRealtimeStressPoint;
if (latest.isSleepLikely) return null;
if (record?.lastRealtimeStressTime case final lastPushTime?) {
if (latest.rawEndTime - lastPushTime < _realtimeWindowSeconds) {
if (lastPushSendTime case final lastPushTime?) {
if (notificationBuildTime - lastPushTime < _realtimeWindowSeconds) {
return null;
}
}
... ... @@ -162,6 +198,29 @@ class HealthRawLocalNotificationBuilder {
return points.reduce((a, b) => a.rawEndTime >= b.rawEndTime ? a : b);
}
HealthRawSleepInterval? _sleepIntervalContaining(
int time,
List<HealthRawSleepInterval> intervals,
) {
for (final interval in intervals) {
if (time >= interval.startTime && time <= interval.endTime) {
return interval;
}
}
return null;
}
HealthRawSleepInterval _defaultSleepInterval(int time) {
final day = _localDayStart(time);
return (startTime: day, endTime: day + 6 * 60 * 60);
}
int _localDayStart(int time) {
final date = DateTime.fromMillisecondsSinceEpoch(time * 1000);
return DateTime(date.year, date.month, date.day).millisecondsSinceEpoch ~/
1000;
}
String _sleepDurationText(int minutes) {
final hours = minutes ~/ 60;
final remainingMinutes = minutes % 60;
... ... @@ -227,12 +286,12 @@ class HealthRawLocalNotificationBuilder {
class HealthRawLocalNotificationDispatcher {
HealthRawLocalNotificationDispatcher({
AppPlatformHostApi? platformApi,
PlatformHostApi? platformApi,
HealthRawLocalNotificationRecordStore? recordStore,
}) : _platformApi = platformApi ?? AppPlatformHostApi(),
}) : _platformApi = platformApi ?? PlatformHostApi(),
_recordStore = recordStore ?? HealthRawLocalNotificationRecordStore();
final AppPlatformHostApi _platformApi;
final PlatformHostApi _platformApi;
final HealthRawLocalNotificationRecordStore _recordStore;
Future<List<HealthRawLocalNotification>> sendAll({
... ... @@ -289,6 +348,28 @@ class HealthRawLocalNotificationDispatcher {
record.copyWith(lastRealtimeStressTime: recordTime),
);
}
Future<void> updateProcessedDataTimes({
required int userId,
int? latestHrvDataTime,
int? latestRealtimeStressDataTime,
int? latestSleepDataTime,
}) async {
if (latestHrvDataTime == null &&
latestRealtimeStressDataTime == null &&
latestSleepDataTime == null) {
return;
}
final record = await _recordStore.read(userId);
await _recordStore.write(
userId,
record.copyWith(
latestHrvDataTime: latestHrvDataTime,
latestRealtimeStressDataTime: latestRealtimeStressDataTime,
latestSleepDataTime: latestSleepDataTime,
),
);
}
}
class HealthRawLocalNotificationRecord {
... ... @@ -296,11 +377,17 @@ class HealthRawLocalNotificationRecord {
this.lastSleepTime,
this.lastHrvTime,
this.lastRealtimeStressTime,
this.latestHrvDataTime,
this.latestRealtimeStressDataTime,
this.latestSleepDataTime,
});
final int? lastSleepTime;
final int? lastHrvTime;
final int? lastRealtimeStressTime;
final int? latestHrvDataTime;
final int? latestRealtimeStressDataTime;
final int? latestSleepDataTime;
factory HealthRawLocalNotificationRecord.fromJson(Map<String, Object?> json) {
return HealthRawLocalNotificationRecord(
... ... @@ -308,6 +395,10 @@ class HealthRawLocalNotificationRecord {
lastHrvTime: (json['last_hrv_time'] as num?)?.toInt(),
lastRealtimeStressTime:
(json['last_realtime_stress_time'] as num?)?.toInt(),
latestHrvDataTime: (json['latest_hrv_data_time'] as num?)?.toInt(),
latestRealtimeStressDataTime:
(json['latest_realtime_stress_data_time'] as num?)?.toInt(),
latestSleepDataTime: (json['latest_sleep_data_time'] as num?)?.toInt(),
);
}
... ... @@ -317,6 +408,11 @@ class HealthRawLocalNotificationRecord {
if (lastHrvTime != null) 'last_hrv_time': lastHrvTime,
if (lastRealtimeStressTime != null)
'last_realtime_stress_time': lastRealtimeStressTime,
if (latestHrvDataTime != null) 'latest_hrv_data_time': latestHrvDataTime,
if (latestRealtimeStressDataTime != null)
'latest_realtime_stress_data_time': latestRealtimeStressDataTime,
if (latestSleepDataTime != null)
'latest_sleep_data_time': latestSleepDataTime,
};
}
... ... @@ -340,12 +436,19 @@ class HealthRawLocalNotificationRecord {
int? lastSleepTime,
int? lastHrvTime,
int? lastRealtimeStressTime,
int? latestHrvDataTime,
int? latestRealtimeStressDataTime,
int? latestSleepDataTime,
}) {
return HealthRawLocalNotificationRecord(
lastSleepTime: lastSleepTime ?? this.lastSleepTime,
lastHrvTime: lastHrvTime ?? this.lastHrvTime,
lastRealtimeStressTime:
lastRealtimeStressTime ?? this.lastRealtimeStressTime,
latestHrvDataTime: latestHrvDataTime ?? this.latestHrvDataTime,
latestRealtimeStressDataTime:
latestRealtimeStressDataTime ?? this.latestRealtimeStressDataTime,
latestSleepDataTime: latestSleepDataTime ?? this.latestSleepDataTime,
);
}
}
... ...
... ... @@ -439,6 +439,10 @@ void main() {
expect(firstRows.sleepRows.single['update_time'], greaterThan(0));
await store.markHrvStressUploaded(rawEndTimes: [base + 10], userId: 42);
final uploadedRows = await store.debugQueryAllRows(42);
final hrvUploadTime = uploadedRows.hrvRows.single['upload_time'] as int;
expect(hrvUploadTime, greaterThan(0));
await store.upsertResult(
HealthRawStressCalculationResult(
userId: 42,
... ... @@ -454,6 +458,7 @@ void main() {
greaterThan(firstHrvUpdateTime),
);
expect(secondRows.hrvRows.single['uploaded'], 1);
expect(secondRows.hrvRows.single['upload_time'], hrvUploadTime);
});
test(
... ... @@ -567,13 +572,14 @@ void main() {
);
final eventFuture = service.healthDataUpdatedStream.first;
await service.onHealthDataUpdated(
final hasNewResult = await service.onHealthDataUpdated(
dataTypes: [
HealthDataUploadType.hrv.type,
HealthDataUploadType.heartRate.type,
],
);
expect(hasNewResult, isTrue);
final event = await eventFuture;
expect(
event.dataTypes,
... ... @@ -581,6 +587,24 @@ void main() {
);
});
test('onHealthDataUpdated returns false after an empty calculation',
() async {
final api = _FakeHealthKitRawDataHostApi();
final service = AppleHealthRawDataCoreService(
healthApi: _FakeHealthKitHostApi(status: 0),
rawDataApi: api,
localStore: _MemoryHealthRawStressLocalStore(),
userIdProvider: () => 42,
uploadResultsAfterCalculation: false,
);
final hasNewResult = await service.onHealthDataUpdated(
dataTypes: [HealthDataUploadType.hrv.type],
);
expect(hasNewResult, isFalse);
});
test('startCoreCaculate calculates and stores sleep results', () async {
final now = DateTime.now();
final day = DateTime(now.year, now.month, now.day);
... ... @@ -670,7 +694,7 @@ void main() {
});
test(
'startCoreCaculate sends hrv and sleep notifications for new non-first results',
'startCoreCaculate sends only latest hrv notification for new non-first results',
() async {
final now = DateTime.now();
final day = DateTime(now.year, now.month, now.day);
... ... @@ -727,7 +751,11 @@ void main() {
),
],
);
final notificationDispatcher = _FakeHealthRawLocalNotificationDispatcher();
final notificationDispatcher = _FakeHealthRawLocalNotificationDispatcher()
..record = HealthRawLocalNotificationRecord(
latestHrvDataTime: base + 10,
latestSleepDataTime: base - Duration.secondsPerDay,
);
final service = AppleHealthRawDataCoreService(
healthApi: _FakeHealthKitHostApi(),
rawDataApi: api,
... ... @@ -742,17 +770,44 @@ void main() {
readChunkDays: 1,
);
expect(
notificationDispatcher.sentNotifications.where(
(e) => e.recordType == HealthRawLocalNotificationRecordType.hrv),
hasLength(1),
);
final hrvNotifications = notificationDispatcher.sentNotifications
.where((e) => e.recordType == HealthRawLocalNotificationRecordType.hrv)
.toList();
expect(hrvNotifications, hasLength(1));
expect(hrvNotifications.single.recordTime, base + 720);
expect(
notificationDispatcher.sentNotifications.where(
(e) => e.recordType == HealthRawLocalNotificationRecordType.sleep,
),
hasLength(1),
);
expect(notificationDispatcher.record.latestHrvDataTime, base + 720);
expect(notificationDispatcher.record.latestSleepDataTime,
LocalHealthDataConvert.unixSeconds(sleepEnd));
final rows = await store.debugQueryAllRows(42);
expect(
rows.hrvRows.singleWhere(
(row) => row['raw_end_time'] == base + 10)['push_send_time'],
isNull,
);
expect(
rows.hrvRows
.where(
(row) => [base + 120, base + 420].contains(row['raw_end_time']))
.every((row) => row['push_send_time'] == null),
isTrue,
);
expect(
rows.hrvRows.singleWhere(
(row) => row['raw_end_time'] == base + 720)['push_send_time'],
isNotNull,
);
expect(
rows.sleepRows.singleWhere(
(row) => row['date'] == LocalHealthDataConvert.unixSeconds(sleepEnd),
)['push_send_time'],
isNotNull,
);
});
test(
... ... @@ -784,7 +839,11 @@ void main() {
),
],
);
final notificationDispatcher = _FakeHealthRawLocalNotificationDispatcher();
final notificationDispatcher = _FakeHealthRawLocalNotificationDispatcher()
..record = const HealthRawLocalNotificationRecord(
latestHrvDataTime: base + 100,
latestSleepDataTime: base + 100,
);
final service = AppleHealthRawDataCoreService(
healthApi: _FakeHealthKitHostApi(),
rawDataApi: api,
... ... @@ -807,6 +866,177 @@ void main() {
),
isEmpty,
);
expect(notificationDispatcher.record.latestHrvDataTime, base + 100);
expect(notificationDispatcher.record.latestSleepDataTime, base + 100);
});
test(
'startCoreCaculate sends realtime notification when database latest is newer than latest data time',
() async {
const base = 1800000000;
final api = _FakeHealthKitRawDataHostApi();
final store = _MemoryHealthRawStressLocalStore();
await store.upsertResult(
HealthRawStressCalculationResult(
userId: 42,
hrvStressPoints: const <HealthRawHrvStressPoint>[],
realtimeStressPoints: [
for (var i = 0; i < 10; i += 1)
_realtimeStressPoint(base + i * 300, result: 70),
],
dailyStressPoints: const <HealthRawDailyStressPoint>[],
),
);
final notificationDispatcher = _FakeHealthRawLocalNotificationDispatcher()
..record = const HealthRawLocalNotificationRecord(
latestRealtimeStressDataTime: base,
);
final service = AppleHealthRawDataCoreService(
healthApi: _FakeHealthKitHostApi(),
rawDataApi: api,
localStore: store,
userIdProvider: () => 42,
uploadResultsAfterCalculation: false,
localNotificationDispatcher: notificationDispatcher,
);
await service.startCoreCaculate(
endTime: base + 3000,
readChunkDays: 1,
);
expect(
notificationDispatcher.sentNotifications.where(
(e) =>
e.recordType == HealthRawLocalNotificationRecordType.realtimeStress,
),
hasLength(1),
);
expect(
notificationDispatcher.record.latestRealtimeStressDataTime,
base + 2700,
);
final rows = await store.debugQueryAllRows(42);
expect(rows.realtimeRows.last['push_send_time'], isNotNull);
});
test('startCoreCaculate suppresses realtime notification inside sleep result',
() async {
final now = DateTime.now();
final day = DateTime(now.year, now.month, now.day).subtract(
const Duration(days: 7),
);
final dayStart = LocalHealthDataConvert.unixSeconds(day);
final latestTime = dayStart + 5 * 3600 + 30 * 60;
final firstTime = latestTime - 9 * 300;
final api = _FakeHealthKitRawDataHostApi();
final store = _MemoryHealthRawStressLocalStore();
await store.upsertResult(
HealthRawStressCalculationResult(
userId: 42,
hrvStressPoints: const <HealthRawHrvStressPoint>[],
realtimeStressPoints: [
for (var i = 0; i < 10; i += 1)
_realtimeStressPoint(firstTime + i * 300, result: 70),
],
dailyStressPoints: const <HealthRawDailyStressPoint>[],
),
);
await store.upsertSleepResults(
userId: 42,
results: [
HealthRawSleepResult(
userId: 42,
date: dayStart + 6 * 3600,
startDate: dayStart + 5 * 3600,
sleepScore: 80,
sleepState: 2,
inBedMinutes: 60,
awakMinutes: 0,
sleepMinutes: 60,
),
],
);
final notificationDispatcher = _FakeHealthRawLocalNotificationDispatcher()
..record = HealthRawLocalNotificationRecord(
latestRealtimeStressDataTime: firstTime - 1,
latestSleepDataTime: dayStart + 6 * 3600,
);
final service = AppleHealthRawDataCoreService(
healthApi: _FakeHealthKitHostApi(),
rawDataApi: api,
localStore: store,
userIdProvider: () => 42,
uploadResultsAfterCalculation: false,
localNotificationDispatcher: notificationDispatcher,
);
await service.startCoreCaculate(
endTime: latestTime,
readChunkDays: 1,
);
expect(
notificationDispatcher.sentNotifications.where(
(e) =>
e.recordType == HealthRawLocalNotificationRecordType.realtimeStress,
),
isEmpty,
);
final rows = await store.debugQueryAllRows(42);
expect(rows.realtimeRows.last['push_send_time'], isNull);
});
test(
'startCoreCaculate uses default sleep interval for realtime notification',
() async {
final now = DateTime.now();
final day = DateTime(now.year, now.month, now.day).subtract(
const Duration(days: 7),
);
final dayStart = LocalHealthDataConvert.unixSeconds(day);
final latestTime = dayStart + 5 * 3600 + 30 * 60;
final firstTime = latestTime - 9 * 300;
final api = _FakeHealthKitRawDataHostApi();
final store = _MemoryHealthRawStressLocalStore();
await store.upsertResult(
HealthRawStressCalculationResult(
userId: 42,
hrvStressPoints: const <HealthRawHrvStressPoint>[],
realtimeStressPoints: [
for (var i = 0; i < 10; i += 1)
_realtimeStressPoint(firstTime + i * 300, result: 70),
],
dailyStressPoints: const <HealthRawDailyStressPoint>[],
),
);
final notificationDispatcher = _FakeHealthRawLocalNotificationDispatcher()
..record = HealthRawLocalNotificationRecord(
latestRealtimeStressDataTime: firstTime - 1,
);
final service = AppleHealthRawDataCoreService(
healthApi: _FakeHealthKitHostApi(),
rawDataApi: api,
localStore: store,
userIdProvider: () => 42,
uploadResultsAfterCalculation: false,
localNotificationDispatcher: notificationDispatcher,
);
await service.startCoreCaculate(
endTime: latestTime,
readChunkDays: 1,
);
expect(
notificationDispatcher.sentNotifications.where(
(e) =>
e.recordType == HealthRawLocalNotificationRecordType.realtimeStress,
),
isEmpty,
);
final rows = await store.debugQueryAllRows(42);
expect(rows.realtimeRows.last['push_send_time'], isNull);
});
test(
... ... @@ -862,6 +1092,9 @@ void main() {
),
isEmpty,
);
expect(notificationDispatcher.record.latestHrvDataTime, base + 420);
expect(notificationDispatcher.record.latestSleepDataTime,
LocalHealthDataConvert.unixSeconds(sleepEnd));
});
test(
... ... @@ -934,6 +1167,10 @@ void main() {
expect(rows.realtimeRows.single['uploaded'], 1);
expect(rows.dailyStressRows.single['uploaded'], 1);
expect(rows.sleepRows.single['uploaded'], 1);
expect(rows.hrvRows.single['upload_time'], isNotNull);
expect(rows.realtimeRows.single['upload_time'], isNotNull);
expect(rows.dailyStressRows.single['upload_time'], isNotNull);
expect(rows.sleepRows.single['upload_time'], isNotNull);
});
test('startCoreCaculate does not wait for pending uploads', () async {
... ... @@ -972,6 +1209,8 @@ void main() {
var rows = await store.debugQueryAllRows(42);
expect(rows.hrvRows.single['uploaded'], 0);
expect(rows.realtimeRows.single['uploaded'], 0);
expect(rows.hrvRows.single['upload_time'], isNull);
expect(rows.realtimeRows.single['upload_time'], isNull);
api.hrvUploadCompleter!.complete(base + 10);
api.hrUploadCompleter!.complete(base + 20);
... ... @@ -980,6 +1219,8 @@ void main() {
expect(rows.hrvRows.single['uploaded'], 1);
expect(rows.realtimeRows.single['uploaded'], 1);
expect(rows.hrvRows.single['upload_time'], isNotNull);
expect(rows.realtimeRows.single['upload_time'], isNotNull);
});
test('local data source returns earliest local hr start time', () async {
... ... @@ -1033,12 +1274,15 @@ HealthKitRawDataPoint _point(
);
}
HealthRawRealtimeStressPoint _realtimeStressPoint(int time) {
HealthRawRealtimeStressPoint _realtimeStressPoint(
int time, {
double result = 30,
}) {
return HealthRawRealtimeStressPoint(
userId: 42,
rawEndTime: time,
rawHr: 70,
result: 30,
result: result,
sourceStartTime: time,
sourceEndTime: time,
);
... ... @@ -1232,6 +1476,20 @@ class _FakeHealthRawLocalNotificationDispatcher
}) async {
record = record.copyWith(lastRealtimeStressTime: recordTime);
}
@override
Future<void> updateProcessedDataTimes({
required int userId,
int? latestHrvDataTime,
int? latestRealtimeStressDataTime,
int? latestSleepDataTime,
}) async {
record = record.copyWith(
latestHrvDataTime: latestHrvDataTime,
latestRealtimeStressDataTime: latestRealtimeStressDataTime,
latestSleepDataTime: latestSleepDataTime,
);
}
}
class _ReadCall {
... ... @@ -1254,8 +1512,16 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
final Map<int, Map<int, int>> _realtimeUpdateTimes = {};
final Map<int, Map<int, int>> _dailyUpdateTimes = {};
final Map<int, Map<int, int>> _sleepUpdateTimes = {};
final Map<int, Map<int, int>> _hrvPushSendTimes = {};
final Map<int, Map<int, int>> _realtimePushSendTimes = {};
final Map<int, Map<int, int>> _sleepPushSendTimes = {};
final Map<int, Map<int, int>> _hrvUploadTimes = {};
final Map<int, Map<int, int>> _realtimeUploadTimes = {};
final Map<int, Map<int, int>> _dailyUploadTimes = {};
final Map<int, Map<int, int>> _sleepUploadTimes = {};
final List<String> operationLog;
var _updateTimeClock = 1;
var _uploadTimeClock = 1000;
var realtimeNotificationWindowQueryCount = 0;
void insertDailyStress(HealthRawDailyStressPoint point) {
... ... @@ -1282,6 +1548,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
@override
Future<void> upsertResult(HealthRawStressCalculationResult result) async {
operationLog.add('upsertResult');
final updateTime = _nextUpdateTime();
final hrvByTime = <int, HealthRawHrvStressPoint>{
for (final point in _hrv[result.userId] ?? <HealthRawHrvStressPoint>[])
point.rawEndTime: point,
... ... @@ -1303,10 +1570,14 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
baselineRestingHr: point.baselineRestingHr,
flags: point.flags,
uploaded: same ? existing.uploaded : false,
pushSendTime: existing?.pushSendTime,
uploadTime: same ? existing.uploadTime : null,
);
_hrvUpdateTimes.putIfAbsent(
point.userId, () => <int, int>{})[point.rawEndTime] =
_nextUpdateTime();
point.userId, () => <int, int>{})[point.rawEndTime] = updateTime;
if (!same) {
_hrvUploadTimes[point.userId]?.remove(point.rawEndTime);
}
}
_hrv[result.userId] = hrvByTime.values.toList()
..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime));
... ... @@ -1329,10 +1600,14 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
sourceEndTime: point.sourceEndTime,
flags: point.flags,
uploaded: same ? existing.uploaded : false,
pushSendTime: existing?.pushSendTime,
uploadTime: same ? existing.uploadTime : null,
);
_realtimeUpdateTimes.putIfAbsent(
point.userId, () => <int, int>{})[point.rawEndTime] =
_nextUpdateTime();
point.userId, () => <int, int>{})[point.rawEndTime] = updateTime;
if (!same) {
_realtimeUploadTimes[point.userId]?.remove(point.rawEndTime);
}
}
_realtime[result.userId] = realtimeByTime.values.toList()
..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime));
... ... @@ -1344,6 +1619,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
required Iterable<HealthRawDailyStressPoint> points,
}) async {
operationLog.add('upsertDailyStressPoints');
final updateTime = _nextUpdateTime();
final byDate = <int, HealthRawDailyStressPoint>{
for (final point in _daily[userId] ?? <HealthRawDailyStressPoint>[])
point.date: point,
... ... @@ -1364,9 +1640,13 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
state: point.state,
dataTime: point.dataTime,
uploaded: isToday ? false : (same ? existing.uploaded : false),
uploadTime: isToday || !same ? null : existing.uploadTime,
);
_dailyUpdateTimes.putIfAbsent(
point.userId, () => <int, int>{})[point.date] = _nextUpdateTime();
point.userId, () => <int, int>{})[point.date] = updateTime;
if (isToday || !same) {
_dailyUploadTimes[point.userId]?.remove(point.date);
}
}
_daily[userId] = byDate.values.toList()
..sort((a, b) => a.date.compareTo(b.date));
... ... @@ -1377,14 +1657,39 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
required int userId,
required Iterable<HealthRawSleepResult> results,
}) async {
final updateTime = _nextUpdateTime();
final byDate = <int, HealthRawSleepResult>{
for (final result in _sleep[userId] ?? <HealthRawSleepResult>[])
result.date: result,
};
for (final result in results) {
byDate[result.date] = result;
final existing = byDate[result.date];
final same = existing != null &&
existing.userId == result.userId &&
existing.startDate == result.startDate &&
existing.sleepScore == result.sleepScore &&
existing.sleepState == result.sleepState &&
existing.inBedMinutes == result.inBedMinutes &&
existing.awakMinutes == result.awakMinutes &&
existing.sleepMinutes == result.sleepMinutes;
byDate[result.date] = HealthRawSleepResult(
userId: result.userId,
date: result.date,
startDate: result.startDate,
sleepScore: result.sleepScore,
sleepState: result.sleepState,
inBedMinutes: result.inBedMinutes,
awakMinutes: result.awakMinutes,
sleepMinutes: result.sleepMinutes,
uploaded: same ? existing.uploaded : result.uploaded,
pushSendTime: existing?.pushSendTime,
uploadTime: same ? existing.uploadTime : result.uploadTime,
);
_sleepUpdateTimes.putIfAbsent(
result.userId, () => <int, int>{})[result.date] = _nextUpdateTime();
result.userId, () => <int, int>{})[result.date] = updateTime;
if (!same) {
_sleepUploadTimes[result.userId]?.remove(result.date);
}
}
_sleep[userId] = byDate.values.toList()
..sort((a, b) => a.date.compareTo(b.date));
... ... @@ -1514,6 +1819,88 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
}
@override
Future<int?> latestRealtimeStressPushSendTime(int userId) async {
final times = (_realtimePushSendTimes[userId] ?? <int, int>{}).values;
if (times.isEmpty) return null;
return times.reduce((a, b) => a >= b ? a : b);
}
@override
Future<void> markLocalNotificationPushed({
required int userId,
required HealthRawLocalNotificationRecordType recordType,
required int recordTime,
required int pushSendTime,
}) async {
switch (recordType) {
case HealthRawLocalNotificationRecordType.hrv:
_hrvPushSendTimes.putIfAbsent(userId, () => <int, int>{})[recordTime] =
pushSendTime;
_hrv[userId] = (_hrv[userId] ?? <HealthRawHrvStressPoint>[])
.map((point) => point.rawEndTime == recordTime
? HealthRawHrvStressPoint(
userId: point.userId,
rawEndTime: point.rawEndTime,
rawHrv: point.rawHrv,
result: point.result,
sourceStartTime: point.sourceStartTime,
sourceEndTime: point.sourceEndTime,
state: point.state,
baselineHrv: point.baselineHrv,
baselineAwakeHrv: point.baselineAwakeHrv,
baselineSleepHrv: point.baselineSleepHrv,
baselineRestingHr: point.baselineRestingHr,
flags: point.flags,
uploaded: point.uploaded,
pushSendTime: pushSendTime,
uploadTime: point.uploadTime,
)
: point)
.toList();
case HealthRawLocalNotificationRecordType.realtimeStress:
_realtimePushSendTimes.putIfAbsent(
userId, () => <int, int>{})[recordTime] = pushSendTime;
_realtime[userId] =
(_realtime[userId] ?? <HealthRawRealtimeStressPoint>[])
.map((point) => point.rawEndTime == recordTime
? HealthRawRealtimeStressPoint(
userId: point.userId,
rawEndTime: point.rawEndTime,
rawHr: point.rawHr,
result: point.result,
sourceStartTime: point.sourceStartTime,
sourceEndTime: point.sourceEndTime,
flags: point.flags,
uploaded: point.uploaded,
pushSendTime: pushSendTime,
uploadTime: point.uploadTime,
)
: point)
.toList();
case HealthRawLocalNotificationRecordType.sleep:
_sleepPushSendTimes.putIfAbsent(
userId, () => <int, int>{})[recordTime] = pushSendTime;
_sleep[userId] = (_sleep[userId] ?? <HealthRawSleepResult>[])
.map((result) => result.date == recordTime
? HealthRawSleepResult(
userId: result.userId,
date: result.date,
startDate: result.startDate,
sleepScore: result.sleepScore,
sleepState: result.sleepState,
inBedMinutes: result.inBedMinutes,
awakMinutes: result.awakMinutes,
sleepMinutes: result.sleepMinutes,
uploaded: result.uploaded,
pushSendTime: pushSendTime,
uploadTime: result.uploadTime,
)
: result)
.toList();
}
}
@override
Future<int?> earliestRealtimeRawEndTime(int userId) async {
final points = _realtime[userId];
if (points == null || points.isEmpty) return null;
... ... @@ -1533,8 +1920,11 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
required Iterable<int> rawEndTimes,
}) async {
final timeSet = rawEndTimes.toSet();
final uploadTime = _nextUploadTime();
_hrv[userId] = (_hrv[userId] ?? <HealthRawHrvStressPoint>[]).map((point) {
if (!timeSet.contains(point.rawEndTime)) return point;
_hrvUploadTimes.putIfAbsent(
userId, () => <int, int>{})[point.rawEndTime] = uploadTime;
return HealthRawHrvStressPoint(
userId: point.userId,
rawEndTime: point.rawEndTime,
... ... @@ -1549,6 +1939,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
baselineRestingHr: point.baselineRestingHr,
flags: point.flags,
uploaded: true,
pushSendTime: point.pushSendTime,
uploadTime: uploadTime,
);
}).toList();
}
... ... @@ -1570,9 +1962,12 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
required Iterable<int> rawEndTimes,
}) async {
final timeSet = rawEndTimes.toSet();
final uploadTime = _nextUploadTime();
_realtime[userId] =
(_realtime[userId] ?? <HealthRawRealtimeStressPoint>[]).map((point) {
if (!timeSet.contains(point.rawEndTime)) return point;
_realtimeUploadTimes.putIfAbsent(
userId, () => <int, int>{})[point.rawEndTime] = uploadTime;
return HealthRawRealtimeStressPoint(
userId: point.userId,
rawEndTime: point.rawEndTime,
... ... @@ -1582,6 +1977,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
sourceEndTime: point.sourceEndTime,
flags: point.flags,
uploaded: true,
pushSendTime: point.pushSendTime,
uploadTime: uploadTime,
);
}).toList();
}
... ... @@ -1602,9 +1999,12 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
required int userId,
required int date,
}) async {
final uploadTime = _nextUploadTime();
_daily[userId] =
(_daily[userId] ?? <HealthRawDailyStressPoint>[]).map((point) {
if (point.date > date) return point;
_dailyUploadTimes.putIfAbsent(userId, () => <int, int>{})[point.date] =
uploadTime;
return HealthRawDailyStressPoint(
userId: point.userId,
date: point.date,
... ... @@ -1613,6 +2013,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
state: point.state,
dataTime: point.dataTime,
uploaded: true,
uploadTime: uploadTime,
);
}).toList();
}
... ... @@ -1622,8 +2023,11 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
required int userId,
required int date,
}) async {
final uploadTime = _nextUploadTime();
_sleep[userId] = (_sleep[userId] ?? <HealthRawSleepResult>[]).map((result) {
if (result.date > date) return result;
_sleepUploadTimes.putIfAbsent(userId, () => <int, int>{})[result.date] =
uploadTime;
return HealthRawSleepResult(
userId: result.userId,
date: result.date,
... ... @@ -1634,6 +2038,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
awakMinutes: result.awakMinutes,
sleepMinutes: result.sleepMinutes,
uploaded: true,
pushSendTime: result.pushSendTime,
uploadTime: uploadTime,
);
}).toList();
}
... ... @@ -1713,6 +2119,9 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
'is_suspected_activity': point.flags.isSuspectedActivity ? 1 : 0,
'uploaded': point.uploaded ? 1 : 0,
'update_time': _hrvUpdateTimes[point.userId]?[point.rawEndTime] ?? 0,
'push_send_time': _hrvPushSendTimes[point.userId]?[point.rawEndTime],
'upload_time':
_hrvUploadTimes[point.userId]?[point.rawEndTime] ?? point.uploadTime,
};
}
... ... @@ -1730,6 +2139,9 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
'is_suspected_activity': point.flags.isSuspectedActivity ? 1 : 0,
'uploaded': point.uploaded ? 1 : 0,
'update_time': _realtimeUpdateTimes[point.userId]?[point.rawEndTime] ?? 0,
'push_send_time': _realtimePushSendTimes[point.userId]?[point.rawEndTime],
'upload_time': _realtimeUploadTimes[point.userId]?[point.rawEndTime] ??
point.uploadTime,
};
}
... ... @@ -1743,6 +2155,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
'data_time': point.dataTime,
'uploaded': point.uploaded ? 1 : 0,
'update_time': _dailyUpdateTimes[point.userId]?[point.date] ?? 0,
'upload_time':
_dailyUploadTimes[point.userId]?[point.date] ?? point.uploadTime,
};
}
... ... @@ -1758,11 +2172,16 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
'sleep_minutes': result.sleepMinutes,
'uploaded': result.uploaded ? 1 : 0,
'update_time': _sleepUpdateTimes[result.userId]?[result.date] ?? 0,
'push_send_time': _sleepPushSendTimes[result.userId]?[result.date],
'upload_time':
_sleepUploadTimes[result.userId]?[result.date] ?? result.uploadTime,
};
}
int _nextUpdateTime() => _updateTimeClock++;
int _nextUploadTime() => _uploadTimeClock++;
int _todayDateKey() {
final now = DateTime.now();
return now.year * 10000 + now.month * 100 + now.day;
... ...
... ... @@ -153,6 +153,76 @@ void main() {
expect(notifications.single.recordTime, latestTime);
});
test('builds only the latest hrv notification for multiple candidates', () {
final firstTime = _seconds(DateTime(2026, 1, 1, 9));
final secondTime = _seconds(DateTime(2026, 1, 1, 10));
final notifications = builder.build(
result: HealthRawStressCalculationResult(
userId: 1,
hrvStressPoints: [
_hrvPoint(secondTime, isSleepLikely: true),
_hrvPoint(firstTime),
],
realtimeStressPoints: const [],
dailyStressPoints: const [],
),
hasExistingHrv: true,
hasExistingSleep: false,
realtimeWindow: const [],
record: const HealthRawLocalNotificationRecord(),
);
expect(notifications, hasLength(1));
expect(notifications.single.recordType,
HealthRawLocalNotificationRecordType.hrv);
expect(notifications.single.recordTime, secondTime);
});
test('keeps the latest notification per type without dropping other types',
() {
final hrvFirstTime = _seconds(DateTime(2026, 1, 1, 9));
final hrvSecondTime = _seconds(DateTime(2026, 1, 1, 10));
final realtimeBase = _seconds(DateTime(2026, 1, 1, 11));
final notifications = builder.build(
result: HealthRawStressCalculationResult(
userId: 1,
hrvStressPoints: [
_hrvPoint(hrvFirstTime),
_hrvPoint(hrvSecondTime),
],
realtimeStressPoints: [
_realtimePoint(realtimeBase + 9 * 300, 70),
],
dailyStressPoints: const [],
),
hasExistingHrv: true,
hasExistingSleep: false,
realtimeWindow: [
for (var i = 0; i < 10; i++) _realtimePoint(realtimeBase + i * 300, 70),
],
record: const HealthRawLocalNotificationRecord(),
);
expect(notifications, hasLength(2));
expect(
notifications
.where(
(e) => e.recordType == HealthRawLocalNotificationRecordType.hrv)
.single
.recordTime,
hrvSecondTime,
);
expect(
notifications
.where((e) =>
e.recordType ==
HealthRawLocalNotificationRecordType.realtimeStress)
.single
.recordTime,
realtimeBase + 9 * 300,
);
});
test('builds realtime stress notification from latest 60 minute window', () {
final base = _seconds(DateTime(2026, 1, 1, 9));
final notifications = builder.build(
... ... @@ -208,6 +278,138 @@ void main() {
);
});
test('skips realtime stress notification within push send interval', () {
final base = _seconds(DateTime(2026, 1, 1, 9));
final notifications = builder.build(
result: HealthRawStressCalculationResult(
userId: 1,
hrvStressPoints: const [],
realtimeStressPoints: [_realtimePoint(base + 9 * 300, 70)],
dailyStressPoints: const [],
),
hasExistingHrv: false,
hasExistingSleep: false,
realtimeWindow: [
for (var i = 0; i < 10; i++) _realtimePoint(base + i * 300, 70),
],
record: const HealthRawLocalNotificationRecord(),
lastRealtimeStressPushSendTime: base + 100,
notificationBuildTime: base + 100 + 3599,
);
expect(notifications, isEmpty);
});
test('skips realtime stress notification inside supplied sleep interval', () {
final base = _seconds(DateTime(2026, 1, 1, 9));
final notifications = builder.build(
result: HealthRawStressCalculationResult(
userId: 1,
hrvStressPoints: const [],
realtimeStressPoints: [_realtimePoint(base + 9 * 300, 70)],
dailyStressPoints: const [],
),
hasExistingHrv: false,
hasExistingSleep: false,
realtimeWindow: [
for (var i = 0; i < 10; i++) _realtimePoint(base + i * 300, 70),
],
record: const HealthRawLocalNotificationRecord(),
realtimeSleepIntervals: [
(startTime: base + 8 * 300, endTime: base + 10 * 300),
],
);
expect(notifications, isEmpty);
});
test('builds realtime stress notification outside supplied sleep interval',
() {
final base = _seconds(DateTime(2026, 1, 1, 9));
final notifications = builder.build(
result: HealthRawStressCalculationResult(
userId: 1,
hrvStressPoints: const [],
realtimeStressPoints: [_realtimePoint(base + 9 * 300, 70)],
dailyStressPoints: const [],
),
hasExistingHrv: false,
hasExistingSleep: false,
realtimeWindow: [
for (var i = 0; i < 10; i++) _realtimePoint(base + i * 300, 70),
],
record: const HealthRawLocalNotificationRecord(),
realtimeSleepIntervals: [
(startTime: base - 3 * 3600, endTime: base - 2 * 3600),
],
);
expect(notifications, hasLength(1));
});
test('uses midnight to six default realtime sleep interval', () {
final dayStart = _seconds(DateTime(2026, 1, 1));
final sleepTime = dayStart + 5 * 3600 + 30 * 60;
final awakeTime = dayStart + 6 * 3600 + 60;
final sleepNotifications = builder.build(
result: HealthRawStressCalculationResult(
userId: 1,
hrvStressPoints: const [],
realtimeStressPoints: [_realtimePoint(sleepTime, 70)],
dailyStressPoints: const [],
),
hasExistingHrv: false,
hasExistingSleep: false,
realtimeWindow: [
for (var i = 0; i < 10; i++)
_realtimePoint(sleepTime - (9 - i) * 300, 70),
],
record: const HealthRawLocalNotificationRecord(),
);
final awakeNotifications = builder.build(
result: HealthRawStressCalculationResult(
userId: 1,
hrvStressPoints: const [],
realtimeStressPoints: [_realtimePoint(awakeTime, 70)],
dailyStressPoints: const [],
),
hasExistingHrv: false,
hasExistingSleep: false,
realtimeWindow: [
for (var i = 0; i < 10; i++)
_realtimePoint(awakeTime - (9 - i) * 300, 70),
],
record: const HealthRawLocalNotificationRecord(),
);
expect(sleepNotifications, isEmpty);
expect(awakeNotifications, hasLength(1));
});
test('ignores legacy realtime record time for push send interval', () {
final base = _seconds(DateTime(2026, 1, 1, 9));
final notifications = builder.build(
result: HealthRawStressCalculationResult(
userId: 1,
hrvStressPoints: const [],
realtimeStressPoints: [_realtimePoint(base + 9 * 300, 70)],
dailyStressPoints: const [],
),
hasExistingHrv: false,
hasExistingSleep: false,
realtimeWindow: [
for (var i = 0; i < 10; i++) _realtimePoint(base + i * 300, 70),
],
record: HealthRawLocalNotificationRecord(
lastRealtimeStressTime: base + 9 * 300,
),
notificationBuildTime: base + 9 * 300,
);
expect(notifications, hasLength(1));
});
test('skips realtime stress notification during likely sleep', () {
final base = _seconds(DateTime(2026, 1, 1, 9));
final notifications = builder.build(
... ... @@ -292,11 +494,57 @@ void main() {
final record = await dispatcher.readRecord(1);
expect(record.lastRealtimeStressTime, 1234);
});
test('reads old notification record json without latest data times', () {
final record = HealthRawLocalNotificationRecord.fromJson(
const <String, Object?>{
'last_sleep_time': 100,
'last_hrv_time': 200,
'last_realtime_stress_time': 300,
},
);
expect(record.lastSleepTime, 100);
expect(record.lastHrvTime, 200);
expect(record.lastRealtimeStressTime, 300);
expect(record.latestHrvDataTime, isNull);
expect(record.latestRealtimeStressDataTime, isNull);
expect(record.latestSleepDataTime, isNull);
});
test('stores processed latest data times independently', () async {
final dir = await Directory.systemTemp.createTemp(
'health_raw_notification_test_',
);
addTearDown(() => dir.delete(recursive: true));
final dispatcher = HealthRawLocalNotificationDispatcher(
recordStore: HealthRawLocalNotificationRecordStore(rootDirectory: dir),
);
await dispatcher.updateProcessedDataTimes(
userId: 1,
latestHrvDataTime: 100,
latestRealtimeStressDataTime: 200,
latestSleepDataTime: 300,
);
await dispatcher.updateProcessedDataTimes(
userId: 1,
latestRealtimeStressDataTime: 250,
);
final record = await dispatcher.readRecord(1);
expect(record.latestHrvDataTime, 100);
expect(record.latestRealtimeStressDataTime, 250);
expect(record.latestSleepDataTime, 300);
});
}
int _seconds(DateTime time) => time.millisecondsSinceEpoch ~/ 1000;
HealthRawHrvStressPoint _hrvPoint(int rawEndTime) {
HealthRawHrvStressPoint _hrvPoint(
int rawEndTime, {
bool isSleepLikely = false,
}) {
return HealthRawHrvStressPoint(
userId: 1,
rawEndTime: rawEndTime,
... ... @@ -309,6 +557,12 @@ HealthRawHrvStressPoint _hrvPoint(int rawEndTime) {
baselineAwakeHrv: 30,
baselineSleepHrv: null,
baselineRestingHr: 60,
flags: HealthRawPointFlags(
isSleepLikely: isSleepLikely,
isWorkout: false,
isWorkoutRecovery: false,
isSuspectedActivity: false,
),
);
}
... ...