|
...
|
...
|
@@ -7,7 +7,7 @@ import 'package:path_provider/path_provider.dart'; |
|
|
|
|
|
|
|
import 'app_logger.dart';
|
|
|
|
|
|
|
|
/// Persists a compact, privacy-safe timeline of the OHOS health data pipeline.
|
|
|
|
/// Persists a privacy-safe timeline of the OHOS health data pipeline.
|
|
|
|
///
|
|
|
|
/// Writes are queued and intentionally fire-and-forget so health callbacks,
|
|
|
|
/// calculation, and push delivery are never blocked by file I/O. Call
|
|
...
|
...
|
@@ -18,9 +18,10 @@ class OhosHealthFlowLogStore { |
|
|
|
|
|
|
|
static final OhosHealthFlowLogStore instance = OhosHealthFlowLogStore();
|
|
|
|
|
|
|
|
static const fileName = 'ohos_health_flow_events.json';
|
|
|
|
static const _retention = Duration(days: 7);
|
|
|
|
static const _maxEventCount = 1000;
|
|
|
|
static const _fileNamePrefix = 'ohos_health_flow_events_';
|
|
|
|
static const _fileNameSuffix = '.json';
|
|
|
|
static const _legacyFileName = 'ohos_health_flow_events.json';
|
|
|
|
static const _retainedDays = 7;
|
|
|
|
|
|
|
|
final Directory? _rootDirectory;
|
|
|
|
Future<void> _writeQueue = Future<void>.value();
|
|
...
|
...
|
@@ -49,18 +50,15 @@ class OhosHealthFlowLogStore { |
|
|
|
if (details != null && details.isNotEmpty)
|
|
|
|
'details': _sanitizeMap(details),
|
|
|
|
};
|
|
|
|
final file = await _file();
|
|
|
|
await file.parent.create(recursive: true);
|
|
|
|
final cutoff = now.subtract(_retention);
|
|
|
|
final events = (await _readEvents(file))
|
|
|
|
.where((item) => _isWithinRetention(item, cutoff))
|
|
|
|
.toList()
|
|
|
|
..add(event);
|
|
|
|
final retained = events.length > _maxEventCount
|
|
|
|
? events.sublist(events.length - _maxEventCount)
|
|
|
|
: events;
|
|
|
|
final root = await _root();
|
|
|
|
await root.create(recursive: true);
|
|
|
|
await _migrateLegacyFile(root, now);
|
|
|
|
await _deleteExpiredDailyFiles(root, now);
|
|
|
|
final file = _fileFor(root, now);
|
|
|
|
final events = await _readEvents(file);
|
|
|
|
events.add(event);
|
|
|
|
await file.writeAsString(
|
|
|
|
const JsonEncoder.withIndent(' ').convert(retained),
|
|
|
|
const JsonEncoder.withIndent(' ').convert(events),
|
|
|
|
flush: true,
|
|
|
|
);
|
|
|
|
} catch (error, stackTrace) {
|
|
...
|
...
|
@@ -77,7 +75,7 @@ class OhosHealthFlowLogStore { |
|
|
|
/// Returns the complete file after all previously queued writes finish.
|
|
|
|
Future<Uint8List> readBytes() async {
|
|
|
|
await _writeQueue;
|
|
|
|
final file = await _file();
|
|
|
|
final file = _fileFor(await _root(), DateTime.now());
|
|
|
|
if (!await file.exists()) {
|
|
|
|
return Uint8List.fromList(
|
|
|
|
utf8.encode('[{"message":"No OHOS health flow logs found"}]'),
|
|
...
|
...
|
@@ -86,11 +84,79 @@ class OhosHealthFlowLogStore { |
|
|
|
return file.readAsBytes();
|
|
|
|
}
|
|
|
|
|
|
|
|
Future<File> _file() async {
|
|
|
|
final root = _rootDirectory ?? await getApplicationDocumentsDirectory();
|
|
|
|
return File('${root.path}/$fileName');
|
|
|
|
Future<Directory> _root() {
|
|
|
|
return _rootDirectory == null
|
|
|
|
? getApplicationDocumentsDirectory()
|
|
|
|
: Future<Directory>.value(_rootDirectory);
|
|
|
|
}
|
|
|
|
|
|
|
|
File _fileFor(Directory root, DateTime date) {
|
|
|
|
return File('${root.path}/${_fileNameFor(date)}');
|
|
|
|
}
|
|
|
|
|
|
|
|
static String _fileNameFor(DateTime date) {
|
|
|
|
final day = '${date.year.toString().padLeft(4, '0')}'
|
|
|
|
'${date.month.toString().padLeft(2, '0')}'
|
|
|
|
'${date.day.toString().padLeft(2, '0')}';
|
|
|
|
return '$_fileNamePrefix$day$_fileNameSuffix';
|
|
|
|
}
|
|
|
|
|
|
|
|
Future<void> _deleteExpiredDailyFiles(Directory root, DateTime now) async {
|
|
|
|
final earliestRetainedDay = DateTime(now.year, now.month, now.day)
|
|
|
|
.subtract(const Duration(days: _retainedDays - 1));
|
|
|
|
await for (final entity in root.list(followLinks: false)) {
|
|
|
|
if (entity is! File) continue;
|
|
|
|
final date = _dateFromFileName(entity.uri.pathSegments.last);
|
|
|
|
if (date != null && date.isBefore(earliestRetainedDay)) {
|
|
|
|
await entity.delete();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
Future<void> _migrateLegacyFile(Directory root, DateTime now) async {
|
|
|
|
final legacyFile = File('${root.path}/$_legacyFileName');
|
|
|
|
if (!await legacyFile.exists()) return;
|
|
|
|
|
|
|
|
final earliestRetainedDay = DateTime(now.year, now.month, now.day)
|
|
|
|
.subtract(const Duration(days: _retainedDays - 1));
|
|
|
|
final eventsByDay = <String, List<Map<String, Object?>>>{};
|
|
|
|
for (final event in await _readEvents(legacyFile)) {
|
|
|
|
final timestamp = event['timestamp'];
|
|
|
|
final date = timestamp is String ? DateTime.tryParse(timestamp) : null;
|
|
|
|
if (date == null || _dayOf(date).isBefore(earliestRetainedDay)) continue;
|
|
|
|
eventsByDay.putIfAbsent(_fileNameFor(date), () => []).add(event);
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
for (final entry in eventsByDay.entries) {
|
|
|
|
final file = File('${root.path}/${entry.key}');
|
|
|
|
final events = await _readEvents(file);
|
|
|
|
events.addAll(entry.value);
|
|
|
|
await file.writeAsString(
|
|
|
|
const JsonEncoder.withIndent(' ').convert(events),
|
|
|
|
flush: true,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
await legacyFile.delete();
|
|
|
|
} catch (_) {
|
|
|
|
// Keep the legacy file for the next write if migration cannot finish.
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
static DateTime? _dateFromFileName(String fileName) {
|
|
|
|
final match =
|
|
|
|
RegExp(r'^ohos_health_flow_events_(\d{8})\.json$').firstMatch(fileName);
|
|
|
|
if (match == null) return null;
|
|
|
|
final day = match.group(1)!;
|
|
|
|
final date = DateTime.tryParse(
|
|
|
|
'${day.substring(0, 4)}-${day.substring(4, 6)}-${day.substring(6, 8)}',
|
|
|
|
);
|
|
|
|
return date != null && _fileNameFor(date) == fileName ? date : null;
|
|
|
|
}
|
|
|
|
|
|
|
|
static DateTime _dayOf(DateTime date) =>
|
|
|
|
DateTime(date.year, date.month, date.day);
|
|
|
|
|
|
|
|
Future<List<Map<String, Object?>>> _readEvents(File file) async {
|
|
|
|
if (!await file.exists()) return <Map<String, Object?>>[];
|
|
|
|
try {
|
|
...
|
...
|
@@ -127,14 +193,4 @@ class OhosHealthFlowLogStore { |
|
|
|
|
|
|
|
static String _limit(String value) =>
|
|
|
|
value.length <= 2000 ? value : '${value.substring(0, 2000)}…';
|
|
|
|
|
|
|
|
static bool _isWithinRetention(
|
|
|
|
Map<String, Object?> event,
|
|
|
|
DateTime cutoff,
|
|
|
|
) {
|
|
|
|
final timestamp = event['timestamp'];
|
|
|
|
if (timestamp is! String) return false;
|
|
|
|
final recordedAt = DateTime.tryParse(timestamp);
|
|
|
|
return recordedAt != null && !recordedAt.isBefore(cutoff);
|
|
|
|
}
|
|
|
|
} |
...
|
...
|
|