Showing
1 changed file
with
85 additions
and
29 deletions
| @@ -7,7 +7,7 @@ import 'package:path_provider/path_provider.dart'; | @@ -7,7 +7,7 @@ import 'package:path_provider/path_provider.dart'; | ||
| 7 | 7 | ||
| 8 | import 'app_logger.dart'; | 8 | import 'app_logger.dart'; |
| 9 | 9 | ||
| 10 | -/// Persists a compact, privacy-safe timeline of the OHOS health data pipeline. | 10 | +/// Persists a privacy-safe timeline of the OHOS health data pipeline. |
| 11 | /// | 11 | /// |
| 12 | /// Writes are queued and intentionally fire-and-forget so health callbacks, | 12 | /// Writes are queued and intentionally fire-and-forget so health callbacks, |
| 13 | /// calculation, and push delivery are never blocked by file I/O. Call | 13 | /// calculation, and push delivery are never blocked by file I/O. Call |
| @@ -18,9 +18,10 @@ class OhosHealthFlowLogStore { | @@ -18,9 +18,10 @@ class OhosHealthFlowLogStore { | ||
| 18 | 18 | ||
| 19 | static final OhosHealthFlowLogStore instance = OhosHealthFlowLogStore(); | 19 | static final OhosHealthFlowLogStore instance = OhosHealthFlowLogStore(); |
| 20 | 20 | ||
| 21 | - static const fileName = 'ohos_health_flow_events.json'; | ||
| 22 | - static const _retention = Duration(days: 7); | ||
| 23 | - static const _maxEventCount = 1000; | 21 | + static const _fileNamePrefix = 'ohos_health_flow_events_'; |
| 22 | + static const _fileNameSuffix = '.json'; | ||
| 23 | + static const _legacyFileName = 'ohos_health_flow_events.json'; | ||
| 24 | + static const _retainedDays = 7; | ||
| 24 | 25 | ||
| 25 | final Directory? _rootDirectory; | 26 | final Directory? _rootDirectory; |
| 26 | Future<void> _writeQueue = Future<void>.value(); | 27 | Future<void> _writeQueue = Future<void>.value(); |
| @@ -49,18 +50,15 @@ class OhosHealthFlowLogStore { | @@ -49,18 +50,15 @@ class OhosHealthFlowLogStore { | ||
| 49 | if (details != null && details.isNotEmpty) | 50 | if (details != null && details.isNotEmpty) |
| 50 | 'details': _sanitizeMap(details), | 51 | 'details': _sanitizeMap(details), |
| 51 | }; | 52 | }; |
| 52 | - final file = await _file(); | ||
| 53 | - await file.parent.create(recursive: true); | ||
| 54 | - final cutoff = now.subtract(_retention); | ||
| 55 | - final events = (await _readEvents(file)) | ||
| 56 | - .where((item) => _isWithinRetention(item, cutoff)) | ||
| 57 | - .toList() | ||
| 58 | - ..add(event); | ||
| 59 | - final retained = events.length > _maxEventCount | ||
| 60 | - ? events.sublist(events.length - _maxEventCount) | ||
| 61 | - : events; | 53 | + final root = await _root(); |
| 54 | + await root.create(recursive: true); | ||
| 55 | + await _migrateLegacyFile(root, now); | ||
| 56 | + await _deleteExpiredDailyFiles(root, now); | ||
| 57 | + final file = _fileFor(root, now); | ||
| 58 | + final events = await _readEvents(file); | ||
| 59 | + events.add(event); | ||
| 62 | await file.writeAsString( | 60 | await file.writeAsString( |
| 63 | - const JsonEncoder.withIndent(' ').convert(retained), | 61 | + const JsonEncoder.withIndent(' ').convert(events), |
| 64 | flush: true, | 62 | flush: true, |
| 65 | ); | 63 | ); |
| 66 | } catch (error, stackTrace) { | 64 | } catch (error, stackTrace) { |
| @@ -77,7 +75,7 @@ class OhosHealthFlowLogStore { | @@ -77,7 +75,7 @@ class OhosHealthFlowLogStore { | ||
| 77 | /// Returns the complete file after all previously queued writes finish. | 75 | /// Returns the complete file after all previously queued writes finish. |
| 78 | Future<Uint8List> readBytes() async { | 76 | Future<Uint8List> readBytes() async { |
| 79 | await _writeQueue; | 77 | await _writeQueue; |
| 80 | - final file = await _file(); | 78 | + final file = _fileFor(await _root(), DateTime.now()); |
| 81 | if (!await file.exists()) { | 79 | if (!await file.exists()) { |
| 82 | return Uint8List.fromList( | 80 | return Uint8List.fromList( |
| 83 | utf8.encode('[{"message":"No OHOS health flow logs found"}]'), | 81 | utf8.encode('[{"message":"No OHOS health flow logs found"}]'), |
| @@ -86,11 +84,79 @@ class OhosHealthFlowLogStore { | @@ -86,11 +84,79 @@ class OhosHealthFlowLogStore { | ||
| 86 | return file.readAsBytes(); | 84 | return file.readAsBytes(); |
| 87 | } | 85 | } |
| 88 | 86 | ||
| 89 | - Future<File> _file() async { | ||
| 90 | - final root = _rootDirectory ?? await getApplicationDocumentsDirectory(); | ||
| 91 | - return File('${root.path}/$fileName'); | 87 | + Future<Directory> _root() { |
| 88 | + return _rootDirectory == null | ||
| 89 | + ? getApplicationDocumentsDirectory() | ||
| 90 | + : Future<Directory>.value(_rootDirectory); | ||
| 92 | } | 91 | } |
| 93 | 92 | ||
| 93 | + File _fileFor(Directory root, DateTime date) { | ||
| 94 | + return File('${root.path}/${_fileNameFor(date)}'); | ||
| 95 | + } | ||
| 96 | + | ||
| 97 | + static String _fileNameFor(DateTime date) { | ||
| 98 | + final day = '${date.year.toString().padLeft(4, '0')}' | ||
| 99 | + '${date.month.toString().padLeft(2, '0')}' | ||
| 100 | + '${date.day.toString().padLeft(2, '0')}'; | ||
| 101 | + return '$_fileNamePrefix$day$_fileNameSuffix'; | ||
| 102 | + } | ||
| 103 | + | ||
| 104 | + Future<void> _deleteExpiredDailyFiles(Directory root, DateTime now) async { | ||
| 105 | + final earliestRetainedDay = DateTime(now.year, now.month, now.day) | ||
| 106 | + .subtract(const Duration(days: _retainedDays - 1)); | ||
| 107 | + await for (final entity in root.list(followLinks: false)) { | ||
| 108 | + if (entity is! File) continue; | ||
| 109 | + final date = _dateFromFileName(entity.uri.pathSegments.last); | ||
| 110 | + if (date != null && date.isBefore(earliestRetainedDay)) { | ||
| 111 | + await entity.delete(); | ||
| 112 | + } | ||
| 113 | + } | ||
| 114 | + } | ||
| 115 | + | ||
| 116 | + Future<void> _migrateLegacyFile(Directory root, DateTime now) async { | ||
| 117 | + final legacyFile = File('${root.path}/$_legacyFileName'); | ||
| 118 | + if (!await legacyFile.exists()) return; | ||
| 119 | + | ||
| 120 | + final earliestRetainedDay = DateTime(now.year, now.month, now.day) | ||
| 121 | + .subtract(const Duration(days: _retainedDays - 1)); | ||
| 122 | + final eventsByDay = <String, List<Map<String, Object?>>>{}; | ||
| 123 | + for (final event in await _readEvents(legacyFile)) { | ||
| 124 | + final timestamp = event['timestamp']; | ||
| 125 | + final date = timestamp is String ? DateTime.tryParse(timestamp) : null; | ||
| 126 | + if (date == null || _dayOf(date).isBefore(earliestRetainedDay)) continue; | ||
| 127 | + eventsByDay.putIfAbsent(_fileNameFor(date), () => []).add(event); | ||
| 128 | + } | ||
| 129 | + | ||
| 130 | + try { | ||
| 131 | + for (final entry in eventsByDay.entries) { | ||
| 132 | + final file = File('${root.path}/${entry.key}'); | ||
| 133 | + final events = await _readEvents(file); | ||
| 134 | + events.addAll(entry.value); | ||
| 135 | + await file.writeAsString( | ||
| 136 | + const JsonEncoder.withIndent(' ').convert(events), | ||
| 137 | + flush: true, | ||
| 138 | + ); | ||
| 139 | + } | ||
| 140 | + await legacyFile.delete(); | ||
| 141 | + } catch (_) { | ||
| 142 | + // Keep the legacy file for the next write if migration cannot finish. | ||
| 143 | + } | ||
| 144 | + } | ||
| 145 | + | ||
| 146 | + static DateTime? _dateFromFileName(String fileName) { | ||
| 147 | + final match = | ||
| 148 | + RegExp(r'^ohos_health_flow_events_(\d{8})\.json$').firstMatch(fileName); | ||
| 149 | + if (match == null) return null; | ||
| 150 | + final day = match.group(1)!; | ||
| 151 | + final date = DateTime.tryParse( | ||
| 152 | + '${day.substring(0, 4)}-${day.substring(4, 6)}-${day.substring(6, 8)}', | ||
| 153 | + ); | ||
| 154 | + return date != null && _fileNameFor(date) == fileName ? date : null; | ||
| 155 | + } | ||
| 156 | + | ||
| 157 | + static DateTime _dayOf(DateTime date) => | ||
| 158 | + DateTime(date.year, date.month, date.day); | ||
| 159 | + | ||
| 94 | Future<List<Map<String, Object?>>> _readEvents(File file) async { | 160 | Future<List<Map<String, Object?>>> _readEvents(File file) async { |
| 95 | if (!await file.exists()) return <Map<String, Object?>>[]; | 161 | if (!await file.exists()) return <Map<String, Object?>>[]; |
| 96 | try { | 162 | try { |
| @@ -127,14 +193,4 @@ class OhosHealthFlowLogStore { | @@ -127,14 +193,4 @@ class OhosHealthFlowLogStore { | ||
| 127 | 193 | ||
| 128 | static String _limit(String value) => | 194 | static String _limit(String value) => |
| 129 | value.length <= 2000 ? value : '${value.substring(0, 2000)}…'; | 195 | value.length <= 2000 ? value : '${value.substring(0, 2000)}…'; |
| 130 | - | ||
| 131 | - static bool _isWithinRetention( | ||
| 132 | - Map<String, Object?> event, | ||
| 133 | - DateTime cutoff, | ||
| 134 | - ) { | ||
| 135 | - final timestamp = event['timestamp']; | ||
| 136 | - if (timestamp is! String) return false; | ||
| 137 | - final recordedAt = DateTime.tryParse(timestamp); | ||
| 138 | - return recordedAt != null && !recordedAt.isBefore(cutoff); | ||
| 139 | - } | ||
| 140 | } | 196 | } |
-
Please register or login to post a comment