Showing
5 changed files
with
510 additions
and
30 deletions
| 1 | import 'package:flutter/foundation.dart'; | 1 | import 'package:flutter/foundation.dart'; |
| 2 | 2 | ||
| 3 | import '../../platform/pigeon_api_facade.dart'; | 3 | import '../../platform/pigeon_api_facade.dart'; |
| 4 | +import 'platform_ohos/ohos_harmony_health_raw_data_sync_service_factory.dart'; | ||
| 4 | import 'platform_ohos/ohos_health_raw_data_sync_service.dart'; | 5 | import 'platform_ohos/ohos_health_raw_data_sync_service.dart'; |
| 5 | 6 | ||
| 6 | abstract class HealthRawDataSource { | 7 | abstract class HealthRawDataSource { |
| @@ -136,7 +137,8 @@ class AppleHealthRawDataSource implements HealthRawDataSource { | @@ -136,7 +137,8 @@ class AppleHealthRawDataSource implements HealthRawDataSource { | ||
| 136 | class OhosHealthRawDataSource implements HealthRawDataSource { | 137 | class OhosHealthRawDataSource implements HealthRawDataSource { |
| 137 | OhosHealthRawDataSource({ | 138 | OhosHealthRawDataSource({ |
| 138 | OhosHealthRawDataSyncService? syncService, | 139 | OhosHealthRawDataSyncService? syncService, |
| 139 | - }) : _syncService = syncService ?? OhosHealthRawDataSyncService(); | 140 | + }) : _syncService = |
| 141 | + syncService ?? createDefaultOhosHealthRawDataSyncService(); | ||
| 140 | 142 | ||
| 141 | final OhosHealthRawDataSyncService _syncService; | 143 | final OhosHealthRawDataSyncService _syncService; |
| 142 | 144 |
| 1 | +import 'package:doublefeel_flutter/core/network/api/harmony_api.dart'; | ||
| 2 | +import 'package:doublefeel_flutter/core/result/app_result.dart'; | ||
| 3 | +import 'package:doublefeel_flutter/data/models/harmony/hm_health_data.dart'; | ||
| 4 | +import 'package:doublefeel_flutter/data/models/harmony/hm_sleep_data.dart'; | ||
| 5 | +import 'package:get/get.dart'; | ||
| 6 | + | ||
| 7 | +import 'huawei_health_data_type.dart'; | ||
| 8 | +import 'ohos_health_raw_data_sync_service.dart'; | ||
| 9 | + | ||
| 10 | +OhosHealthRawDataSyncService createDefaultOhosHealthRawDataSyncService() { | ||
| 11 | + if (!Get.isRegistered<HarmonyApi>()) { | ||
| 12 | + return OhosHealthRawDataSyncService(); | ||
| 13 | + } | ||
| 14 | + return OhosHealthRawDataSyncService( | ||
| 15 | + remoteDataSource: OhosHarmonyHealthRawDataRemoteDataSource( | ||
| 16 | + HarmonyApiOhosRawDataClient(Get.find<HarmonyApi>()), | ||
| 17 | + ), | ||
| 18 | + ); | ||
| 19 | +} | ||
| 20 | + | ||
| 21 | +class HarmonyApiOhosRawDataClient implements OhosHarmonyRawDataClient { | ||
| 22 | + const HarmonyApiOhosRawDataClient(this._harmonyApi); | ||
| 23 | + | ||
| 24 | + final HarmonyApi _harmonyApi; | ||
| 25 | + | ||
| 26 | + @override | ||
| 27 | + Future<AppResult<HmHealthData>> getHealthData( | ||
| 28 | + HuaweiHealthDataType dataType, | ||
| 29 | + int startDate, | ||
| 30 | + int endDate, | ||
| 31 | + ) { | ||
| 32 | + return _harmonyApi.getHealthData(dataType, startDate, endDate); | ||
| 33 | + } | ||
| 34 | + | ||
| 35 | + @override | ||
| 36 | + Future<AppResult<HmSleepData>> getSleepData(int startDate, int endDate) { | ||
| 37 | + return _harmonyApi.getSleepData(startDate, endDate); | ||
| 38 | + } | ||
| 39 | +} |
| 1 | +import 'package:doublefeel_flutter/core/logging/app_logger.dart'; | ||
| 2 | +import 'package:doublefeel_flutter/core/result/app_result.dart'; | ||
| 3 | +import 'package:doublefeel_flutter/data/models/harmony/hm_health_data.dart'; | ||
| 4 | +import 'package:doublefeel_flutter/data/models/harmony/hm_sleep_data.dart'; | ||
| 5 | +import 'package:flutter/foundation.dart'; | ||
| 6 | + | ||
| 7 | +import 'huawei_health_data_type.dart'; | ||
| 8 | + | ||
| 1 | class OhosHealthRawDataType { | 9 | class OhosHealthRawDataType { |
| 2 | const OhosHealthRawDataType._(); | 10 | const OhosHealthRawDataType._(); |
| 3 | 11 | ||
| @@ -11,16 +19,20 @@ class OhosHealthRawDataSyncService { | @@ -11,16 +19,20 @@ class OhosHealthRawDataSyncService { | ||
| 11 | OhosHealthRawDataRemoteDataSource? remoteDataSource, | 19 | OhosHealthRawDataRemoteDataSource? remoteDataSource, |
| 12 | OhosHealthRawDataLocalStore? localStore, | 20 | OhosHealthRawDataLocalStore? localStore, |
| 13 | DateTime Function()? nowProvider, | 21 | DateTime Function()? nowProvider, |
| 22 | + void Function(String message)? logSink, | ||
| 14 | }) : _remoteDataSource = | 23 | }) : _remoteDataSource = |
| 15 | remoteDataSource ?? const TodoOhosHealthRawDataRemoteDataSource(), | 24 | remoteDataSource ?? const TodoOhosHealthRawDataRemoteDataSource(), |
| 16 | _localStore = localStore ?? const TodoOhosHealthRawDataLocalStore(), | 25 | _localStore = localStore ?? const TodoOhosHealthRawDataLocalStore(), |
| 17 | - _nowProvider = nowProvider ?? DateTime.now; | 26 | + _nowProvider = nowProvider ?? DateTime.now, |
| 27 | + _logSink = logSink; | ||
| 18 | 28 | ||
| 19 | static const int defaultLookbackDays = 183; | 29 | static const int defaultLookbackDays = 183; |
| 30 | + static const String logMarker = '[OHOS_HEALTH_RAW_SYNC]'; | ||
| 20 | 31 | ||
| 21 | final OhosHealthRawDataRemoteDataSource _remoteDataSource; | 32 | final OhosHealthRawDataRemoteDataSource _remoteDataSource; |
| 22 | final OhosHealthRawDataLocalStore _localStore; | 33 | final OhosHealthRawDataLocalStore _localStore; |
| 23 | final DateTime Function() _nowProvider; | 34 | final DateTime Function() _nowProvider; |
| 35 | + final void Function(String message)? _logSink; | ||
| 24 | 36 | ||
| 25 | Future<OhosHealthRawDataSyncResult> syncRawData({ | 37 | Future<OhosHealthRawDataSyncResult> syncRawData({ |
| 26 | required int dataType, | 38 | required int dataType, |
| @@ -38,34 +50,79 @@ class OhosHealthRawDataSyncService { | @@ -38,34 +50,79 @@ class OhosHealthRawDataSyncService { | ||
| 38 | throw ArgumentError.value(endTime, 'endTime'); | 50 | throw ArgumentError.value(endTime, 'endTime'); |
| 39 | } | 51 | } |
| 40 | 52 | ||
| 41 | - String? pageToken; | 53 | + final fetchRanges = _splitIntoFetchRanges( |
| 54 | + startTime: resolvedStartTime, | ||
| 55 | + endTime: resolvedEndTime, | ||
| 56 | + ); | ||
| 57 | + _log( | ||
| 58 | + 'sync_start dataType=$dataType ' | ||
| 59 | + 'startTime=$resolvedStartTime endTime=$resolvedEndTime ' | ||
| 60 | + 'rangeCount=${fetchRanges.length}', | ||
| 61 | + ); | ||
| 62 | + | ||
| 42 | var pageCount = 0; | 63 | var pageCount = 0; |
| 43 | var storedCount = 0; | 64 | var storedCount = 0; |
| 44 | 65 | ||
| 45 | - do { | ||
| 46 | - final page = await _remoteDataSource.fetchRawDataPage( | ||
| 47 | - dataType: dataType, | ||
| 48 | - startTime: resolvedStartTime, | ||
| 49 | - endTime: resolvedEndTime, | ||
| 50 | - pageToken: pageToken, | 66 | + for (var rangeIndex = 0; rangeIndex < fetchRanges.length; rangeIndex++) { |
| 67 | + final range = fetchRanges[rangeIndex]; | ||
| 68 | + _log( | ||
| 69 | + 'segment_start dataType=$dataType ' | ||
| 70 | + 'segment=${rangeIndex + 1}/${fetchRanges.length} ' | ||
| 71 | + 'startTime=${range.startTime} endTime=${range.endTime} ' | ||
| 72 | + 'startDate=${_dateKeyFromUnixSeconds(range.startTime)} ' | ||
| 73 | + 'endDate=${_dateKeyFromUnixSeconds(range.endTime)}', | ||
| 51 | ); | 74 | ); |
| 52 | - pageCount += 1; | ||
| 53 | 75 | ||
| 54 | - if (page.items.isNotEmpty) { | ||
| 55 | - await _localStore.upsertRawDataBatch( | 76 | + String? pageToken; |
| 77 | + do { | ||
| 78 | + final page = await _remoteDataSource.fetchRawDataPage( | ||
| 56 | dataType: dataType, | 79 | dataType: dataType, |
| 57 | - items: page.items, | 80 | + startTime: range.startTime, |
| 81 | + endTime: range.endTime, | ||
| 82 | + pageToken: pageToken, | ||
| 83 | + ); | ||
| 84 | + pageCount += 1; | ||
| 85 | + _log( | ||
| 86 | + 'page_fetched dataType=$dataType ' | ||
| 87 | + 'segment=${rangeIndex + 1}/${fetchRanges.length} ' | ||
| 88 | + 'page=$pageCount items=${page.items.length} ' | ||
| 89 | + 'nextPageToken=${page.nextPageToken ?? ''}', | ||
| 58 | ); | 90 | ); |
| 59 | - storedCount += page.items.length; | ||
| 60 | - } | ||
| 61 | 91 | ||
| 62 | - pageToken = page.nextPageToken; | ||
| 63 | - } while (pageToken != null && pageToken.isNotEmpty); | 92 | + if (page.items.isNotEmpty) { |
| 93 | + await _localStore.upsertRawDataBatch( | ||
| 94 | + dataType: dataType, | ||
| 95 | + items: page.items, | ||
| 96 | + ); | ||
| 97 | + storedCount += page.items.length; | ||
| 98 | + _log( | ||
| 99 | + 'page_stored dataType=$dataType ' | ||
| 100 | + 'segment=${rangeIndex + 1}/${fetchRanges.length} ' | ||
| 101 | + 'storedItems=${page.items.length} totalStored=$storedCount', | ||
| 102 | + ); | ||
| 103 | + } | ||
| 104 | + | ||
| 105 | + pageToken = page.nextPageToken; | ||
| 106 | + } while (pageToken != null && pageToken.isNotEmpty); | ||
| 107 | + | ||
| 108 | + _log( | ||
| 109 | + 'segment_finish dataType=$dataType ' | ||
| 110 | + 'segment=${rangeIndex + 1}/${fetchRanges.length}', | ||
| 111 | + ); | ||
| 112 | + } | ||
| 113 | + | ||
| 114 | + _log( | ||
| 115 | + 'sync_finish dataType=$dataType ' | ||
| 116 | + 'startTime=$resolvedStartTime endTime=$resolvedEndTime ' | ||
| 117 | + 'rangeCount=${fetchRanges.length} pageCount=$pageCount ' | ||
| 118 | + 'storedCount=$storedCount', | ||
| 119 | + ); | ||
| 64 | 120 | ||
| 65 | return OhosHealthRawDataSyncResult( | 121 | return OhosHealthRawDataSyncResult( |
| 66 | dataType: dataType, | 122 | dataType: dataType, |
| 67 | startTime: resolvedStartTime, | 123 | startTime: resolvedStartTime, |
| 68 | endTime: resolvedEndTime, | 124 | endTime: resolvedEndTime, |
| 125 | + segmentCount: fetchRanges.length, | ||
| 69 | pageCount: pageCount, | 126 | pageCount: pageCount, |
| 70 | storedCount: storedCount, | 127 | storedCount: storedCount, |
| 71 | ); | 128 | ); |
| @@ -88,6 +145,46 @@ class OhosHealthRawDataSyncService { | @@ -88,6 +145,46 @@ class OhosHealthRawDataSyncService { | ||
| 88 | int _unixSeconds(DateTime time) { | 145 | int _unixSeconds(DateTime time) { |
| 89 | return time.millisecondsSinceEpoch ~/ 1000; | 146 | return time.millisecondsSinceEpoch ~/ 1000; |
| 90 | } | 147 | } |
| 148 | + | ||
| 149 | + List<OhosHealthRawDataFetchRange> _splitIntoFetchRanges({ | ||
| 150 | + required int startTime, | ||
| 151 | + required int endTime, | ||
| 152 | + }) { | ||
| 153 | + final ranges = <OhosHealthRawDataFetchRange>[]; | ||
| 154 | + var cursor = DateTime.fromMillisecondsSinceEpoch(startTime * 1000); | ||
| 155 | + final end = DateTime.fromMillisecondsSinceEpoch(endTime * 1000); | ||
| 156 | + | ||
| 157 | + while (!cursor.isAfter(end)) { | ||
| 158 | + final nextMonthStart = DateTime(cursor.year, cursor.month + 1); | ||
| 159 | + final monthEndTime = nextMonthStart.subtract(const Duration(seconds: 1)); | ||
| 160 | + final segmentEndDateTime = | ||
| 161 | + monthEndTime.isBefore(end) ? monthEndTime : end; | ||
| 162 | + final segmentStartTime = _unixSeconds(cursor); | ||
| 163 | + final segmentEndTime = _unixSeconds(segmentEndDateTime); | ||
| 164 | + ranges.add( | ||
| 165 | + OhosHealthRawDataFetchRange( | ||
| 166 | + startTime: segmentStartTime, | ||
| 167 | + endTime: segmentEndTime, | ||
| 168 | + ), | ||
| 169 | + ); | ||
| 170 | + cursor = DateTime.fromMillisecondsSinceEpoch( | ||
| 171 | + (segmentEndTime + 1) * 1000, | ||
| 172 | + ); | ||
| 173 | + } | ||
| 174 | + | ||
| 175 | + return ranges; | ||
| 176 | + } | ||
| 177 | + | ||
| 178 | + void _log(String message) { | ||
| 179 | + final taggedMessage = '$logMarker $message'; | ||
| 180 | + debugPrint(taggedMessage); | ||
| 181 | + _logSink?.call(taggedMessage); | ||
| 182 | + try { | ||
| 183 | + AppLogger.i(taggedMessage); | ||
| 184 | + } catch (_) { | ||
| 185 | + // AppLogger may not be initialized in isolated test/bootstrap contexts. | ||
| 186 | + } | ||
| 187 | + } | ||
| 91 | } | 188 | } |
| 92 | 189 | ||
| 93 | abstract class OhosHealthRawDataRemoteDataSource { | 190 | abstract class OhosHealthRawDataRemoteDataSource { |
| @@ -108,6 +205,111 @@ abstract class OhosHealthRawDataLocalStore { | @@ -108,6 +205,111 @@ abstract class OhosHealthRawDataLocalStore { | ||
| 108 | }); | 205 | }); |
| 109 | } | 206 | } |
| 110 | 207 | ||
| 208 | +abstract class OhosHarmonyRawDataClient { | ||
| 209 | + Future<AppResult<HmHealthData>> getHealthData( | ||
| 210 | + HuaweiHealthDataType dataType, | ||
| 211 | + int startDate, | ||
| 212 | + int endDate, | ||
| 213 | + ); | ||
| 214 | + | ||
| 215 | + Future<AppResult<HmSleepData>> getSleepData( | ||
| 216 | + int startDate, | ||
| 217 | + int endDate, | ||
| 218 | + ); | ||
| 219 | +} | ||
| 220 | + | ||
| 221 | +class OhosHarmonyHealthRawDataRemoteDataSource | ||
| 222 | + implements OhosHealthRawDataRemoteDataSource { | ||
| 223 | + const OhosHarmonyHealthRawDataRemoteDataSource(this._client); | ||
| 224 | + | ||
| 225 | + final OhosHarmonyRawDataClient _client; | ||
| 226 | + | ||
| 227 | + @override | ||
| 228 | + Future<OhosHealthRawDataPage> fetchRawDataPage({ | ||
| 229 | + required int dataType, | ||
| 230 | + required int startTime, | ||
| 231 | + required int endTime, | ||
| 232 | + String? pageToken, | ||
| 233 | + }) async { | ||
| 234 | + final startDate = _dateKeyFromUnixSeconds(startTime); | ||
| 235 | + final endDate = _dateKeyFromUnixSeconds(endTime); | ||
| 236 | + if (dataType == OhosHealthRawDataType.sleepAnalysis) { | ||
| 237 | + final result = await _client.getSleepData(startDate, endDate); | ||
| 238 | + return switch (result) { | ||
| 239 | + AppSuccess<HmSleepData>(:final data) => OhosHealthRawDataPage( | ||
| 240 | + items: _sleepItems(data), | ||
| 241 | + ), | ||
| 242 | + AppFailure<HmSleepData>(:final error) => throw StateError( | ||
| 243 | + 'OHOS sleep raw data fetch failed: $error', | ||
| 244 | + ), | ||
| 245 | + }; | ||
| 246 | + } | ||
| 247 | + | ||
| 248 | + final huaweiDataType = _resolveHuaweiDataType(dataType); | ||
| 249 | + final result = await _client.getHealthData( | ||
| 250 | + huaweiDataType, | ||
| 251 | + startDate, | ||
| 252 | + endDate, | ||
| 253 | + ); | ||
| 254 | + return switch (result) { | ||
| 255 | + AppSuccess<HmHealthData>(:final data) => OhosHealthRawDataPage( | ||
| 256 | + items: _healthItems( | ||
| 257 | + dataType: dataType, | ||
| 258 | + data: data, | ||
| 259 | + ), | ||
| 260 | + ), | ||
| 261 | + AppFailure<HmHealthData>(:final error) => throw StateError( | ||
| 262 | + 'OHOS health raw data fetch failed: $error', | ||
| 263 | + ), | ||
| 264 | + }; | ||
| 265 | + } | ||
| 266 | + | ||
| 267 | + HuaweiHealthDataType _resolveHuaweiDataType(int dataType) { | ||
| 268 | + if (dataType == OhosHealthRawDataType.activitySummary) { | ||
| 269 | + return HuaweiHealthDataType.activity; | ||
| 270 | + } | ||
| 271 | + if (dataType == OhosHealthRawDataType.workout) { | ||
| 272 | + return HuaweiHealthDataType.exerciseDuration; | ||
| 273 | + } | ||
| 274 | + for (final value in HuaweiHealthDataType.values) { | ||
| 275 | + if (value.dataType == dataType) { | ||
| 276 | + return value; | ||
| 277 | + } | ||
| 278 | + } | ||
| 279 | + throw ArgumentError.value(dataType, 'dataType'); | ||
| 280 | + } | ||
| 281 | + | ||
| 282 | + List<OhosHealthRawDataItem> _healthItems({ | ||
| 283 | + required int dataType, | ||
| 284 | + required HmHealthData data, | ||
| 285 | + }) { | ||
| 286 | + return (data.list ?? const <HmHealthDataItem>[]) | ||
| 287 | + .where((item) => item.time != null) | ||
| 288 | + .map( | ||
| 289 | + (item) => OhosHealthRawDataItem( | ||
| 290 | + dataType: item.dataType?.toInt() ?? dataType, | ||
| 291 | + dataTime: item.time!.toInt(), | ||
| 292 | + payload: Map<String, Object?>.from(item.toJson()), | ||
| 293 | + ), | ||
| 294 | + ) | ||
| 295 | + .toList(growable: false); | ||
| 296 | + } | ||
| 297 | + | ||
| 298 | + List<OhosHealthRawDataItem> _sleepItems(HmSleepData data) { | ||
| 299 | + return (data.list ?? const <HmSleepDataItem>[]) | ||
| 300 | + .where((item) => item.toTime != null) | ||
| 301 | + .map( | ||
| 302 | + (item) => OhosHealthRawDataItem( | ||
| 303 | + dataType: | ||
| 304 | + item.dataType?.toInt() ?? OhosHealthRawDataType.sleepAnalysis, | ||
| 305 | + dataTime: item.toTime!.toInt(), | ||
| 306 | + payload: Map<String, Object?>.from(item.toJson()), | ||
| 307 | + ), | ||
| 308 | + ) | ||
| 309 | + .toList(growable: false); | ||
| 310 | + } | ||
| 311 | +} | ||
| 312 | + | ||
| 111 | class TodoOhosHealthRawDataRemoteDataSource | 313 | class TodoOhosHealthRawDataRemoteDataSource |
| 112 | implements OhosHealthRawDataRemoteDataSource { | 314 | implements OhosHealthRawDataRemoteDataSource { |
| 113 | const TodoOhosHealthRawDataRemoteDataSource(); | 315 | const TodoOhosHealthRawDataRemoteDataSource(); |
| @@ -165,11 +367,22 @@ class OhosHealthRawDataItem { | @@ -165,11 +367,22 @@ class OhosHealthRawDataItem { | ||
| 165 | final Map<String, Object?> payload; | 367 | final Map<String, Object?> payload; |
| 166 | } | 368 | } |
| 167 | 369 | ||
| 370 | +class OhosHealthRawDataFetchRange { | ||
| 371 | + const OhosHealthRawDataFetchRange({ | ||
| 372 | + required this.startTime, | ||
| 373 | + required this.endTime, | ||
| 374 | + }); | ||
| 375 | + | ||
| 376 | + final int startTime; | ||
| 377 | + final int endTime; | ||
| 378 | +} | ||
| 379 | + | ||
| 168 | class OhosHealthRawDataSyncResult { | 380 | class OhosHealthRawDataSyncResult { |
| 169 | const OhosHealthRawDataSyncResult({ | 381 | const OhosHealthRawDataSyncResult({ |
| 170 | required this.dataType, | 382 | required this.dataType, |
| 171 | required this.startTime, | 383 | required this.startTime, |
| 172 | required this.endTime, | 384 | required this.endTime, |
| 385 | + this.segmentCount = 1, | ||
| 173 | required this.pageCount, | 386 | required this.pageCount, |
| 174 | required this.storedCount, | 387 | required this.storedCount, |
| 175 | }); | 388 | }); |
| @@ -177,6 +390,12 @@ class OhosHealthRawDataSyncResult { | @@ -177,6 +390,12 @@ class OhosHealthRawDataSyncResult { | ||
| 177 | final int dataType; | 390 | final int dataType; |
| 178 | final int startTime; | 391 | final int startTime; |
| 179 | final int endTime; | 392 | final int endTime; |
| 393 | + final int segmentCount; | ||
| 180 | final int pageCount; | 394 | final int pageCount; |
| 181 | final int storedCount; | 395 | final int storedCount; |
| 182 | } | 396 | } |
| 397 | + | ||
| 398 | +int _dateKeyFromUnixSeconds(int seconds) { | ||
| 399 | + final dateTime = DateTime.fromMillisecondsSinceEpoch(seconds * 1000); | ||
| 400 | + return dateTime.year * 10000 + dateTime.month * 100 + dateTime.day; | ||
| 401 | +} |
| @@ -5,12 +5,12 @@ | @@ -5,12 +5,12 @@ | ||
| 5 | "name": "auto", | 5 | "name": "auto", |
| 6 | "type": "HarmonyOS", | 6 | "type": "HarmonyOS", |
| 7 | "material": { | 7 | "material": { |
| 8 | - "certpath": "/Users/liuhongzhe/.ohos/config/debug_ohos_WWjJA4uV69RmLVhj0o_rA0VEMyuWGcKp7RSWudtmCR0=.cer", | 8 | + "certpath": "/Users/quanhai/Developer/doublefeel_flutter/.ohos/config/debug_ohos_WWjJA4uV69RmLVhj0o_rA0VEMyuWGcKp7RSWudtmCR0=.cer", |
| 9 | "keyAlias": "debugKey", | 9 | "keyAlias": "debugKey", |
| 10 | "keyPassword": "00000019084446C3F235D890CFE9AC6015D14270D13D370ACF44FEDD89097E1ABBBDE3810C8FEBFFFD", | 10 | "keyPassword": "00000019084446C3F235D890CFE9AC6015D14270D13D370ACF44FEDD89097E1ABBBDE3810C8FEBFFFD", |
| 11 | - "profile": "/Users/liuhongzhe/.ohos/config/debug_ohos_WWjJA4uV69RmLVhj0o_rA0VEMyuWGcKp7RSWudtmCR0=.p7b", | 11 | + "profile": "/Users/quanhai/Developer/doublefeel_flutter/.ohos/config/debug_ohos_WWjJA4uV69RmLVhj0o_rA0VEMyuWGcKp7RSWudtmCR0=.p7b", |
| 12 | "signAlg": "SHA256withECDSA", | 12 | "signAlg": "SHA256withECDSA", |
| 13 | - "storeFile": "/Users/liuhongzhe/.ohos/config/debug_ohos_WWjJA4uV69RmLVhj0o_rA0VEMyuWGcKp7RSWudtmCR0=.p12", | 13 | + "storeFile": "/Users/quanhai/Developer/doublefeel_flutter/.ohos/config/debug_ohos_WWjJA4uV69RmLVhj0o_rA0VEMyuWGcKp7RSWudtmCR0=.p12", |
| 14 | "storePassword": "00000019D85309732E0C3572D434400BA13D429AE5A182B54C1EA6F2C9D7DB5AE81BD9A64F12ACC681" | 14 | "storePassword": "00000019D85309732E0C3572D434400BA13D429AE5A182B54C1EA6F2C9D7DB5AE81BD9A64F12ACC681" |
| 15 | } | 15 | } |
| 16 | }, | 16 | }, |
| @@ -18,33 +18,33 @@ | @@ -18,33 +18,33 @@ | ||
| 18 | "name": "release", | 18 | "name": "release", |
| 19 | "type": "HarmonyOS", | 19 | "type": "HarmonyOS", |
| 20 | "material": { | 20 | "material": { |
| 21 | - "storeFile": "/Users/liuhongzhe/Work/DD/Project/GitLab/doublefeel_flutter/ohos/keystore/doublefeel.p12", | 21 | + "storeFile": "/Users/quanhai/Developer/doublefeel_flutter/ohos/keystore/doublefeel.p12", |
| 22 | "storePassword": "0000001E8F02C32C2D5431F31FC368F020D891F18E6DC45A5913C60CACC1074B11282B3527F3E7D420F00B21B13A", | 22 | "storePassword": "0000001E8F02C32C2D5431F31FC368F020D891F18E6DC45A5913C60CACC1074B11282B3527F3E7D420F00B21B13A", |
| 23 | "keyAlias": "DoubleFeel", | 23 | "keyAlias": "DoubleFeel", |
| 24 | "keyPassword": "0000001ECC963DD8029FC0957D403D334E6A864BA98BD1ABF0F96E41AEB1002C3CBAFE12680A33902C1D5C61E7C9", | 24 | "keyPassword": "0000001ECC963DD8029FC0957D403D334E6A864BA98BD1ABF0F96E41AEB1002C3CBAFE12680A33902C1D5C61E7C9", |
| 25 | "signAlg": "SHA256withECDSA", | 25 | "signAlg": "SHA256withECDSA", |
| 26 | - "profile": "/Users/liuhongzhe/Work/DD/Project/GitLab/doublefeel_flutter/ohos/keystore/release/DoubleFeel_hm_releaseRelease.p7b", | ||
| 27 | - "certpath": "/Users/liuhongzhe/Work/DD/Project/GitLab/doublefeel_flutter/ohos/keystore/release/DoubleFeelWatch_Release.cer" | 26 | + "profile": "/Users/quanhai/Developer/doublefeel_flutter/ohos/keystore/release/DoubleFeel_hm_releaseRelease.p7b", |
| 27 | + "certpath": "/Users/quanhai/Developer/doublefeel_flutter/ohos/keystore/release/DoubleFeelWatch_Release.cer" | ||
| 28 | } | 28 | } |
| 29 | }, | 29 | }, |
| 30 | { | 30 | { |
| 31 | "name": "debug", | 31 | "name": "debug", |
| 32 | "type": "HarmonyOS", | 32 | "type": "HarmonyOS", |
| 33 | "material": { | 33 | "material": { |
| 34 | - "storeFile": "/Users/liuhongzhe/Work/DD/Project/GitLab/doublefeel_flutter/ohos/keystore/doublefeel.p12", | 34 | + "storeFile": "/Users/quanhai/Developer/doublefeel_flutter/ohos/keystore/doublefeel.p12", |
| 35 | "storePassword": "0000001E1BE8542E4E9F7969905217A5421D89506648518A33A93BE1045A511094FF10805EC4686172CC000ED9FA", | 35 | "storePassword": "0000001E1BE8542E4E9F7969905217A5421D89506648518A33A93BE1045A511094FF10805EC4686172CC000ED9FA", |
| 36 | "keyAlias": "DoubleFeel", | 36 | "keyAlias": "DoubleFeel", |
| 37 | "keyPassword": "0000001EFBAD9B2C408962C5BF2044ED8A57E4E192D171CAC3EBFF8D648684E52A690DFD6B8BDD7DC28AF8D6174C", | 37 | "keyPassword": "0000001EFBAD9B2C408962C5BF2044ED8A57E4E192D171CAC3EBFF8D648684E52A690DFD6B8BDD7DC28AF8D6174C", |
| 38 | "signAlg": "SHA256withECDSA", | 38 | "signAlg": "SHA256withECDSA", |
| 39 | - "profile": "/Users/liuhongzhe/Work/DD/Project/GitLab/doublefeel_flutter/ohos/keystore/debug/DoubleFeel_hm_debugDebug.p7b", | ||
| 40 | - "certpath": "/Users/liuhongzhe/Work/DD/Project/GitLab/doublefeel_flutter/ohos/keystore/debug/DoubleFeel_hm_debug.cer" | 39 | + "profile": "/Users/quanhai/Developer/doublefeel_flutter/ohos/keystore/debug/DoubleFeel_hm_debugDebug.p7b", |
| 40 | + "certpath": "/Users/quanhai/Developer/doublefeel_flutter/ohos/keystore/debug/DoubleFeel_hm_debug.cer" | ||
| 41 | } | 41 | } |
| 42 | } | 42 | } |
| 43 | ], | 43 | ], |
| 44 | "products": [ | 44 | "products": [ |
| 45 | { | 45 | { |
| 46 | "name": "default", | 46 | "name": "default", |
| 47 | - "signingConfig": "auto", | 47 | + "signingConfig": "debug", |
| 48 | "targetSdkVersion": "6.0.0(20)", | 48 | "targetSdkVersion": "6.0.0(20)", |
| 49 | "compatibleSdkVersion": "6.0.0(20)", | 49 | "compatibleSdkVersion": "6.0.0(20)", |
| 50 | "runtimeOS": "HarmonyOS", | 50 | "runtimeOS": "HarmonyOS", |
| 1 | +import 'package:doublefeel_flutter/core/result/app_result.dart'; | ||
| 2 | +import 'package:doublefeel_flutter/core/services/raw_data_service/platform_ohos/huawei_health_data_type.dart'; | ||
| 1 | import 'package:doublefeel_flutter/core/services/raw_data_service/platform_ohos/ohos_health_raw_data_sync_service.dart'; | 3 | import 'package:doublefeel_flutter/core/services/raw_data_service/platform_ohos/ohos_health_raw_data_sync_service.dart'; |
| 4 | +import 'package:doublefeel_flutter/data/models/harmony/hm_health_data.dart'; | ||
| 5 | +import 'package:doublefeel_flutter/data/models/harmony/hm_sleep_data.dart'; | ||
| 2 | import 'package:flutter_test/flutter_test.dart'; | 6 | import 'package:flutter_test/flutter_test.dart'; |
| 3 | 7 | ||
| 4 | void main() { | 8 | void main() { |
| @@ -36,6 +40,7 @@ void main() { | @@ -36,6 +40,7 @@ void main() { | ||
| 36 | 40 | ||
| 37 | expect(result.startTime, 1000); | 41 | expect(result.startTime, 1000); |
| 38 | expect(result.endTime, 2000); | 42 | expect(result.endTime, 2000); |
| 43 | + expect(result.segmentCount, 1); | ||
| 39 | expect(result.pageCount, 2); | 44 | expect(result.pageCount, 2); |
| 40 | expect(result.storedCount, 2); | 45 | expect(result.storedCount, 2); |
| 41 | expect(remote.calls.map((e) => e.pageToken), [null, 'next']); | 46 | expect(remote.calls.map((e) => e.pageToken), [null, 'next']); |
| @@ -70,10 +75,149 @@ void main() { | @@ -70,10 +75,149 @@ void main() { | ||
| 70 | 75 | ||
| 71 | expect(result.startTime, expectedStart); | 76 | expect(result.startTime, expectedStart); |
| 72 | expect(result.endTime, expectedEnd); | 77 | expect(result.endTime, expectedEnd); |
| 73 | - expect(remote.calls.single.startTime, expectedStart); | ||
| 74 | - expect(remote.calls.single.endTime, expectedEnd); | 78 | + expect(result.segmentCount, remote.calls.length); |
| 79 | + expect(remote.calls.first.startTime, expectedStart); | ||
| 80 | + expect(remote.calls.last.endTime, expectedEnd); | ||
| 75 | expect(local.storedBatches, isEmpty); | 81 | expect(local.storedBatches, isEmpty); |
| 76 | }); | 82 | }); |
| 83 | + | ||
| 84 | + test('syncRawData splits cross-month ranges and writes marked logs', | ||
| 85 | + () async { | ||
| 86 | + final start = _unixSeconds(DateTime(2026, 1, 30, 10)); | ||
| 87 | + final end = _unixSeconds(DateTime(2026, 3, 2, 9)); | ||
| 88 | + final remote = _FakeOhosHealthRawDataRemoteDataSource([ | ||
| 89 | + const OhosHealthRawDataPage( | ||
| 90 | + items: [ | ||
| 91 | + OhosHealthRawDataItem( | ||
| 92 | + dataType: 1, | ||
| 93 | + dataTime: 1, | ||
| 94 | + payload: {'value': 45}, | ||
| 95 | + ), | ||
| 96 | + ], | ||
| 97 | + ), | ||
| 98 | + const OhosHealthRawDataPage( | ||
| 99 | + items: [ | ||
| 100 | + OhosHealthRawDataItem( | ||
| 101 | + dataType: 1, | ||
| 102 | + dataTime: 2, | ||
| 103 | + payload: {'value': 46}, | ||
| 104 | + ), | ||
| 105 | + ], | ||
| 106 | + ), | ||
| 107 | + const OhosHealthRawDataPage( | ||
| 108 | + items: [ | ||
| 109 | + OhosHealthRawDataItem( | ||
| 110 | + dataType: 1, | ||
| 111 | + dataTime: 3, | ||
| 112 | + payload: {'value': 47}, | ||
| 113 | + ), | ||
| 114 | + ], | ||
| 115 | + ), | ||
| 116 | + ]); | ||
| 117 | + final local = _FakeOhosHealthRawDataLocalStore(); | ||
| 118 | + final logs = <String>[]; | ||
| 119 | + final service = OhosHealthRawDataSyncService( | ||
| 120 | + remoteDataSource: remote, | ||
| 121 | + localStore: local, | ||
| 122 | + logSink: logs.add, | ||
| 123 | + ); | ||
| 124 | + | ||
| 125 | + final result = await service.syncRawData( | ||
| 126 | + dataType: 1, | ||
| 127 | + startTime: start, | ||
| 128 | + endTime: end, | ||
| 129 | + ); | ||
| 130 | + | ||
| 131 | + expect(result.segmentCount, 3); | ||
| 132 | + expect(result.pageCount, 3); | ||
| 133 | + expect(result.storedCount, 3); | ||
| 134 | + expect(remote.calls.map((e) => _dateKey(e.startTime)), [ | ||
| 135 | + 20260130, | ||
| 136 | + 20260201, | ||
| 137 | + 20260301, | ||
| 138 | + ]); | ||
| 139 | + expect(remote.calls.map((e) => _dateKey(e.endTime)), [ | ||
| 140 | + 20260131, | ||
| 141 | + 20260228, | ||
| 142 | + 20260302, | ||
| 143 | + ]); | ||
| 144 | + expect( | ||
| 145 | + logs, | ||
| 146 | + contains( | ||
| 147 | + contains(OhosHealthRawDataSyncService.logMarker), | ||
| 148 | + ), | ||
| 149 | + ); | ||
| 150 | + expect(logs, contains(contains('segment_start dataType=1 segment=1/3'))); | ||
| 151 | + expect(logs, contains(contains('sync_finish dataType=1'))); | ||
| 152 | + }); | ||
| 153 | + | ||
| 154 | + test('Harmony remote data source calls getHealthData and converts items', | ||
| 155 | + () async { | ||
| 156 | + final client = _FakeOhosHarmonyRawDataClient( | ||
| 157 | + healthData: HmHealthData( | ||
| 158 | + list: [ | ||
| 159 | + HmHealthDataItem( | ||
| 160 | + time: 1787900000, | ||
| 161 | + dataType: 1, | ||
| 162 | + value: 53, | ||
| 163 | + isAsleep: 0, | ||
| 164 | + ), | ||
| 165 | + HmHealthDataItem( | ||
| 166 | + dataType: 1, | ||
| 167 | + value: 54, | ||
| 168 | + ), | ||
| 169 | + ], | ||
| 170 | + ), | ||
| 171 | + ); | ||
| 172 | + final remote = OhosHarmonyHealthRawDataRemoteDataSource(client); | ||
| 173 | + | ||
| 174 | + final page = await remote.fetchRawDataPage( | ||
| 175 | + dataType: HuaweiHealthDataType.hrv.dataType, | ||
| 176 | + startTime: _unixSeconds(DateTime(2026, 8, 1)), | ||
| 177 | + endTime: _unixSeconds(DateTime(2026, 8, 31, 23, 59, 59)), | ||
| 178 | + ); | ||
| 179 | + | ||
| 180 | + expect(client.healthCalls.single.dataType, HuaweiHealthDataType.hrv); | ||
| 181 | + expect(client.healthCalls.single.startDate, 20260801); | ||
| 182 | + expect(client.healthCalls.single.endDate, 20260831); | ||
| 183 | + expect(page.nextPageToken, isNull); | ||
| 184 | + expect(page.items, hasLength(1)); | ||
| 185 | + expect(page.items.single.dataType, 1); | ||
| 186 | + expect(page.items.single.dataTime, 1787900000); | ||
| 187 | + expect(page.items.single.payload['value'], 53); | ||
| 188 | + }); | ||
| 189 | + | ||
| 190 | + test('Harmony remote data source calls getSleepData and converts items', | ||
| 191 | + () async { | ||
| 192 | + final client = _FakeOhosHarmonyRawDataClient( | ||
| 193 | + sleepData: HmSleepData( | ||
| 194 | + list: [ | ||
| 195 | + HmSleepDataItem( | ||
| 196 | + fromTime: 1787850000, | ||
| 197 | + toTime: 1787870000, | ||
| 198 | + dataType: 100, | ||
| 199 | + ), | ||
| 200 | + HmSleepDataItem( | ||
| 201 | + fromTime: 1787880000, | ||
| 202 | + ), | ||
| 203 | + ], | ||
| 204 | + ), | ||
| 205 | + ); | ||
| 206 | + final remote = OhosHarmonyHealthRawDataRemoteDataSource(client); | ||
| 207 | + | ||
| 208 | + final page = await remote.fetchRawDataPage( | ||
| 209 | + dataType: OhosHealthRawDataType.sleepAnalysis, | ||
| 210 | + startTime: _unixSeconds(DateTime(2026, 8, 1)), | ||
| 211 | + endTime: _unixSeconds(DateTime(2026, 8, 2, 23, 59, 59)), | ||
| 212 | + ); | ||
| 213 | + | ||
| 214 | + expect(client.sleepCalls.single.startDate, 20260801); | ||
| 215 | + expect(client.sleepCalls.single.endDate, 20260802); | ||
| 216 | + expect(page.items, hasLength(1)); | ||
| 217 | + expect(page.items.single.dataType, 100); | ||
| 218 | + expect(page.items.single.dataTime, 1787870000); | ||
| 219 | + expect(page.items.single.payload['from_time'], 1787850000); | ||
| 220 | + }); | ||
| 77 | } | 221 | } |
| 78 | 222 | ||
| 79 | class _FakeOhosHealthRawDataRemoteDataSource | 223 | class _FakeOhosHealthRawDataRemoteDataSource |
| @@ -98,7 +242,11 @@ class _FakeOhosHealthRawDataRemoteDataSource | @@ -98,7 +242,11 @@ class _FakeOhosHealthRawDataRemoteDataSource | ||
| 98 | pageToken: pageToken, | 242 | pageToken: pageToken, |
| 99 | ), | 243 | ), |
| 100 | ); | 244 | ); |
| 101 | - return _pages[calls.length - 1]; | 245 | + final pageIndex = calls.length - 1; |
| 246 | + if (pageIndex >= _pages.length) { | ||
| 247 | + return const OhosHealthRawDataPage(items: <OhosHealthRawDataItem>[]); | ||
| 248 | + } | ||
| 249 | + return _pages[pageIndex]; | ||
| 102 | } | 250 | } |
| 103 | } | 251 | } |
| 104 | 252 | ||
| @@ -137,3 +285,75 @@ class _FetchCall { | @@ -137,3 +285,75 @@ class _FetchCall { | ||
| 137 | final int endTime; | 285 | final int endTime; |
| 138 | final String? pageToken; | 286 | final String? pageToken; |
| 139 | } | 287 | } |
| 288 | + | ||
| 289 | +class _FakeOhosHarmonyRawDataClient implements OhosHarmonyRawDataClient { | ||
| 290 | + _FakeOhosHarmonyRawDataClient({ | ||
| 291 | + HmHealthData? healthData, | ||
| 292 | + HmSleepData? sleepData, | ||
| 293 | + }) : _healthData = healthData ?? HmHealthData(), | ||
| 294 | + _sleepData = sleepData ?? HmSleepData(); | ||
| 295 | + | ||
| 296 | + final HmHealthData _healthData; | ||
| 297 | + final HmSleepData _sleepData; | ||
| 298 | + final List<_HealthCall> healthCalls = <_HealthCall>[]; | ||
| 299 | + final List<_SleepCall> sleepCalls = <_SleepCall>[]; | ||
| 300 | + | ||
| 301 | + @override | ||
| 302 | + Future<AppResult<HmHealthData>> getHealthData( | ||
| 303 | + HuaweiHealthDataType dataType, | ||
| 304 | + int startDate, | ||
| 305 | + int endDate, | ||
| 306 | + ) async { | ||
| 307 | + healthCalls.add( | ||
| 308 | + _HealthCall( | ||
| 309 | + dataType: dataType, | ||
| 310 | + startDate: startDate, | ||
| 311 | + endDate: endDate, | ||
| 312 | + ), | ||
| 313 | + ); | ||
| 314 | + return AppSuccess(_healthData); | ||
| 315 | + } | ||
| 316 | + | ||
| 317 | + @override | ||
| 318 | + Future<AppResult<HmSleepData>> getSleepData( | ||
| 319 | + int startDate, | ||
| 320 | + int endDate, | ||
| 321 | + ) async { | ||
| 322 | + sleepCalls.add( | ||
| 323 | + _SleepCall( | ||
| 324 | + startDate: startDate, | ||
| 325 | + endDate: endDate, | ||
| 326 | + ), | ||
| 327 | + ); | ||
| 328 | + return AppSuccess(_sleepData); | ||
| 329 | + } | ||
| 330 | +} | ||
| 331 | + | ||
| 332 | +class _HealthCall { | ||
| 333 | + const _HealthCall({ | ||
| 334 | + required this.dataType, | ||
| 335 | + required this.startDate, | ||
| 336 | + required this.endDate, | ||
| 337 | + }); | ||
| 338 | + | ||
| 339 | + final HuaweiHealthDataType dataType; | ||
| 340 | + final int startDate; | ||
| 341 | + final int endDate; | ||
| 342 | +} | ||
| 343 | + | ||
| 344 | +class _SleepCall { | ||
| 345 | + const _SleepCall({ | ||
| 346 | + required this.startDate, | ||
| 347 | + required this.endDate, | ||
| 348 | + }); | ||
| 349 | + | ||
| 350 | + final int startDate; | ||
| 351 | + final int endDate; | ||
| 352 | +} | ||
| 353 | + | ||
| 354 | +int _unixSeconds(DateTime time) => time.millisecondsSinceEpoch ~/ 1000; | ||
| 355 | + | ||
| 356 | +int _dateKey(int seconds) { | ||
| 357 | + final dateTime = DateTime.fromMillisecondsSinceEpoch(seconds * 1000); | ||
| 358 | + return dateTime.year * 10000 + dateTime.month * 100 + dateTime.day; | ||
| 359 | +} |
-
Please register or login to post a comment