Commit 2a66a694034c6fc1ca97a4bfdba49cf466aa29a1

Authored by 权海
1 parent 2ad10b3d

feat(ui):增加本地通知日志系统

... ... @@ -396,10 +396,14 @@ protocol HealthKitRawDataHostApi {
/// caculate 一旦完成,让native 存储这次的计算记录
/// 需要保存引起此次计算的数据变化类型列表、当前时间
func saveFlutterCaculateFinished(relativeDataTypes: [Int64], completion: @escaping (Result<Bool, Error>) -> Void)
/// 让原生保存本地通知记录
func saveLocalNotificationRecord(notificationType: String, timestamp: Int64, completion: @escaping (Result<Bool, Error>) -> Void)
/// 让原生分享出来本地的记录文件
func shareNativeAppleHealthObserverRecord(completion: @escaping (Result<Bool, Error>) -> Void)
func saveLocalNotificationDebugEvent(event: [String: Any?], completion: @escaping (Result<Bool, Error>) -> Void)
/// 让原生分享出来flutter的记录文件
func shareFlutterObserverRecord(completion: @escaping (Result<Bool, Error>) -> Void)
func shareLocalNotificationDebugRecord(completion: @escaping (Result<Bool, Error>) -> Void)
/// 让原生分享出来上传记录
func shareUploadTaskRecord(completion: @escaping (Result<Bool, Error>) -> Void)
}
... ... @@ -650,6 +654,25 @@ class HealthKitRawDataHostApiSetup {
} else {
saveFlutterCaculateFinishedChannel.setMessageHandler(nil)
}
/// 让原生保存本地通知记录
let saveLocalNotificationRecordChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.saveLocalNotificationRecord\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
saveLocalNotificationRecordChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let notificationTypeArg = args[0] as! String
let timestampArg = args[1] as! Int64
api.saveLocalNotificationRecord(notificationType: notificationTypeArg, timestamp: timestampArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
saveLocalNotificationRecordChannel.setMessageHandler(nil)
}
/// 让原生分享出来本地的记录文件
let shareNativeAppleHealthObserverRecordChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.shareNativeAppleHealthObserverRecord\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
... ... @@ -666,6 +689,23 @@ class HealthKitRawDataHostApiSetup {
} else {
shareNativeAppleHealthObserverRecordChannel.setMessageHandler(nil)
}
let saveLocalNotificationDebugEventChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.saveLocalNotificationDebugEvent\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
saveLocalNotificationDebugEventChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let eventArg = args[0] as! [String: Any?]
api.saveLocalNotificationDebugEvent(event: eventArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
saveLocalNotificationDebugEventChannel.setMessageHandler(nil)
}
/// 让原生分享出来flutter的记录文件
let shareFlutterObserverRecordChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.shareFlutterObserverRecord\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
... ... @@ -682,6 +722,21 @@ class HealthKitRawDataHostApiSetup {
} else {
shareFlutterObserverRecordChannel.setMessageHandler(nil)
}
let shareLocalNotificationDebugRecordChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.shareLocalNotificationDebugRecord\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
shareLocalNotificationDebugRecordChannel.setMessageHandler { _, reply in
api.shareLocalNotificationDebugRecord { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
shareLocalNotificationDebugRecordChannel.setMessageHandler(nil)
}
/// 让原生分享出来上传记录
let shareUploadTaskRecordChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.shareUploadTaskRecord\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
... ...
... ... @@ -25,6 +25,7 @@ enum DeveloperOptionsAction {
shareNativeAppleHealthObserverRecord,
shareFlutterObserverRecord,
shareUploadTaskRecord,
shareLocalNotificationDebugRecord,
clearHealthRawDataDatabaseAndUploadLog,
}
... ... @@ -73,6 +74,10 @@ class DeveloperOptionsController extends GetxController {
action: DeveloperOptionsAction.shareUploadTaskRecord,
),
DeveloperOptionsItem(
title: '分享本地推送诊断日志',
action: DeveloperOptionsAction.shareLocalNotificationDebugRecord,
),
DeveloperOptionsItem(
title: '清除本地数据库、上传日志',
action: DeveloperOptionsAction.clearHealthRawDataDatabaseAndUploadLog,
),
... ... @@ -107,6 +112,9 @@ class DeveloperOptionsController extends GetxController {
case DeveloperOptionsAction.shareUploadTaskRecord:
await shareUploadTaskRecord();
break;
case DeveloperOptionsAction.shareLocalNotificationDebugRecord:
await shareLocalNotificationDebugRecord();
break;
case DeveloperOptionsAction.clearHealthRawDataDatabaseAndUploadLog:
await clearHealthRawDataDatabaseAndUploadLog();
break;
... ... @@ -174,6 +182,24 @@ class DeveloperOptionsController extends GetxController {
}
}
Future<void> shareLocalNotificationDebugRecord() async {
try {
final sharedByNative =
await _healthRawDataCoreService.shareLocalNotificationDebugRecord();
if (sharedByNative) return;
final text =
await _healthRawDataCoreService.readLocalNotificationDebugLogText();
await _platformHostApi.shareText(text);
} catch (error, stackTrace) {
AppLogger.e(
'Share local notification debug record failed',
error,
stackTrace,
);
Get.snackbar('Share failed', error.toString());
}
}
Future<void> clearHealthRawDataDatabaseAndUploadLog() async {
try {
await _healthRawDataCoreService.clearLocalDatabaseAndUploadLog();
... ...
... ... @@ -16,6 +16,7 @@ import '../config/app_environment_config.dart';
import '../logging/app_logger.dart';
import '../network/api/health_api.dart';
import '../result/app_result.dart';
import 'health_raw_local_notification_debug_store.dart';
import 'health_raw_local_notification.dart';
import 'health_raw_stress_calculator.dart';
import 'health_sleep_calculator.dart';
... ... @@ -32,6 +33,7 @@ class HealthRawDataCoreService {
int Function()? userIdProvider,
bool uploadResultsAfterCalculation = true,
HealthRawLocalNotificationDispatcher? localNotificationDispatcher,
HealthRawLocalNotificationDebugStore? localNotificationDebugStore,
HealthApi? serverHealthApi,
}) : _healthApi = healthApi ?? HealthKitHostApi(),
_rawDataApi = rawDataApi ?? HealthKitRawDataHostApi(),
... ... @@ -41,6 +43,8 @@ class HealthRawDataCoreService {
_uploadResultsAfterCalculation = uploadResultsAfterCalculation,
_localNotificationDispatcher = localNotificationDispatcher ??
HealthRawLocalNotificationDispatcher(),
_localNotificationDebugStore = localNotificationDebugStore ??
HealthRawLocalNotificationDebugStore(),
_serverHealthApi = serverHealthApi;
final HealthKitHostApi _healthApi;
... ... @@ -50,6 +54,7 @@ class HealthRawDataCoreService {
final int Function()? _userIdProvider;
final bool _uploadResultsAfterCalculation;
final HealthRawLocalNotificationDispatcher _localNotificationDispatcher;
final HealthRawLocalNotificationDebugStore _localNotificationDebugStore;
final HealthApi? _serverHealthApi;
final StreamController<HealthRawDataUpdatedEvent>
_healthDataUpdatedController =
... ... @@ -187,6 +192,19 @@ class HealthRawDataCoreService {
return _rawDataApi.shareUploadTaskRecord();
}
Future<String> readLocalNotificationDebugLogText() {
return _localNotificationDebugStore.readText(isDebug: _isDebug);
}
Future<bool> shareLocalNotificationDebugRecord() async {
if (!_isDebug) return false;
return _shareNativeLocalNotificationDebugRecord();
}
Future<bool> _shareNativeLocalNotificationDebugRecord() async {
return _rawDataApi.shareLocalNotificationDebugRecord();
}
Future<HealthRawStressCalculationResult> startCoreCaculate({
int? endTime,
int readChunkDays = defaultReadChunkDays,
... ... @@ -399,9 +417,30 @@ class HealthRawDataCoreService {
required HealthRawStressCalculationResult result,
required bool hasExistingHrv,
}) async {
await _saveLocalNotificationDebugEvent(
<String, Object?>{
'event': 'flutterNotificationCalculationFinished',
'user_id': result.userId,
'has_existing_hrv': hasExistingHrv,
'hrv_count': result.hrvStressPoints.length,
'realtime_count': result.realtimeStressPoints.length,
'sleep_count': result.sleepResults.length,
'latest_hrv': _latestHrvStressPointPayload(result.hrvStressPoints),
'latest_realtime':
_latestRealtimeStressPointPayload(result.realtimeStressPoints),
'latest_sleep': _latestSleepResultPayload(result.sleepResults),
},
);
if (result.hrvStressPoints.isEmpty &&
result.realtimeStressPoints.isEmpty &&
result.sleepResults.isEmpty) {
await _saveLocalNotificationDebugEvent(
<String, Object?>{
'event': 'flutterNotificationNotBuilt',
'reason': 'empty_calculation_result',
'user_id': result.userId,
},
);
return;
}
... ... @@ -417,6 +456,15 @@ class HealthRawDataCoreService {
latestRealtimeStressPoint.isWorkout ||
latestRealtimeStressPoint.isWorkoutRecovery;
if (shouldResetRealtimeStressPushTime) {
await _saveLocalNotificationDebugEvent(
<String, Object?>{
'event': 'flutterRealtimeStressSuppressedBeforeBuild',
'reason': 'workout_or_recovery',
'user_id': result.userId,
'latest_realtime':
_realtimeStressPointPayload(latestRealtimeStressPoint),
},
);
await _recordRealtimeStressTimeSafely(
userId: result.userId,
recordTime: latestRealtimeStressPoint.rawEndTime,
... ... @@ -433,20 +481,72 @@ class HealthRawDataCoreService {
}
final record = await _readLocalNotificationRecordSafely(result.userId);
await _saveLocalNotificationDebugEvent(
<String, Object?>{
'event': 'flutterNotificationRecordRead',
'user_id': result.userId,
'record': _localNotificationRecordPayload(record),
},
);
await _saveLocalNotificationDebugEvent(
<String, Object?>{
'event': 'flutterNotificationBuildDecisions',
'user_id': result.userId,
'decisions': _localNotificationDecisionPayloads(
result: notificationResult,
hasExistingHrv: hasExistingHrv,
realtimeWindow: realtimeWindow,
record: record,
),
},
);
final notifications = HealthRawLocalNotificationBuilder(l10n).build(
result: notificationResult,
hasExistingHrv: hasExistingHrv,
realtimeWindow: realtimeWindow,
record: record,
);
if (notifications.isEmpty) return;
await _saveLocalNotificationDebugEvent(
<String, Object?>{
'event': 'flutterNotificationBuildFinished',
'user_id': result.userId,
'notification_count': notifications.length,
'notifications': notifications.map(_localNotificationPayload).toList(),
},
);
if (notifications.isEmpty) {
await _saveLocalNotificationDebugEvent(
<String, Object?>{
'event': 'flutterNotificationNotSent',
'reason': 'no_built_notifications',
'user_id': result.userId,
},
);
return;
}
final sentNotifications = <HealthRawLocalNotification>[];
for (final notification in notifications) {
await _saveLocalNotificationDebugEvent(
<String, Object?>{
'event': 'flutterNotificationSendStart',
'user_id': result.userId,
'notification': _localNotificationPayload(notification),
},
);
final sent = await _sendLocalNotificationSafely(
userId: result.userId,
notification: notification,
);
await _saveLocalNotificationDebugEvent(
<String, Object?>{
'event': 'flutterNotificationSendFinished',
'user_id': result.userId,
'sent': sent,
'reason': sent ? null : 'native_or_dispatcher_returned_false',
'notification': _localNotificationPayload(notification),
},
);
if (sent) {
sentNotifications.add(notification);
}
... ... @@ -485,17 +585,38 @@ class HealthRawDataCoreService {
required int latestRawEndTime,
}) async {
try {
return await _localStore.queryRealtimeStressPoints(
final points = await _localStore.queryRealtimeStressPoints(
userId: userId,
startTime: latestRawEndTime - Duration.secondsPerHour + 1,
endTime: latestRawEndTime,
);
await _saveLocalNotificationDebugEvent(
<String, Object?>{
'event': 'flutterRealtimeStressWindowQueried',
'user_id': userId,
'latest_raw_end_time': latestRawEndTime,
'window_start_time': latestRawEndTime - Duration.secondsPerHour + 1,
'window_count': points.length,
'valid_window_count':
points.where((e) => e.result >= 1 && e.result <= 100).length,
},
);
return points;
} catch (error, stackTrace) {
_logError(
'query realtime stress notification window failed',
error,
stackTrace,
);
await _saveLocalNotificationDebugEvent(
<String, Object?>{
'event': 'flutterRealtimeStressWindowQueryFailed',
'user_id': userId,
'latest_raw_end_time': latestRawEndTime,
'error': error.toString(),
'stack_trace': stackTrace.toString(),
},
);
return const <HealthRawRealtimeStressPoint>[];
}
}
... ... @@ -511,10 +632,285 @@ class HealthRawDataCoreService {
);
} catch (error, stackTrace) {
_logError('send local health notification failed', error, stackTrace);
await _saveLocalNotificationDebugEvent(
<String, Object?>{
'event': 'flutterNotificationSendError',
'user_id': userId,
'notification': _localNotificationPayload(notification),
'error': error.toString(),
'stack_trace': stackTrace.toString(),
},
);
return false;
}
}
List<Map<String, Object?>> _localNotificationDecisionPayloads({
required HealthRawStressCalculationResult result,
required bool hasExistingHrv,
required List<HealthRawRealtimeStressPoint> realtimeWindow,
required HealthRawLocalNotificationRecord record,
}) {
return <Map<String, Object?>>[
_sleepNotificationDecisionPayload(result.sleepResults),
_hrvNotificationDecisionPayload(
result.hrvStressPoints,
hasExistingHrv: hasExistingHrv,
record: record,
),
_realtimeStressNotificationDecisionPayload(
result.realtimeStressPoints,
realtimeWindow: realtimeWindow,
record: record,
),
];
}
Map<String, Object?> _sleepNotificationDecisionPayload(
List<HealthRawSleepResult> sleepResults,
) {
if (sleepResults.isEmpty) {
return <String, Object?>{
'type': HealthRawLocalNotificationRecordType.sleep.name,
'will_build': false,
'reason': 'no_sleep_results',
};
}
final sorted = [...sleepResults]..sort((a, b) => a.date.compareTo(b.date));
final latest = sorted.last;
final invalidState = !<int>{1, 2, 3}.contains(latest.sleepState);
if (invalidState) {
return <String, Object?>{
'type': HealthRawLocalNotificationRecordType.sleep.name,
'will_build': false,
'reason': 'invalid_sleep_state',
'latest_sleep': _sleepResultPayload(latest),
};
}
if (latest.sleepMinutes <= 0) {
return <String, Object?>{
'type': HealthRawLocalNotificationRecordType.sleep.name,
'will_build': false,
'reason': 'invalid_sleep_minutes',
'latest_sleep': _sleepResultPayload(latest),
};
}
return <String, Object?>{
'type': HealthRawLocalNotificationRecordType.sleep.name,
'will_build': true,
'reason': 'candidate',
'latest_sleep': _sleepResultPayload(latest),
};
}
Map<String, Object?> _hrvNotificationDecisionPayload(
List<HealthRawHrvStressPoint> hrvPoints, {
required bool hasExistingHrv,
required HealthRawLocalNotificationRecord record,
}) {
if (!hasExistingHrv) {
return <String, Object?>{
'type': HealthRawLocalNotificationRecordType.hrv.name,
'will_build': false,
'reason': 'first_calculation_no_existing_hrv',
'has_existing_hrv': hasExistingHrv,
};
}
if (hrvPoints.isEmpty) {
return <String, Object?>{
'type': HealthRawLocalNotificationRecordType.hrv.name,
'will_build': false,
'reason': 'no_new_hrv_points',
'has_existing_hrv': hasExistingHrv,
};
}
final sorted = [...hrvPoints]
..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime));
final latest = sorted.last;
if (record.lastHrvTime == latest.rawEndTime) {
return <String, Object?>{
'type': HealthRawLocalNotificationRecordType.hrv.name,
'will_build': false,
'reason': 'duplicate_hrv_record_time',
'last_hrv_time': record.lastHrvTime,
'latest_hrv': _hrvStressPointPayload(latest),
};
}
return <String, Object?>{
'type': HealthRawLocalNotificationRecordType.hrv.name,
'will_build': true,
'reason': 'candidate',
'last_hrv_time': record.lastHrvTime,
'latest_hrv': _hrvStressPointPayload(latest),
};
}
Map<String, Object?> _realtimeStressNotificationDecisionPayload(
List<HealthRawRealtimeStressPoint> realtimePoints, {
required List<HealthRawRealtimeStressPoint> realtimeWindow,
required HealthRawLocalNotificationRecord record,
}) {
if (realtimePoints.isEmpty) {
return <String, Object?>{
'type': HealthRawLocalNotificationRecordType.realtimeStress.name,
'will_build': false,
'reason': 'no_realtime_points',
};
}
final latest = realtimePoints.reduce(
(a, b) => a.rawEndTime >= b.rawEndTime ? a : b,
);
if (latest.isWorkout || latest.isWorkoutRecovery) {
return <String, Object?>{
'type': HealthRawLocalNotificationRecordType.realtimeStress.name,
'will_build': false,
'reason': 'workout_or_recovery',
'latest_realtime': _realtimeStressPointPayload(latest),
};
}
final valid = realtimeWindow
.where((e) => e.result >= 1 && e.result <= 100)
.toList()
..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime));
if (valid.length < 10) {
return <String, Object?>{
'type': HealthRawLocalNotificationRecordType.realtimeStress.name,
'will_build': false,
'reason': 'insufficient_valid_realtime_points',
'valid_window_count': valid.length,
'latest_realtime': _realtimeStressPointPayload(latest),
};
}
if (latest.isSleepLikely) {
return <String, Object?>{
'type': HealthRawLocalNotificationRecordType.realtimeStress.name,
'will_build': false,
'reason': 'sleep_likely',
'latest_realtime': _realtimeStressPointPayload(latest),
};
}
final lastPushTime = record.lastRealtimeStressTime;
if (lastPushTime != null &&
latest.rawEndTime - lastPushTime < Duration.secondsPerHour) {
return <String, Object?>{
'type': HealthRawLocalNotificationRecordType.realtimeStress.name,
'will_build': false,
'reason': 'within_realtime_interval',
'last_realtime_stress_time': lastPushTime,
'latest_realtime': _realtimeStressPointPayload(latest),
};
}
return <String, Object?>{
'type': HealthRawLocalNotificationRecordType.realtimeStress.name,
'will_build': true,
'reason': 'candidate',
'last_realtime_stress_time': lastPushTime,
'valid_window_count': valid.length,
'latest_realtime': _realtimeStressPointPayload(latest),
};
}
Map<String, Object?> _localNotificationPayload(
HealthRawLocalNotification notification,
) {
return <String, Object?>{
'type': notification.recordType.name,
'record_time': notification.recordTime,
'title': notification.title,
'content': notification.content,
'link': notification.link,
'current_state': notification.currentState,
};
}
Map<String, Object?> _localNotificationRecordPayload(
HealthRawLocalNotificationRecord record,
) {
return <String, Object?>{
'last_sleep_time': record.lastSleepTime,
'last_hrv_time': record.lastHrvTime,
'last_realtime_stress_time': record.lastRealtimeStressTime,
};
}
Map<String, Object?>? _latestHrvStressPointPayload(
List<HealthRawHrvStressPoint> points,
) {
if (points.isEmpty) return null;
return _hrvStressPointPayload(
points.reduce((a, b) => a.rawEndTime >= b.rawEndTime ? a : b),
);
}
Map<String, Object?> _hrvStressPointPayload(HealthRawHrvStressPoint point) {
return <String, Object?>{
'raw_end_time': point.rawEndTime,
'raw_hrv': point.rawHrv,
'result': point.result,
'state': point.state.name,
'state_value': point.state.value,
'baseline_hrv': point.baselineHrv,
'baseline_awake_hrv': point.baselineAwakeHrv,
'baseline_sleep_hrv': point.baselineSleepHrv,
'uploaded': point.uploaded,
'flags': _pointFlagsPayload(point.flags),
};
}
Map<String, Object?>? _latestRealtimeStressPointPayload(
List<HealthRawRealtimeStressPoint> points,
) {
if (points.isEmpty) return null;
return _realtimeStressPointPayload(
points.reduce((a, b) => a.rawEndTime >= b.rawEndTime ? a : b),
);
}
Map<String, Object?> _realtimeStressPointPayload(
HealthRawRealtimeStressPoint point,
) {
return <String, Object?>{
'raw_end_time': point.rawEndTime,
'raw_hr': point.rawHr,
'result': point.result,
'state': point.state.name,
'state_value': point.state.value,
'uploaded': point.uploaded,
'flags': _pointFlagsPayload(point.flags),
};
}
Map<String, Object?>? _latestSleepResultPayload(
List<HealthRawSleepResult> sleepResults,
) {
if (sleepResults.isEmpty) return null;
final sorted = [...sleepResults]..sort((a, b) => a.date.compareTo(b.date));
return _sleepResultPayload(sorted.last);
}
Map<String, Object?> _sleepResultPayload(HealthRawSleepResult result) {
return <String, Object?>{
'date': result.date,
'start_date': result.startDate,
'sleep_score': result.sleepScore,
'sleep_state': result.sleepState,
'in_bed_minutes': result.inBedMinutes,
'awak_minutes': result.awakMinutes,
'sleep_minutes': result.sleepMinutes,
'uploaded': result.uploaded,
};
}
Map<String, Object?> _pointFlagsPayload(HealthRawPointFlags flags) {
return <String, Object?>{
'is_workout': flags.isWorkout,
'is_workout_recovery': flags.isWorkoutRecovery,
'is_sleep_likely': flags.isSleepLikely,
'is_suspected_activity': flags.isSuspectedActivity,
'context': flags.context.name,
};
}
void _scheduleRealtimeStressServerPush(
Iterable<HealthRawLocalNotification> sentNotifications,
) {
... ... @@ -938,6 +1334,25 @@ class HealthRawDataCoreService {
}
}
Future<void> _saveLocalNotificationDebugEvent(
Map<String, Object?> event,
) async {
if (!_isDebug) return;
try {
await _localNotificationDebugStore.append(event);
await _saveNativeLocalNotificationDebugEvent(event);
} catch (error, stackTrace) {
_logError(
'save local notification debug event failed', error, stackTrace);
}
}
Future<bool> _saveNativeLocalNotificationDebugEvent(
Map<String, Object?> event,
) async {
return _rawDataApi.saveLocalNotificationDebugEvent(event: event);
}
Future<File> _uploadApiLogFile() async {
final dir = await getApplicationDocumentsDirectory();
return File('${dir.path}/health_raw_upload_debug.log');
... ...
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({required bool isDebug}) async {
if (!isDebug) {
return '当前非 Debug 模式,未记录本地推送诊断日志';
}
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')}';
}
}
... ...
... ... @@ -764,6 +764,35 @@ class HealthKitRawDataHostApi {
}
}
/// 让原生保存本地通知记录
Future<bool> saveLocalNotificationRecord({required String notificationType, required int timestamp}) async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.saveLocalNotificationRecord$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[notificationType, timestamp]);
final List<Object?>? pigeonVar_replyList =
await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else if (pigeonVar_replyList[0] == null) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
} else {
return (pigeonVar_replyList[0] as bool?)!;
}
}
/// 让原生分享出来本地的记录文件
Future<bool> shareNativeAppleHealthObserverRecord() async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.shareNativeAppleHealthObserverRecord$pigeonVar_messageChannelSuffix';
... ... @@ -793,6 +822,34 @@ class HealthKitRawDataHostApi {
}
}
Future<bool> saveLocalNotificationDebugEvent({required Map<String, Object?> event}) async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.saveLocalNotificationDebugEvent$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[event]);
final List<Object?>? pigeonVar_replyList =
await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else if (pigeonVar_replyList[0] == null) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
} else {
return (pigeonVar_replyList[0] as bool?)!;
}
}
/// 让原生分享出来flutter的记录文件
Future<bool> shareFlutterObserverRecord() async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.shareFlutterObserverRecord$pigeonVar_messageChannelSuffix';
... ... @@ -822,6 +879,34 @@ class HealthKitRawDataHostApi {
}
}
Future<bool> shareLocalNotificationDebugRecord() async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.shareLocalNotificationDebugRecord$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final List<Object?>? pigeonVar_replyList =
await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else if (pigeonVar_replyList[0] == null) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
} else {
return (pigeonVar_replyList[0] as bool?)!;
}
}
/// 让原生分享出来上传记录
Future<bool> shareUploadTaskRecord() async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.shareUploadTaskRecord$pigeonVar_messageChannelSuffix';
... ...
... ... @@ -155,14 +155,25 @@ abstract class HealthKitRawDataHostApi {
@async
bool saveFlutterCaculateFinished({required List<int> relativeDataTypes});
/// 让原生保存本地通知记录
@async
bool saveLocalNotificationRecord(
{required String notificationType, required int timestamp});
/// 让原生分享出来本地的记录文件
@async
bool shareNativeAppleHealthObserverRecord();
@async
bool saveLocalNotificationDebugEvent({required Map<String, Object?> event});
/// 让原生分享出来flutter的记录文件
@async
bool shareFlutterObserverRecord();
@async
bool shareLocalNotificationDebugRecord();
/// 让原生分享出来上传记录
@async
bool shareUploadTaskRecord();
... ...