Commit 2a94da76826907c4940dcd9855a661c311460ac1

Authored by 权海
1 parent c16d74a6

feat(ui):优化计算数据和推送发送

... ... @@ -160,6 +160,8 @@ class HealthRawHrvStressPoint {
required this.baselineRestingHr,
this.flags = const HealthRawPointFlags.none(),
this.uploaded = false,
this.pushSendTime,
this.uploadTime,
});
final int userId;
... ... @@ -175,6 +177,8 @@ class HealthRawHrvStressPoint {
final double baselineRestingHr;
final HealthRawPointFlags flags;
final bool uploaded;
final int? pushSendTime;
final int? uploadTime;
factory HealthRawHrvStressPoint.fromDb(Map<String, Object?> row) {
final result = (row['result'] as num).toDouble();
... ... @@ -196,6 +200,8 @@ class HealthRawHrvStressPoint {
baselineRestingHr: _positiveDouble(row['baseline_resting_hr']) ?? 65,
flags: _flagsFromDb(row),
uploaded: (row['uploaded'] as int) == 1,
pushSendTime: (row['push_send_time'] as num?)?.toInt(),
uploadTime: (row['upload_time'] as num?)?.toInt(),
);
}
}
... ... @@ -210,6 +216,8 @@ class HealthRawRealtimeStressPoint {
required this.sourceEndTime,
this.flags = const HealthRawPointFlags.none(),
this.uploaded = false,
this.pushSendTime,
this.uploadTime,
});
final int userId;
... ... @@ -220,6 +228,8 @@ class HealthRawRealtimeStressPoint {
final int sourceEndTime;
final HealthRawPointFlags flags;
final bool uploaded;
final int? pushSendTime;
final int? uploadTime;
HealthRawStressState get state => healthRawRealtimeStressState(result);
bool get isWorkout => flags.isWorkout;
... ... @@ -237,6 +247,8 @@ class HealthRawRealtimeStressPoint {
sourceEndTime: row['source_end_time'] as int,
flags: _flagsFromDb(row),
uploaded: (row['uploaded'] as int) == 1,
pushSendTime: (row['push_send_time'] as num?)?.toInt(),
uploadTime: (row['upload_time'] as num?)?.toInt(),
);
}
}
... ... @@ -250,6 +262,7 @@ class HealthRawDailyStressPoint {
required this.state,
required this.dataTime,
this.uploaded = false,
this.uploadTime,
});
final int userId;
... ... @@ -259,6 +272,7 @@ class HealthRawDailyStressPoint {
final HealthRawStressState state;
final int dataTime;
final bool uploaded;
final int? uploadTime;
factory HealthRawDailyStressPoint.fromDb(Map<String, Object?> row) {
final stressScore = row['stress_score'] as int;
... ... @@ -271,6 +285,7 @@ class HealthRawDailyStressPoint {
healthRawRealtimeStressState(stressScore),
dataTime: row['data_time'] as int,
uploaded: (row['uploaded'] as int) == 1,
uploadTime: (row['upload_time'] as num?)?.toInt(),
);
}
}
... ... @@ -286,6 +301,8 @@ class HealthRawSleepResult {
required this.awakMinutes,
required this.sleepMinutes,
this.uploaded = false,
this.pushSendTime,
this.uploadTime,
});
final int userId;
... ... @@ -297,6 +314,8 @@ class HealthRawSleepResult {
final int awakMinutes;
final int sleepMinutes;
final bool uploaded;
final int? pushSendTime;
final int? uploadTime;
factory HealthRawSleepResult.fromDb(Map<String, Object?> row) {
return HealthRawSleepResult(
... ... @@ -309,6 +328,8 @@ class HealthRawSleepResult {
awakMinutes: row['awak_minutes'] as int,
sleepMinutes: row['sleep_minutes'] as int,
uploaded: (row['uploaded'] as int? ?? 0) == 1,
pushSendTime: (row['push_send_time'] as num?)?.toInt(),
uploadTime: (row['upload_time'] as num?)?.toInt(),
);
}
... ...
... ... @@ -17,7 +17,6 @@ import '../../../logging/app_logger.dart';
import '../../../network/api/health_api.dart';
import '../../../result/app_result.dart';
import '../health_raw_models.dart';
import 'apple_health_raw_local_notification_debug_store.dart';
import 'apple_health_raw_local_notification.dart';
import '../health_raw_stress_calculator.dart';
import '../health_sleep_calculator.dart';
... ... @@ -39,7 +38,6 @@ class AppleHealthRawDataCoreService {
int Function()? userIdProvider,
bool uploadResultsAfterCalculation = true,
HealthRawLocalNotificationDispatcher? localNotificationDispatcher,
HealthRawLocalNotificationDebugStore? localNotificationDebugStore,
HealthApi? serverHealthApi,
}) : _healthApi = healthApi ?? HealthKitHostApi(),
_rawDataApi = rawDataApi ?? HealthKitRawDataHostApi(),
... ... @@ -49,8 +47,6 @@ class AppleHealthRawDataCoreService {
_uploadResultsAfterCalculation = uploadResultsAfterCalculation,
_localNotificationDispatcher = localNotificationDispatcher ??
HealthRawLocalNotificationDispatcher(),
_localNotificationDebugStore = localNotificationDebugStore ??
HealthRawLocalNotificationDebugStore(),
_serverHealthApi = serverHealthApi;
final HealthKitHostApi _healthApi;
... ... @@ -60,7 +56,6 @@ class AppleHealthRawDataCoreService {
final int Function()? _userIdProvider;
final bool _uploadResultsAfterCalculation;
final HealthRawLocalNotificationDispatcher _localNotificationDispatcher;
final HealthRawLocalNotificationDebugStore _localNotificationDebugStore;
final HealthApi? _serverHealthApi;
final StreamController<HealthRawDataUpdatedEvent>
_healthDataUpdatedController =
... ... @@ -219,7 +214,7 @@ class AppleHealthRawDataCoreService {
}
Future<String> readLocalNotificationDebugLogText() {
return _localNotificationDebugStore.readText();
return Future.value('');
}
Future<bool> shareLocalNotificationDebugRecord() async {
... ... @@ -537,6 +532,12 @@ class AppleHealthRawDataCoreService {
);
var notificationResult = candidateResult;
var realtimeWindow = const <HealthRawRealtimeStressPoint>[];
final lastRealtimeStressPushSendTime =
await _localStore.latestRealtimeStressPushSendTime(
candidateResult.userId,
);
final notificationBuildTime = _unixSecondsNow();
var realtimeSleepIntervals = const <HealthRawSleepInterval>[];
if (latestRealtimeStressPoint != null) {
final shouldResetRealtimeStressPushTime =
latestRealtimeStressPoint.isWorkout ||
... ... @@ -551,10 +552,6 @@ class AppleHealthRawDataCoreService {
_realtimeStressPointPayload(latestRealtimeStressPoint),
},
);
await _recordRealtimeStressTimeSafely(
userId: candidateResult.userId,
recordTime: latestRealtimeStressPoint.rawEndTime,
);
notificationResult = candidateResult.copyWith(
realtimeStressPoints: const <HealthRawRealtimeStressPoint>[],
);
... ... @@ -563,6 +560,11 @@ class AppleHealthRawDataCoreService {
userId: candidateResult.userId,
latestRawEndTime: latestRealtimeStressPoint.rawEndTime,
);
realtimeSleepIntervals =
await _queryRealtimeStressSleepIntervalsForNotificationSafely(
userId: candidateResult.userId,
latestRawEndTime: latestRealtimeStressPoint.rawEndTime,
);
}
}
... ... @@ -576,7 +578,10 @@ class AppleHealthRawDataCoreService {
hasExistingHrv: candidateData.hasExistingHrvDataTime,
hasExistingSleep: candidateData.hasExistingSleepDataTime,
realtimeWindow: realtimeWindow,
realtimeSleepIntervals: realtimeSleepIntervals,
record: record,
lastRealtimeStressPushSendTime: lastRealtimeStressPushSendTime,
notificationBuildTime: notificationBuildTime,
),
},
);
... ... @@ -585,7 +590,10 @@ class AppleHealthRawDataCoreService {
hasExistingHrv: candidateData.hasExistingHrvDataTime,
hasExistingSleep: candidateData.hasExistingSleepDataTime,
realtimeWindow: realtimeWindow,
realtimeSleepIntervals: realtimeSleepIntervals,
record: record,
lastRealtimeStressPushSendTime: lastRealtimeStressPushSendTime,
notificationBuildTime: notificationBuildTime,
);
await _saveLocalNotificationDebugEvent(
<String, Object?>{
... ... @@ -634,6 +642,10 @@ class AppleHealthRawDataCoreService {
);
if (sent) {
sentNotifications.add(notification);
await _recordLocalNotificationPushSendTimeSafely(
userId: candidateResult.userId,
notification: notification,
);
}
}
_scheduleRealtimeStressServerPush(sentNotifications);
... ... @@ -759,17 +771,20 @@ class AppleHealthRawDataCoreService {
}
}
Future<void> _recordRealtimeStressTimeSafely({
Future<void> _recordLocalNotificationPushSendTimeSafely({
required int userId,
required int recordTime,
required HealthRawLocalNotification notification,
}) async {
try {
await _localNotificationDispatcher.recordRealtimeStressTime(
await _localStore.markLocalNotificationPushed(
userId: userId,
recordTime: recordTime,
recordType: notification.recordType,
recordTime: notification.recordTime,
pushSendTime: _unixSecondsNow(),
);
} catch (error, stackTrace) {
_logError('record realtime stress push time failed', error, stackTrace);
_logError(
'record local notification push send time failed', error, stackTrace);
}
}
... ... @@ -815,6 +830,73 @@ class AppleHealthRawDataCoreService {
}
}
Future<List<HealthRawSleepInterval>>
_queryRealtimeStressSleepIntervalsForNotificationSafely({
required int userId,
required int latestRawEndTime,
}) async {
try {
final intervals = await _queryRealtimeStressSleepIntervalsForNotification(
userId: userId,
latestRawEndTime: latestRawEndTime,
);
await _saveLocalNotificationDebugEvent(
<String, Object?>{
'event': 'flutterRealtimeStressSleepIntervalsQueried',
'user_id': userId,
'latest_raw_end_time': latestRawEndTime,
'sleep_intervals': intervals.map(_sleepIntervalPayload).toList(),
},
);
return intervals;
} catch (error, stackTrace) {
_logError(
'query realtime stress sleep intervals failed',
error,
stackTrace,
);
final fallback = [_defaultSleepIntervalForUnixSeconds(latestRawEndTime)];
await _saveLocalNotificationDebugEvent(
<String, Object?>{
'event': 'flutterRealtimeStressSleepIntervalsQueryFailed',
'user_id': userId,
'latest_raw_end_time': latestRawEndTime,
'sleep_intervals': fallback.map(_sleepIntervalPayload).toList(),
'error': error.toString(),
'stack_trace': stackTrace.toString(),
},
);
return fallback;
}
}
Future<List<HealthRawSleepInterval>>
_queryRealtimeStressSleepIntervalsForNotification({
required int userId,
required int latestRawEndTime,
}) async {
final dayRange = _dayRangeFromUnixSeconds(latestRawEndTime);
final sleepResults = await _localStore.querySleepResults(
userId: userId,
startTime: dayRange.$1,
endTime: dayRange.$2,
);
final intervals = sleepResults
.where((result) => result.startDate <= result.date)
.map(
(result) => (
startTime: result.startDate,
endTime: result.date,
),
)
.toList()
..sort((a, b) => a.startTime.compareTo(b.startTime));
if (intervals.isNotEmpty) {
return intervals;
}
return [_defaultSleepIntervalForUnixSeconds(latestRawEndTime)];
}
Future<bool> _sendLocalNotificationSafely({
required int userId,
required HealthRawLocalNotification notification,
... ... @@ -844,7 +926,10 @@ class AppleHealthRawDataCoreService {
required bool hasExistingHrv,
required bool hasExistingSleep,
required List<HealthRawRealtimeStressPoint> realtimeWindow,
required List<HealthRawSleepInterval> realtimeSleepIntervals,
required HealthRawLocalNotificationRecord record,
required int? lastRealtimeStressPushSendTime,
required int notificationBuildTime,
}) {
return <Map<String, Object?>>[
_sleepNotificationDecisionPayload(
... ... @@ -859,7 +944,10 @@ class AppleHealthRawDataCoreService {
_realtimeStressNotificationDecisionPayload(
result.realtimeStressPoints,
realtimeWindow: realtimeWindow,
realtimeSleepIntervals: realtimeSleepIntervals,
record: record,
lastPushSendTime: lastRealtimeStressPushSendTime,
notificationBuildTime: notificationBuildTime,
),
];
}
... ... @@ -937,20 +1025,23 @@ class AppleHealthRawDataCoreService {
}
final sorted = [...hrvPoints]
..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime));
final latest = sorted.last;
return <String, Object?>{
'type': HealthRawLocalNotificationRecordType.hrv.name,
'will_build': true,
'reason': 'candidate',
'last_hrv_time': record.lastHrvTime,
'latest_hrv': _hrvStressPointPayload(latest),
'candidate_count': sorted.length,
'latest_hrv': _hrvStressPointPayload(sorted.last),
};
}
Map<String, Object?> _realtimeStressNotificationDecisionPayload(
List<HealthRawRealtimeStressPoint> realtimePoints, {
required List<HealthRawRealtimeStressPoint> realtimeWindow,
required List<HealthRawSleepInterval> realtimeSleepIntervals,
required HealthRawLocalNotificationRecord record,
required int? lastPushSendTime,
required int notificationBuildTime,
}) {
if (realtimePoints.isEmpty) {
return <String, Object?>{
... ... @@ -970,6 +1061,19 @@ class AppleHealthRawDataCoreService {
'latest_realtime': _realtimeStressPointPayload(latest),
};
}
final matchedSleepInterval =
_sleepIntervalContaining(latest.rawEndTime, realtimeSleepIntervals);
if (matchedSleepInterval != null) {
return <String, Object?>{
'type': HealthRawLocalNotificationRecordType.realtimeStress.name,
'will_build': false,
'reason': 'sleep_interval',
'matched_sleep_interval': _sleepIntervalPayload(matchedSleepInterval),
'sleep_intervals':
realtimeSleepIntervals.map(_sleepIntervalPayload).toList(),
'latest_realtime': _realtimeStressPointPayload(latest),
};
}
final valid = realtimeWindow
.where((e) => e.result >= 1 && e.result <= 100)
.toList()
... ... @@ -991,14 +1095,15 @@ class AppleHealthRawDataCoreService {
'latest_realtime': _realtimeStressPointPayload(latest),
};
}
final lastPushTime = record.lastRealtimeStressTime;
final lastPushTime = lastPushSendTime;
if (lastPushTime != null &&
latest.rawEndTime - lastPushTime < Duration.secondsPerHour) {
notificationBuildTime - lastPushTime < Duration.secondsPerHour) {
return <String, Object?>{
'type': HealthRawLocalNotificationRecordType.realtimeStress.name,
'will_build': false,
'reason': 'within_realtime_interval',
'last_realtime_stress_time': lastPushTime,
'last_realtime_stress_push_send_time': lastPushTime,
'notification_build_time': notificationBuildTime,
'latest_realtime': _realtimeStressPointPayload(latest),
};
}
... ... @@ -1006,12 +1111,36 @@ class AppleHealthRawDataCoreService {
'type': HealthRawLocalNotificationRecordType.realtimeStress.name,
'will_build': true,
'reason': 'candidate',
'last_realtime_stress_time': lastPushTime,
'last_realtime_stress_push_send_time': lastPushTime,
'notification_build_time': notificationBuildTime,
'valid_window_count': valid.length,
'sleep_intervals':
realtimeSleepIntervals.map(_sleepIntervalPayload).toList(),
'latest_realtime': _realtimeStressPointPayload(latest),
};
}
HealthRawSleepInterval? _sleepIntervalContaining(
int time,
List<HealthRawSleepInterval> intervals,
) {
for (final interval in intervals) {
if (time >= interval.startTime && time <= interval.endTime) {
return interval;
}
}
return null;
}
Map<String, Object?> _sleepIntervalPayload(
HealthRawSleepInterval interval,
) {
return <String, Object?>{
'start_time': interval.startTime,
'end_time': interval.endTime,
};
}
Map<String, Object?> _localNotificationPayload(
HealthRawLocalNotification notification,
) {
... ... @@ -1802,6 +1931,10 @@ class AppleHealthRawDataCoreService {
return date.year * 10000 + date.month * 100 + date.day;
}
static (int startTime, int endTime) _dayRangeFromUnixSeconds(int seconds) {
return _dayRangeFromDateKey(_dateKeyFromUnixSeconds(seconds));
}
static (int startTime, int endTime) _dayRangeFromDateKey(int dateKey) {
final year = dateKey ~/ 10000;
final month = (dateKey ~/ 100) % 100;
... ... @@ -1813,6 +1946,16 @@ class AppleHealthRawDataCoreService {
);
}
static HealthRawSleepInterval _defaultSleepIntervalForUnixSeconds(
int seconds,
) {
final dayRange = _dayRangeFromUnixSeconds(seconds);
return (
startTime: dayRange.$1,
endTime: dayRange.$1 + 6 * 60 * 60,
);
}
static String _formatDateTimeMilliseconds(DateTime dateTime) {
String two(int value) => value.toString().padLeft(2, '0');
String three(int value) => value.toString().padLeft(3, '0');
... ... @@ -1911,6 +2054,8 @@ class _HealthRawLocalNotificationCandidateData {
double _integerDouble(num value) => value.toInt().toDouble();
int _unixSecondsNow() => DateTime.now().millisecondsSinceEpoch ~/ 1000;
class HealthRawStressLocalStore {
HealthRawStressLocalStore({
Directory? rootDirectory,
... ... @@ -1932,19 +2077,20 @@ class HealthRawStressLocalStore {
Future<void> upsertResult(HealthRawStressCalculationResult result) async {
final db = await _database(result.userId);
final updateTime = _currentUnixSeconds();
await db.transaction((txn) async {
for (final point in result.hrvStressPoints) {
await _upsertResettingUploaded(
txn,
hrvResultsTable,
_hrvRow(point),
_hrvRow(point, updateTime),
);
}
for (final point in result.realtimeStressPoints) {
await _upsertResettingUploaded(
txn,
realtimeStressResultsTable,
_realtimeRow(point),
_realtimeRow(point, updateTime),
);
}
});
... ... @@ -1955,9 +2101,13 @@ class HealthRawStressLocalStore {
required Iterable<HealthRawDailyStressPoint> points,
}) async {
final db = await _database(userId);
final updateTime = _currentUnixSeconds();
await db.transaction((txn) async {
for (final point in points) {
await _upsertDailyStressResettingUploaded(txn, _dailyStressRow(point));
await _upsertDailyStressResettingUploaded(
txn,
_dailyStressRow(point, updateTime),
);
}
});
}
... ... @@ -1967,9 +2117,13 @@ class HealthRawStressLocalStore {
required Iterable<HealthRawSleepResult> results,
}) async {
final db = await _database(userId);
final updateTime = _currentUnixSeconds();
await db.transaction((txn) async {
for (final result in results) {
await _upsertSleepResultResettingUploaded(txn, _sleepRow(result));
await _upsertSleepResultResettingUploaded(
txn,
_sleepRow(result, updateTime),
);
}
});
}
... ... @@ -2145,10 +2299,43 @@ class HealthRawStressLocalStore {
return _latestRawEndTime(userId, realtimeStressResultsTable);
}
Future<int?> latestRealtimeStressPushSendTime(int userId) {
return _latestPushSendTime(userId, realtimeStressResultsTable);
}
Future<int?> earliestRealtimeRawEndTime(int userId) {
return _earliestRawEndTime(userId, realtimeStressResultsTable);
}
Future<void> markLocalNotificationPushed({
required int userId,
required HealthRawLocalNotificationRecordType recordType,
required int recordTime,
required int pushSendTime,
}) async {
final (table, timeColumn) = switch (recordType) {
HealthRawLocalNotificationRecordType.hrv => (
hrvResultsTable,
'raw_end_time',
),
HealthRawLocalNotificationRecordType.realtimeStress => (
realtimeStressResultsTable,
'raw_end_time',
),
HealthRawLocalNotificationRecordType.sleep => (
sleepResultsTable,
'date',
),
};
final db = await _database(userId);
await db.update(
table,
<String, Object?>{'push_send_time': pushSendTime},
where: '$timeColumn = ?',
whereArgs: [recordTime],
);
}
Future<int?> latestSleepResultTime(int userId) async {
final db = await _database(userId);
final rows = await db.query(
... ... @@ -2288,7 +2475,7 @@ class HealthRawStressLocalStore {
final db = await factory.openDatabase(
path,
options: OpenDatabaseOptions(
version: 8,
version: 10,
onCreate: (db, version) async {
await _createTables(db);
},
... ... @@ -2315,6 +2502,12 @@ class HealthRawStressLocalStore {
if (oldVersion < 8) {
await _addUpdateTimeColumns(db);
}
if (oldVersion < 9) {
await _addPushSendTimeColumns(db);
}
if (oldVersion < 10) {
await _addUploadTimeColumns(db);
}
},
),
);
... ... @@ -2341,7 +2534,9 @@ CREATE TABLE IF NOT EXISTS $hrvResultsTable (
is_sleep_likely INTEGER NOT NULL DEFAULT 0,
is_suspected_activity INTEGER NOT NULL DEFAULT 0,
uploaded INTEGER NOT NULL DEFAULT 0,
update_time INTEGER NOT NULL DEFAULT 0
update_time INTEGER NOT NULL DEFAULT 0,
push_send_time INTEGER,
upload_time INTEGER
)
''');
await db.execute('''
... ... @@ -2357,7 +2552,9 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable (
is_sleep_likely INTEGER NOT NULL DEFAULT 0,
is_suspected_activity INTEGER NOT NULL DEFAULT 0,
uploaded INTEGER NOT NULL DEFAULT 0,
update_time INTEGER NOT NULL DEFAULT 0
update_time INTEGER NOT NULL DEFAULT 0,
push_send_time INTEGER,
upload_time INTEGER
)
''');
await _createDailyStressTable(db);
... ... @@ -2374,7 +2571,8 @@ CREATE TABLE IF NOT EXISTS $dailyStressResultsTable (
state INTEGER NOT NULL,
data_time INTEGER NOT NULL,
uploaded INTEGER NOT NULL DEFAULT 0,
update_time INTEGER NOT NULL DEFAULT 0
update_time INTEGER NOT NULL DEFAULT 0,
upload_time INTEGER
)
''');
}
... ... @@ -2391,7 +2589,9 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
awak_minutes INTEGER NOT NULL,
sleep_minutes INTEGER NOT NULL,
uploaded INTEGER NOT NULL DEFAULT 0,
update_time INTEGER NOT NULL DEFAULT 0
update_time INTEGER NOT NULL DEFAULT 0,
push_send_time INTEGER,
upload_time INTEGER
)
''');
}
... ... @@ -2413,6 +2613,37 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
}
}
Future<void> _addPushSendTimeColumns(DatabaseExecutor db) async {
for (final table in const [
hrvResultsTable,
realtimeStressResultsTable,
sleepResultsTable,
]) {
try {
await db.execute(
'ALTER TABLE $table ADD COLUMN push_send_time INTEGER',
);
} on DatabaseException catch (error) {
if (!error.isDuplicateColumnError()) rethrow;
}
}
}
Future<void> _addUploadTimeColumns(DatabaseExecutor db) async {
for (final table in const [
hrvResultsTable,
realtimeStressResultsTable,
dailyStressResultsTable,
sleepResultsTable,
]) {
try {
await db.execute('ALTER TABLE $table ADD COLUMN upload_time INTEGER');
} on DatabaseException catch (error) {
if (!error.isDuplicateColumnError()) rethrow;
}
}
}
Future<void> _addFlagColumns(DatabaseExecutor db, String table) async {
for (final column in const [
'is_workout',
... ... @@ -2496,6 +2727,19 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
return rows.first['raw_end_time'] as int;
}
Future<int?> _latestPushSendTime(int userId, String table) async {
final db = await _database(userId);
final rows = await db.query(
table,
columns: ['push_send_time'],
where: 'push_send_time IS NOT NULL',
orderBy: 'push_send_time DESC',
limit: 1,
);
if (rows.isEmpty) return null;
return rows.first['push_send_time'] as int?;
}
Future<int?> _earliestRawEndTime(int userId, String table) async {
final db = await _database(userId);
final rows = await db.query(
... ... @@ -2516,9 +2760,10 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
final times = rawEndTimes.toList();
if (times.isEmpty) return;
final db = await _database(userId);
final uploadTime = _currentUnixSeconds();
await db.update(
table,
{'uploaded': 1},
{'uploaded': 1, 'upload_time': uploadTime},
where: 'raw_end_time IN (${List.filled(times.length, '?').join(',')})',
whereArgs: times,
);
... ... @@ -2532,9 +2777,10 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
}) async {
if (time <= 0) return;
final db = await _database(userId);
final uploadTime = _currentUnixSeconds();
await db.update(
table,
{'uploaded': 1},
{'uploaded': 1, 'upload_time': uploadTime},
where: '$timeColumn <= ? AND uploaded != 1',
whereArgs: [time],
);
... ... @@ -2559,7 +2805,10 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
return now.year * 10000 + now.month * 100 + now.day;
}
Map<String, Object?> _hrvRow(HealthRawHrvStressPoint point) {
Map<String, Object?> _hrvRow(
HealthRawHrvStressPoint point,
int updateTime,
) {
return <String, Object?>{
'raw_end_time': point.rawEndTime,
'user_id': point.userId,
... ... @@ -2574,11 +2823,15 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
'baseline_resting_hr': point.baselineRestingHr,
..._flagsRow(point.flags),
'uploaded': point.uploaded ? 1 : 0,
'update_time': _currentUnixSeconds(),
'update_time': updateTime,
'upload_time': point.uploadTime,
};
}
Map<String, Object?> _realtimeRow(HealthRawRealtimeStressPoint point) {
Map<String, Object?> _realtimeRow(
HealthRawRealtimeStressPoint point,
int updateTime,
) {
return <String, Object?>{
'raw_end_time': point.rawEndTime,
'user_id': point.userId,
... ... @@ -2588,11 +2841,15 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
'source_end_time': point.sourceEndTime,
..._flagsRow(point.flags),
'uploaded': point.uploaded ? 1 : 0,
'update_time': _currentUnixSeconds(),
'update_time': updateTime,
'upload_time': point.uploadTime,
};
}
Map<String, Object?> _dailyStressRow(HealthRawDailyStressPoint point) {
Map<String, Object?> _dailyStressRow(
HealthRawDailyStressPoint point,
int updateTime,
) {
return <String, Object?>{
'date': point.date,
'user_id': point.userId,
... ... @@ -2601,11 +2858,12 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
'state': point.state.value,
'data_time': point.dataTime,
'uploaded': point.uploaded ? 1 : 0,
'update_time': _currentUnixSeconds(),
'update_time': updateTime,
'upload_time': point.uploadTime,
};
}
Map<String, Object?> _sleepRow(HealthRawSleepResult result) {
Map<String, Object?> _sleepRow(HealthRawSleepResult result, int updateTime) {
return <String, Object?>{
'date': result.date,
'user_id': result.userId,
... ... @@ -2616,7 +2874,8 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
'awak_minutes': result.awakMinutes,
'sleep_minutes': result.sleepMinutes,
'uploaded': result.uploaded ? 1 : 0,
'update_time': _currentUnixSeconds(),
'update_time': updateTime,
'upload_time': result.uploadTime,
};
}
... ... @@ -2683,6 +2942,7 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
'is_sleep_likely': row['is_sleep_likely'],
'is_suspected_activity': row['is_suspected_activity'],
'uploaded': uploadPayloadChanged ? 0 : existingRow['uploaded'],
'upload_time': uploadPayloadChanged ? null : existingRow['upload_time'],
'update_time': row['update_time'],
},
where: 'raw_end_time = ?',
... ... @@ -2697,6 +2957,7 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
final isToday = row['date'] == _todayDateKey();
if (isToday) {
row['uploaded'] = 0;
row['upload_time'] = null;
}
final existing = await db.query(
dailyStressResultsTable,
... ... @@ -2723,6 +2984,8 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
'state': row['state'],
'data_time': row['data_time'],
'uploaded': isToday ? 0 : (valueChanged ? 0 : existingRow['uploaded']),
'upload_time':
isToday || valueChanged ? null : existingRow['upload_time'],
'update_time': row['update_time'],
},
where: 'date = ?',
... ... @@ -2769,6 +3032,7 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
'awak_minutes': row['awak_minutes'],
'sleep_minutes': row['sleep_minutes'],
'uploaded': valueChanged ? 0 : existingRow['uploaded'],
'upload_time': valueChanged ? null : existingRow['upload_time'],
'update_time': row['update_time'],
},
where: 'date = ?',
... ...
... ... @@ -35,6 +35,8 @@ enum HealthRawLocalNotificationRecordType {
realtimeStress,
}
typedef HealthRawSleepInterval = ({int startTime, int endTime});
class HealthRawLocalNotificationBuilder {
const HealthRawLocalNotificationBuilder(this.l10n);
... ... @@ -49,20 +51,31 @@ class HealthRawLocalNotificationBuilder {
required bool hasExistingSleep,
required List<HealthRawRealtimeStressPoint> realtimeWindow,
required HealthRawLocalNotificationRecord? record,
List<HealthRawSleepInterval> realtimeSleepIntervals = const [],
int? lastRealtimeStressPushSendTime,
int? notificationBuildTime,
}) {
final buildTime =
notificationBuildTime ?? DateTime.now().millisecondsSinceEpoch ~/ 1000;
final latestRealtimeStressPoint = _latestRealtimeStressPoint(
result.realtimeStressPoints,
);
final resolvedRealtimeSleepIntervals =
realtimeSleepIntervals.isEmpty && latestRealtimeStressPoint != null
? [_defaultSleepInterval(latestRealtimeStressPoint.rawEndTime)]
: realtimeSleepIntervals;
return [
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,
... ... @@ -89,33 +102,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 +140,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 +183,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;
... ...
import 'dart:convert';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
class HealthRawLocalNotificationDebugStore {
HealthRawLocalNotificationDebugStore({Directory? rootDirectory})
: _rootDirectory = rootDirectory;
static const _fileName = 'health_raw_local_notification_debug_events.json';
static const _maxEventCount = 500;
final Directory? _rootDirectory;
Future<void> append(Map<String, Object?> event) async {
final file = await _file();
await file.parent.create(recursive: true);
final eventsByMinute = await _readEventsByMinute(file);
final now = DateTime.now();
final minuteKey = _minuteKey(now);
final enrichedEvent = <String, Object?>{
..._sanitizeMap(event),
'saved_at': now.toIso8601String(),
'saved_at_readable': _readableTime(now),
'saved_at_minute': minuteKey,
'saved_at_unix': now.millisecondsSinceEpoch / 1000,
};
eventsByMinute.putIfAbsent(minuteKey, () => <Map<String, Object?>>[]);
eventsByMinute[minuteKey]!.add(enrichedEvent);
final limitedEventsByMinute = _limitedEventsByMinute(eventsByMinute);
const encoder = JsonEncoder.withIndent(' ');
await file.writeAsString(
encoder.convert(limitedEventsByMinute),
flush: true,
);
}
Future<String> readText() async {
final file = await _file();
if (!await file.exists()) {
return '暂无本地推送诊断日志';
}
final text = await file.readAsString();
if (text.trim().isEmpty) {
return '暂无本地推送诊断日志';
}
return text;
}
Future<File> _file() async {
final rootDirectory =
_rootDirectory ?? await getApplicationDocumentsDirectory();
return File('${rootDirectory.path}/$_fileName');
}
Future<Map<String, List<Map<String, Object?>>>> _readEventsByMinute(
File file,
) async {
if (!await file.exists()) {
return <String, List<Map<String, Object?>>>{};
}
final text = await file.readAsString();
if (text.trim().isEmpty) {
return <String, List<Map<String, Object?>>>{};
}
final json = jsonDecode(text);
if (json is Map) {
return json.map((key, value) {
final events = value is List
? value
.whereType<Map>()
.map((e) => _sanitizeMap(e.cast<String, Object?>()))
.toList()
: <Map<String, Object?>>[];
return MapEntry(key.toString(), events);
});
}
if (json is List) {
final events = json
.whereType<Map>()
.map((e) => _sanitizeMap(e.cast<String, Object?>()))
.toList();
final grouped = <String, List<Map<String, Object?>>>{};
for (final event in events) {
final key =
(event['saved_at_minute'] ?? event['saved_at_readable'] ?? 'legacy')
.toString();
grouped.putIfAbsent(key, () => <Map<String, Object?>>[]);
grouped[key]!.add(event);
}
return grouped;
}
return <String, List<Map<String, Object?>>>{};
}
Map<String, List<Map<String, Object?>>> _limitedEventsByMinute(
Map<String, List<Map<String, Object?>>> eventsByMinute,
) {
final events = eventsByMinute.values.expand((e) => e).toList()
..sort((a, b) {
final aTime = (a['saved_at_unix'] as num?)?.toDouble() ?? 0;
final bTime = (b['saved_at_unix'] as num?)?.toDouble() ?? 0;
return aTime.compareTo(bTime);
});
final limitedEvents = events.length > _maxEventCount
? events.sublist(events.length - _maxEventCount)
: events;
final grouped = <String, List<Map<String, Object?>>>{};
for (final event in limitedEvents) {
final key =
(event['saved_at_minute'] ?? event['saved_at_readable'] ?? 'unknown')
.toString();
grouped.putIfAbsent(key, () => <Map<String, Object?>>[]);
grouped[key]!.add(event);
}
return grouped;
}
static Map<String, Object?> _sanitizeMap(Map<String, Object?> map) {
return map.map((key, value) => MapEntry(key, _sanitizeValue(value)));
}
static Object? _sanitizeValue(Object? value) {
return switch (value) {
null => null,
String() => value,
num() => value,
bool() => value,
DateTime() => value.toIso8601String(),
List() => value.map(_sanitizeValue).toList(),
Map() => value.map(
(key, value) => MapEntry(key.toString(), _sanitizeValue(value)),
),
_ => value.toString(),
};
}
static String _minuteKey(DateTime time) {
return '${time.year.toString().padLeft(4, '0')}-'
'${time.month.toString().padLeft(2, '0')}-'
'${time.day.toString().padLeft(2, '0')} '
'${time.hour.toString().padLeft(2, '0')}:'
'${time.minute.toString().padLeft(2, '0')}';
}
static String _readableTime(DateTime time) {
return '${time.year.toString().padLeft(4, '0')}-'
'${time.month.toString().padLeft(2, '0')}-'
'${time.day.toString().padLeft(2, '0')} '
'${time.hour.toString().padLeft(2, '0')}:'
'${time.minute.toString().padLeft(2, '0')}:'
'${time.second.toString().padLeft(2, '0')}';
}
}
... ... @@ -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(
... ... @@ -768,7 +773,7 @@ void main() {
expect(
notificationDispatcher.sentNotifications.where(
(e) => e.recordType == HealthRawLocalNotificationRecordType.hrv),
hasLength(1),
hasLength(3),
);
expect(
notificationDispatcher.sentNotifications.where(
... ... @@ -779,6 +784,25 @@ void main() {
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, base + 720]
.contains(row['raw_end_time']))
.every((row) => row['push_send_time'] != null),
isTrue,
);
expect(
rows.sleepRows.singleWhere(
(row) => row['date'] == LocalHealthDataConvert.unixSeconds(sleepEnd),
)['push_send_time'],
isNotNull,
);
});
test(
... ... @@ -887,6 +911,127 @@ void main() {
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(
... ... @@ -1017,6 +1162,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 {
... ... @@ -1055,6 +1204,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);
... ... @@ -1063,6 +1214,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 {
... ... @@ -1354,8 +1507,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) {
... ... @@ -1382,6 +1543,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,
... ... @@ -1403,10 +1565,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));
... ... @@ -1429,10 +1595,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));
... ... @@ -1444,6 +1614,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,
... ... @@ -1464,9 +1635,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));
... ... @@ -1477,14 +1652,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));
... ... @@ -1614,6 +1814,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;
... ... @@ -1633,8 +1915,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,
... ... @@ -1649,6 +1934,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
baselineRestingHr: point.baselineRestingHr,
flags: point.flags,
uploaded: true,
pushSendTime: point.pushSendTime,
uploadTime: uploadTime,
);
}).toList();
}
... ... @@ -1670,9 +1957,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,
... ... @@ -1682,6 +1972,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
sourceEndTime: point.sourceEndTime,
flags: point.flags,
uploaded: true,
pushSendTime: point.pushSendTime,
uploadTime: uploadTime,
);
}).toList();
}
... ... @@ -1702,9 +1994,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,
... ... @@ -1713,6 +2008,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
state: point.state,
dataTime: point.dataTime,
uploaded: true,
uploadTime: uploadTime,
);
}).toList();
}
... ... @@ -1722,8 +2018,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,
... ... @@ -1734,6 +2033,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
awakMinutes: result.awakMinutes,
sleepMinutes: result.sleepMinutes,
uploaded: true,
pushSendTime: result.pushSendTime,
uploadTime: uploadTime,
);
}).toList();
}
... ... @@ -1813,6 +2114,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,
};
}
... ... @@ -1830,6 +2134,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,
};
}
... ... @@ -1843,6 +2150,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,
};
}
... ... @@ -1858,11 +2167,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,30 @@ void main() {
expect(notifications.single.recordTime, latestTime);
});
test('builds hrv notifications for every candidate including sleep likely',
() {
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(2));
expect(notifications.map((e) => e.recordTime), [firstTime, secondTime]);
});
test('builds realtime stress notification from latest 60 minute window', () {
final base = _seconds(DateTime(2026, 1, 1, 9));
final notifications = builder.build(
... ... @@ -208,6 +232,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(
... ... @@ -339,7 +495,10 @@ void main() {
int _seconds(DateTime time) => time.millisecondsSinceEpoch ~/ 1000;
HealthRawHrvStressPoint _hrvPoint(int rawEndTime) {
HealthRawHrvStressPoint _hrvPoint(
int rawEndTime, {
bool isSleepLikely = false,
}) {
return HealthRawHrvStressPoint(
userId: 1,
rawEndTime: rawEndTime,
... ... @@ -352,6 +511,12 @@ HealthRawHrvStressPoint _hrvPoint(int rawEndTime) {
baselineAwakeHrv: 30,
baselineSleepHrv: null,
baselineRestingHr: 60,
flags: HealthRawPointFlags(
isSleepLikely: isSleepLikely,
isWorkout: false,
isWorkoutRecovery: false,
isSuspectedActivity: false,
),
);
}
... ...