Showing
7 changed files
with
596 additions
and
188 deletions
| @@ -23,6 +23,9 @@ import '../health_sleep_calculator.dart'; | @@ -23,6 +23,9 @@ import '../health_sleep_calculator.dart'; | ||
| 23 | class AppleHealthRawDataCoreService { | 23 | class AppleHealthRawDataCoreService { |
| 24 | static const int defaultLookbackDays = 183; | 24 | static const int defaultLookbackDays = 183; |
| 25 | static const int defaultReadChunkDays = 7; | 25 | static const int defaultReadChunkDays = 7; |
| 26 | + // Re-read enough history to rebuild the previous completed sleep when | ||
| 27 | + // HealthKit delivers its stages after a newer sleep result was stored. | ||
| 28 | + static const int sleepReconciliationDays = 2; | ||
| 26 | static String _executionEngineType = 'main'; | 29 | static String _executionEngineType = 'main'; |
| 27 | 30 | ||
| 28 | static void setExecutionEngineType(String value) { | 31 | static void setExecutionEngineType(String value) { |
| @@ -341,9 +344,11 @@ class AppleHealthRawDataCoreService { | @@ -341,9 +344,11 @@ class AppleHealthRawDataCoreService { | ||
| 341 | ); | 344 | ); |
| 342 | final restingHeartRateStartTime = heartRateStartTime; | 345 | final restingHeartRateStartTime = heartRateStartTime; |
| 343 | final sleepStartTime = math.max( | 346 | final sleepStartTime = math.max( |
| 344 | - latestSleepResultTime == null | ||
| 345 | - ? requestedStartTime | ||
| 346 | - : latestSleepResultTime - Duration.secondsPerDay, | 347 | + forceStartTime ?? |
| 348 | + (latestSleepResultTime == null | ||
| 349 | + ? requestedStartTime | ||
| 350 | + : latestSleepResultTime - | ||
| 351 | + sleepReconciliationDays * Duration.secondsPerDay), | ||
| 347 | earliestStartTime, | 352 | earliestStartTime, |
| 348 | ); | 353 | ); |
| 349 | 354 | ||
| @@ -434,7 +439,6 @@ class AppleHealthRawDataCoreService { | @@ -434,7 +439,6 @@ class AppleHealthRawDataCoreService { | ||
| 434 | final sleepResults = await _calculateAndStoreSleepResults( | 439 | final sleepResults = await _calculateAndStoreSleepResults( |
| 435 | userId: userId, | 440 | userId: userId, |
| 436 | sleepIntervals: sleepIntervals, | 441 | sleepIntervals: sleepIntervals, |
| 437 | - latestSleepResultTime: latestSleepResultTime, | ||
| 438 | ); | 442 | ); |
| 439 | _scheduleResultUpload('upload sleep results', _uploadSleepResults); | 443 | _scheduleResultUpload('upload sleep results', _uploadSleepResults); |
| 440 | final storedResult = newResult.copyWith( | 444 | final storedResult = newResult.copyWith( |
| @@ -1814,14 +1818,13 @@ class AppleHealthRawDataCoreService { | @@ -1814,14 +1818,13 @@ class AppleHealthRawDataCoreService { | ||
| 1814 | Future<List<HealthRawSleepResult>> _calculateAndStoreSleepResults({ | 1818 | Future<List<HealthRawSleepResult>> _calculateAndStoreSleepResults({ |
| 1815 | required int userId, | 1819 | required int userId, |
| 1816 | required List<HealthKitRawDataPoint> sleepIntervals, | 1820 | required List<HealthKitRawDataPoint> sleepIntervals, |
| 1817 | - required int? latestSleepResultTime, | ||
| 1818 | }) async { | 1821 | }) async { |
| 1819 | if (sleepIntervals.isEmpty) return const <HealthRawSleepResult>[]; | 1822 | if (sleepIntervals.isEmpty) return const <HealthRawSleepResult>[]; |
| 1820 | final days = { | 1823 | final days = { |
| 1821 | for (final interval in sleepIntervals) _localDay(interval.endTime), | 1824 | for (final interval in sleepIntervals) _localDay(interval.endTime), |
| 1822 | }.toList() | 1825 | }.toList() |
| 1823 | ..sort((a, b) => a.compareTo(b)); | 1826 | ..sort((a, b) => a.compareTo(b)); |
| 1824 | - final results = <HealthRawSleepResult>[]; | 1827 | + final candidates = <HealthRawSleepResult>[]; |
| 1825 | for (final day in days) { | 1828 | for (final day in days) { |
| 1826 | final calculation = HealthSleepCalculator.calculateDay( | 1829 | final calculation = HealthSleepCalculator.calculateDay( |
| 1827 | day: day, | 1830 | day: day, |
| @@ -1832,11 +1835,7 @@ class AppleHealthRawDataCoreService { | @@ -1832,11 +1835,7 @@ class AppleHealthRawDataCoreService { | ||
| 1832 | final state = calculation.state; | 1835 | final state = calculation.state; |
| 1833 | if (merged == null || score == null || state == null) continue; | 1836 | if (merged == null || score == null || state == null) continue; |
| 1834 | if (!calculation.hasValidSleep) continue; | 1837 | if (!calculation.hasValidSleep) continue; |
| 1835 | - if (latestSleepResultTime != null && | ||
| 1836 | - merged.endTime <= latestSleepResultTime) { | ||
| 1837 | - continue; | ||
| 1838 | - } | ||
| 1839 | - results.add( | 1838 | + candidates.add( |
| 1840 | HealthRawSleepResult( | 1839 | HealthRawSleepResult( |
| 1841 | userId: userId, | 1840 | userId: userId, |
| 1842 | date: merged.endTime, | 1841 | date: merged.endTime, |
| @@ -1850,11 +1849,45 @@ class AppleHealthRawDataCoreService { | @@ -1850,11 +1849,45 @@ class AppleHealthRawDataCoreService { | ||
| 1850 | ), | 1849 | ), |
| 1851 | ); | 1850 | ); |
| 1852 | } | 1851 | } |
| 1852 | + if (candidates.isEmpty) return const <HealthRawSleepResult>[]; | ||
| 1853 | + final earliestDate = | ||
| 1854 | + candidates.map((result) => result.date).reduce(math.min); | ||
| 1855 | + final latestDate = candidates.map((result) => result.date).reduce(math.max); | ||
| 1856 | + final existingByDate = { | ||
| 1857 | + for (final result in await _localStore.querySleepResults( | ||
| 1858 | + userId: userId, | ||
| 1859 | + startTime: earliestDate, | ||
| 1860 | + endTime: latestDate, | ||
| 1861 | + )) | ||
| 1862 | + result.date: result, | ||
| 1863 | + }; | ||
| 1864 | + final changedResults = candidates | ||
| 1865 | + .where( | ||
| 1866 | + (candidate) => !_sameSleepResult( | ||
| 1867 | + existingByDate[candidate.date], | ||
| 1868 | + candidate, | ||
| 1869 | + ), | ||
| 1870 | + ) | ||
| 1871 | + .toList(); | ||
| 1853 | await _localStore.upsertSleepResults( | 1872 | await _localStore.upsertSleepResults( |
| 1854 | userId: userId, | 1873 | userId: userId, |
| 1855 | - results: results, | 1874 | + results: candidates, |
| 1856 | ); | 1875 | ); |
| 1857 | - return results; | 1876 | + return changedResults; |
| 1877 | + } | ||
| 1878 | + | ||
| 1879 | + bool _sameSleepResult( | ||
| 1880 | + HealthRawSleepResult? existing, | ||
| 1881 | + HealthRawSleepResult candidate, | ||
| 1882 | + ) { | ||
| 1883 | + return existing != null && | ||
| 1884 | + existing.userId == candidate.userId && | ||
| 1885 | + existing.startDate == candidate.startDate && | ||
| 1886 | + existing.sleepScore == candidate.sleepScore && | ||
| 1887 | + existing.sleepState == candidate.sleepState && | ||
| 1888 | + existing.inBedMinutes == candidate.inBedMinutes && | ||
| 1889 | + existing.awakMinutes == candidate.awakMinutes && | ||
| 1890 | + existing.sleepMinutes == candidate.sleepMinutes; | ||
| 1858 | } | 1891 | } |
| 1859 | 1892 | ||
| 1860 | List<HealthRawHrvStressPoint> _filterNewHrvStressPoints( | 1893 | List<HealthRawHrvStressPoint> _filterNewHrvStressPoints( |
| @@ -2242,7 +2275,7 @@ class HealthRawStressLocalStore { | @@ -2242,7 +2275,7 @@ class HealthRawStressLocalStore { | ||
| 2242 | final db = await _database(userId); | 2275 | final db = await _database(userId); |
| 2243 | final rows = await db.query( | 2276 | final rows = await db.query( |
| 2244 | hrvResultsTable, | 2277 | hrvResultsTable, |
| 2245 | - where: 'uploaded IS NULL OR uploaded != 1', | 2278 | + where: 'uploaded = 0', |
| 2246 | orderBy: 'raw_end_time ASC', | 2279 | orderBy: 'raw_end_time ASC', |
| 2247 | limit: limit, | 2280 | limit: limit, |
| 2248 | ); | 2281 | ); |
| @@ -2271,7 +2304,7 @@ class HealthRawStressLocalStore { | @@ -2271,7 +2304,7 @@ class HealthRawStressLocalStore { | ||
| 2271 | final db = await _database(userId); | 2304 | final db = await _database(userId); |
| 2272 | final rows = await db.query( | 2305 | final rows = await db.query( |
| 2273 | realtimeStressResultsTable, | 2306 | realtimeStressResultsTable, |
| 2274 | - where: 'uploaded IS NULL OR uploaded != 1', | 2307 | + where: 'uploaded = 0', |
| 2275 | orderBy: 'raw_end_time ASC', | 2308 | orderBy: 'raw_end_time ASC', |
| 2276 | limit: limit, | 2309 | limit: limit, |
| 2277 | ); | 2310 | ); |
| @@ -2307,7 +2340,7 @@ class HealthRawStressLocalStore { | @@ -2307,7 +2340,7 @@ class HealthRawStressLocalStore { | ||
| 2307 | final db = await _database(userId); | 2340 | final db = await _database(userId); |
| 2308 | final rows = await db.query( | 2341 | final rows = await db.query( |
| 2309 | dailyStressResultsTable, | 2342 | dailyStressResultsTable, |
| 2310 | - where: 'uploaded IS NULL OR uploaded != 1', | 2343 | + where: 'uploaded = 0', |
| 2311 | orderBy: 'date ASC', | 2344 | orderBy: 'date ASC', |
| 2312 | limit: limit, | 2345 | limit: limit, |
| 2313 | ); | 2346 | ); |
| @@ -2336,7 +2369,7 @@ class HealthRawStressLocalStore { | @@ -2336,7 +2369,7 @@ class HealthRawStressLocalStore { | ||
| 2336 | final db = await _database(userId); | 2369 | final db = await _database(userId); |
| 2337 | final rows = await db.query( | 2370 | final rows = await db.query( |
| 2338 | sleepResultsTable, | 2371 | sleepResultsTable, |
| 2339 | - where: 'uploaded IS NULL OR uploaded != 1', | 2372 | + where: 'uploaded = 0', |
| 2340 | orderBy: 'date ASC', | 2373 | orderBy: 'date ASC', |
| 2341 | limit: limit, | 2374 | limit: limit, |
| 2342 | ); | 2375 | ); |
| @@ -2561,7 +2594,7 @@ class HealthRawStressLocalStore { | @@ -2561,7 +2594,7 @@ class HealthRawStressLocalStore { | ||
| 2561 | final db = await factory.openDatabase( | 2594 | final db = await factory.openDatabase( |
| 2562 | path, | 2595 | path, |
| 2563 | options: OpenDatabaseOptions( | 2596 | options: OpenDatabaseOptions( |
| 2564 | - version: 10, | 2597 | + version: 11, |
| 2565 | onCreate: (db, version) async { | 2598 | onCreate: (db, version) async { |
| 2566 | await _createTables(db); | 2599 | await _createTables(db); |
| 2567 | }, | 2600 | }, |
| @@ -2594,6 +2627,9 @@ class HealthRawStressLocalStore { | @@ -2594,6 +2627,9 @@ class HealthRawStressLocalStore { | ||
| 2594 | if (oldVersion < 10) { | 2627 | if (oldVersion < 10) { |
| 2595 | await _addPushSendTimeColumns(db); | 2628 | await _addPushSendTimeColumns(db); |
| 2596 | } | 2629 | } |
| 2630 | + if (oldVersion < 11) { | ||
| 2631 | + await _createIndexes(db); | ||
| 2632 | + } | ||
| 2597 | }, | 2633 | }, |
| 2598 | ), | 2634 | ), |
| 2599 | ); | 2635 | ); |
| @@ -2647,6 +2683,7 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable ( | @@ -2647,6 +2683,7 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable ( | ||
| 2647 | '''); | 2683 | '''); |
| 2648 | await _createDailyStressTable(db); | 2684 | await _createDailyStressTable(db); |
| 2649 | await _createSleepResultsTable(db); | 2685 | await _createSleepResultsTable(db); |
| 2686 | + await _createIndexes(db); | ||
| 2650 | } | 2687 | } |
| 2651 | 2688 | ||
| 2652 | Future<void> _createDailyStressTable(DatabaseExecutor db) async { | 2689 | Future<void> _createDailyStressTable(DatabaseExecutor db) async { |
| @@ -2686,6 +2723,39 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | @@ -2686,6 +2723,39 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | ||
| 2686 | '''); | 2723 | '''); |
| 2687 | } | 2724 | } |
| 2688 | 2725 | ||
| 2726 | + Future<void> _createIndexes(DatabaseExecutor db) async { | ||
| 2727 | + // Range reads already use each table's INTEGER PRIMARY KEY. These indexes | ||
| 2728 | + // cover the pending-upload and latest-notification queries instead. | ||
| 2729 | + await db.execute( | ||
| 2730 | + 'CREATE INDEX IF NOT EXISTS idx_hrv_results_pending ' | ||
| 2731 | + 'ON $hrvResultsTable(uploaded, raw_end_time)', | ||
| 2732 | + ); | ||
| 2733 | + await db.execute( | ||
| 2734 | + 'CREATE INDEX IF NOT EXISTS idx_realtime_stress_results_pending ' | ||
| 2735 | + 'ON $realtimeStressResultsTable(uploaded, raw_end_time)', | ||
| 2736 | + ); | ||
| 2737 | + await db.execute( | ||
| 2738 | + 'CREATE INDEX IF NOT EXISTS idx_daily_stress_results_pending ' | ||
| 2739 | + 'ON $dailyStressResultsTable(uploaded, date)', | ||
| 2740 | + ); | ||
| 2741 | + await db.execute( | ||
| 2742 | + 'CREATE INDEX IF NOT EXISTS idx_sleep_results_pending ' | ||
| 2743 | + 'ON $sleepResultsTable(uploaded, date)', | ||
| 2744 | + ); | ||
| 2745 | + await db.execute( | ||
| 2746 | + 'CREATE INDEX IF NOT EXISTS idx_hrv_results_push_send_time ' | ||
| 2747 | + 'ON $hrvResultsTable(push_send_time)', | ||
| 2748 | + ); | ||
| 2749 | + await db.execute( | ||
| 2750 | + 'CREATE INDEX IF NOT EXISTS idx_realtime_stress_results_push_send_time ' | ||
| 2751 | + 'ON $realtimeStressResultsTable(push_send_time)', | ||
| 2752 | + ); | ||
| 2753 | + await db.execute( | ||
| 2754 | + 'CREATE INDEX IF NOT EXISTS idx_sleep_results_push_send_time ' | ||
| 2755 | + 'ON $sleepResultsTable(push_send_time)', | ||
| 2756 | + ); | ||
| 2757 | + } | ||
| 2758 | + | ||
| 2689 | Future<void> _addUpdateTimeColumns(DatabaseExecutor db) async { | 2759 | Future<void> _addUpdateTimeColumns(DatabaseExecutor db) async { |
| 2690 | for (final table in const [ | 2760 | for (final table in const [ |
| 2691 | hrvResultsTable, | 2761 | hrvResultsTable, |
| @@ -2919,7 +2989,7 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | @@ -2919,7 +2989,7 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | ||
| 2919 | final rows = await db.query( | 2989 | final rows = await db.query( |
| 2920 | table, | 2990 | table, |
| 2921 | columns: ['uploaded'], | 2991 | columns: ['uploaded'], |
| 2922 | - where: 'uploaded != 1', | 2992 | + where: 'uploaded = 0', |
| 2923 | limit: 1, | 2993 | limit: 1, |
| 2924 | ); | 2994 | ); |
| 2925 | return rows.isNotEmpty; | 2995 | return rows.isNotEmpty; |
| @@ -3217,6 +3287,8 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | @@ -3217,6 +3287,8 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable ( | ||
| 3217 | 'sleep_minutes', | 3287 | 'sleep_minutes', |
| 3218 | ], | 3288 | ], |
| 3219 | ); | 3289 | ); |
| 3290 | + // Recalculation/backfill must not rewrite unchanged rows or upload cursors. | ||
| 3291 | + if (!valueChanged && existingRow['date_key'] == row['date_key']) return; | ||
| 3220 | await db.update( | 3292 | await db.update( |
| 3221 | sleepResultsTable, | 3293 | sleepResultsTable, |
| 3222 | <String, Object?>{ | 3294 | <String, Object?>{ |
| @@ -67,13 +67,10 @@ class HuaweiHealthRawStressCalculator { | @@ -67,13 +67,10 @@ class HuaweiHealthRawStressCalculator { | ||
| 67 | userId: userId, | 67 | userId: userId, |
| 68 | hrvStressPoints: hrvStressPoints, | 68 | hrvStressPoints: hrvStressPoints, |
| 69 | realtimeStressPoints: realtimeStressPoints, | 69 | realtimeStressPoints: realtimeStressPoints, |
| 70 | - dailyStressPoints: calculateDailyStressPoints( | ||
| 71 | - userId: userId, | ||
| 72 | - realtimePoints: realtimeStressPoints, | ||
| 73 | - startTime: startTime, | ||
| 74 | - endTime: endTime, | ||
| 75 | - dataTime: endTime, | ||
| 76 | - ), | 70 | + // OHOS calculates daily stress after realtime points are stored, so it |
| 71 | + // can include existing points for every affected day. Calculating it | ||
| 72 | + // here would duplicate CPU work and be incomplete for partial syncs. | ||
| 73 | + dailyStressPoints: const <HealthRawDailyStressPoint>[], | ||
| 77 | ); | 74 | ); |
| 78 | } | 75 | } |
| 79 | 76 |
| @@ -290,6 +290,23 @@ class OHOSHealthRawDataCoreService { | @@ -290,6 +290,23 @@ class OHOSHealthRawDataCoreService { | ||
| 290 | '${syncResults.fold<int>(0, (sum, result) => sum + result.storedCount)} ' | 290 | '${syncResults.fold<int>(0, (sum, result) => sum + result.storedCount)} ' |
| 291 | 'elapsedMs=${syncElapsed.inMilliseconds}', | 291 | 'elapsedMs=${syncElapsed.inMilliseconds}', |
| 292 | ); | 292 | ); |
| 293 | + if (willSyncRawData && | ||
| 294 | + syncSnapshot != null && | ||
| 295 | + !syncSnapshot.hasNewCalculationData) { | ||
| 296 | + _logInfo( | ||
| 297 | + '$_calculateLogMarker skip_no_new_base_data userId=$userId ' | ||
| 298 | + 'checkedDataTypes=hr,hrv,sleep', | ||
| 299 | + ); | ||
| 300 | + // Calculation and notifications require new base data. Keep the upload | ||
| 301 | + // retry path active so a previous failed result upload is not stranded. | ||
| 302 | + _scheduleResultUpload(); | ||
| 303 | + return HealthRawStressCalculationResult( | ||
| 304 | + userId: userId, | ||
| 305 | + hrvStressPoints: const <HealthRawHrvStressPoint>[], | ||
| 306 | + realtimeStressPoints: const <HealthRawRealtimeStressPoint>[], | ||
| 307 | + dailyStressPoints: const <HealthRawDailyStressPoint>[], | ||
| 308 | + ); | ||
| 309 | + } | ||
| 293 | 310 | ||
| 294 | final calculationStartTime = DateTime.now(); | 311 | final calculationStartTime = DateTime.now(); |
| 295 | _publishCalculationEvent( | 312 | _publishCalculationEvent( |
| @@ -394,25 +411,26 @@ class OHOSHealthRawDataCoreService { | @@ -394,25 +411,26 @@ class OHOSHealthRawDataCoreService { | ||
| 394 | var hasExistingSleep = false; | 411 | var hasExistingSleep = false; |
| 395 | try { | 412 | try { |
| 396 | final contextStopwatch = Stopwatch()..start(); | 413 | final contextStopwatch = Stopwatch()..start(); |
| 397 | - final hrvContextStart = | ||
| 398 | - await _localStore.latestHrvSourceStartTime(userId); | ||
| 399 | - final realtimeContextStart = | ||
| 400 | - await _localStore.latestRealtimeSourceStartTime(userId); | ||
| 401 | - final latestHrvRawEndTime = await _localStore.latestHrvRawEndTime(userId); | ||
| 402 | - final latestRealtimeRawEndTime = | ||
| 403 | - await _localStore.latestRealtimeRawEndTime(userId); | ||
| 404 | - final latestSleepResultTime = await _localStore.latestSleepResultTime( | ||
| 405 | - userId, | ||
| 406 | - ); | ||
| 407 | - final latestRawHrvDataTime = await _latestOhosRawDataTime( | ||
| 408 | - HealthDataUploadType.hrv.type, | ||
| 409 | - ); | ||
| 410 | - final latestRawHrDataTime = await _latestOhosRawDataTime( | ||
| 411 | - HealthDataUploadType.heartRate.type, | ||
| 412 | - ); | ||
| 413 | - final latestRawSleepDataTime = await _latestOhosRawDataTime( | ||
| 414 | - OhosHealthRawDataType.sleepAnalysis, | ||
| 415 | - ); | 414 | + // These reads have no dependencies. Start them together so separate |
| 415 | + // SQLite databases/platform channels do not extend the critical path. | ||
| 416 | + final context = await Future.wait<int?>([ | ||
| 417 | + _localStore.latestHrvSourceStartTime(userId), | ||
| 418 | + _localStore.latestRealtimeSourceStartTime(userId), | ||
| 419 | + _localStore.latestHrvRawEndTime(userId), | ||
| 420 | + _localStore.latestRealtimeRawEndTime(userId), | ||
| 421 | + _localStore.latestSleepResultTime(userId), | ||
| 422 | + _latestOhosRawDataTime(HealthDataUploadType.hrv.type), | ||
| 423 | + _latestOhosRawDataTime(HealthDataUploadType.heartRate.type), | ||
| 424 | + _latestOhosRawDataTime(OhosHealthRawDataType.sleepAnalysis), | ||
| 425 | + ]); | ||
| 426 | + final hrvContextStart = context[0]; | ||
| 427 | + final realtimeContextStart = context[1]; | ||
| 428 | + final latestHrvRawEndTime = context[2]; | ||
| 429 | + final latestRealtimeRawEndTime = context[3]; | ||
| 430 | + final latestSleepResultTime = context[4]; | ||
| 431 | + final latestRawHrvDataTime = context[5]; | ||
| 432 | + final latestRawHrDataTime = context[6]; | ||
| 433 | + final latestRawSleepDataTime = context[7]; | ||
| 416 | _profileLog( | 434 | _profileLog( |
| 417 | 'calculate_contextQuery_finish userId=$userId ' | 435 | 'calculate_contextQuery_finish userId=$userId ' |
| 418 | 'elapsedMs=${contextStopwatch.elapsedMilliseconds}', | 436 | 'elapsedMs=${contextStopwatch.elapsedMilliseconds}', |
| @@ -500,14 +518,48 @@ class OHOSHealthRawDataCoreService { | @@ -500,14 +518,48 @@ class OHOSHealthRawDataCoreService { | ||
| 500 | 'realtimeRecomputeStartTime=$realtimeRecomputeStartTime', | 518 | 'realtimeRecomputeStartTime=$realtimeRecomputeStartTime', |
| 501 | ); | 519 | ); |
| 502 | 520 | ||
| 521 | + // Raw types are independent. Create every future before awaiting one so | ||
| 522 | + // their reads overlap; stress dependency is enforced only below. | ||
| 503 | final hrvFetchStopwatch = Stopwatch()..start(); | 523 | final hrvFetchStopwatch = Stopwatch()..start(); |
| 504 | - final hrvPoints = await _fetchRawDataForCalculation( | 524 | + final heartRateFetchStopwatch = Stopwatch()..start(); |
| 525 | + final restingHeartRateFetchStopwatch = Stopwatch()..start(); | ||
| 526 | + final sleepFetchStopwatch = Stopwatch()..start(); | ||
| 527 | + final workoutFetchStopwatch = Stopwatch()..start(); | ||
| 528 | + final hrvPointsFuture = _fetchRawDataForCalculation( | ||
| 505 | HealthDataUploadType.hrv.type, | 529 | HealthDataUploadType.hrv.type, |
| 506 | hrvStartTime, | 530 | hrvStartTime, |
| 507 | effectiveEndTime, | 531 | effectiveEndTime, |
| 508 | rawDataSnapshot: rawDataSnapshot, | 532 | rawDataSnapshot: rawDataSnapshot, |
| 509 | readChunkDays: readChunkDays, | 533 | readChunkDays: readChunkDays, |
| 510 | ); | 534 | ); |
| 535 | + final heartRatePointsFuture = _fetchRawDataForCalculation( | ||
| 536 | + HealthDataUploadType.heartRate.type, | ||
| 537 | + heartRateStartTime, | ||
| 538 | + effectiveEndTime, | ||
| 539 | + rawDataSnapshot: rawDataSnapshot, | ||
| 540 | + readChunkDays: readChunkDays, | ||
| 541 | + ); | ||
| 542 | + final restingHeartRatePointsFuture = _fetchRawDataForCalculation( | ||
| 543 | + HealthDataUploadType.restingHeartRate.type, | ||
| 544 | + heartRateStartTime, | ||
| 545 | + effectiveEndTime, | ||
| 546 | + rawDataSnapshot: rawDataSnapshot, | ||
| 547 | + readChunkDays: readChunkDays, | ||
| 548 | + ); | ||
| 549 | + final sleepIntervalsFuture = _fetchSleepIntervalsForCalculation( | ||
| 550 | + sleepStartTime, | ||
| 551 | + effectiveEndTime, | ||
| 552 | + rawDataSnapshot: rawDataSnapshot, | ||
| 553 | + readChunkDays: readChunkDays, | ||
| 554 | + ); | ||
| 555 | + final workoutIntervalsFuture = _fetchWorkoutIntervalsForCalculation( | ||
| 556 | + heartRateStartTime, | ||
| 557 | + effectiveEndTime, | ||
| 558 | + rawDataSnapshot: rawDataSnapshot, | ||
| 559 | + readChunkDays: readChunkDays, | ||
| 560 | + ); | ||
| 561 | + | ||
| 562 | + final hrvPoints = await hrvPointsFuture; | ||
| 511 | _profileLog( | 563 | _profileLog( |
| 512 | 'calculate_fetchRaw_finish userId=$userId ' | 564 | 'calculate_fetchRaw_finish userId=$userId ' |
| 513 | 'name=hrv dataType=${HealthDataUploadType.hrv.type} ' | 565 | 'name=hrv dataType=${HealthDataUploadType.hrv.type} ' |
| @@ -515,14 +567,7 @@ class OHOSHealthRawDataCoreService { | @@ -515,14 +567,7 @@ class OHOSHealthRawDataCoreService { | ||
| 515 | 'count=${hrvPoints.length} ' | 567 | 'count=${hrvPoints.length} ' |
| 516 | 'elapsedMs=${hrvFetchStopwatch.elapsedMilliseconds}', | 568 | 'elapsedMs=${hrvFetchStopwatch.elapsedMilliseconds}', |
| 517 | ); | 569 | ); |
| 518 | - final heartRateFetchStopwatch = Stopwatch()..start(); | ||
| 519 | - final heartRatePoints = await _fetchRawDataForCalculation( | ||
| 520 | - HealthDataUploadType.heartRate.type, | ||
| 521 | - heartRateStartTime, | ||
| 522 | - effectiveEndTime, | ||
| 523 | - rawDataSnapshot: rawDataSnapshot, | ||
| 524 | - readChunkDays: readChunkDays, | ||
| 525 | - ); | 570 | + final heartRatePoints = await heartRatePointsFuture; |
| 526 | _profileLog( | 571 | _profileLog( |
| 527 | 'calculate_fetchRaw_finish userId=$userId ' | 572 | 'calculate_fetchRaw_finish userId=$userId ' |
| 528 | 'name=heartRate dataType=${HealthDataUploadType.heartRate.type} ' | 573 | 'name=heartRate dataType=${HealthDataUploadType.heartRate.type} ' |
| @@ -530,14 +575,7 @@ class OHOSHealthRawDataCoreService { | @@ -530,14 +575,7 @@ class OHOSHealthRawDataCoreService { | ||
| 530 | 'count=${heartRatePoints.length} ' | 575 | 'count=${heartRatePoints.length} ' |
| 531 | 'elapsedMs=${heartRateFetchStopwatch.elapsedMilliseconds}', | 576 | 'elapsedMs=${heartRateFetchStopwatch.elapsedMilliseconds}', |
| 532 | ); | 577 | ); |
| 533 | - final restingHeartRateFetchStopwatch = Stopwatch()..start(); | ||
| 534 | - final restingHeartRatePoints = await _fetchRawDataForCalculation( | ||
| 535 | - HealthDataUploadType.restingHeartRate.type, | ||
| 536 | - heartRateStartTime, | ||
| 537 | - effectiveEndTime, | ||
| 538 | - rawDataSnapshot: rawDataSnapshot, | ||
| 539 | - readChunkDays: readChunkDays, | ||
| 540 | - ); | 578 | + final restingHeartRatePoints = await restingHeartRatePointsFuture; |
| 541 | _profileLog( | 579 | _profileLog( |
| 542 | 'calculate_fetchRaw_finish userId=$userId ' | 580 | 'calculate_fetchRaw_finish userId=$userId ' |
| 543 | 'name=restingHeartRate ' | 581 | 'name=restingHeartRate ' |
| @@ -546,26 +584,14 @@ class OHOSHealthRawDataCoreService { | @@ -546,26 +584,14 @@ class OHOSHealthRawDataCoreService { | ||
| 546 | 'count=${restingHeartRatePoints.length} ' | 584 | 'count=${restingHeartRatePoints.length} ' |
| 547 | 'elapsedMs=${restingHeartRateFetchStopwatch.elapsedMilliseconds}', | 585 | 'elapsedMs=${restingHeartRateFetchStopwatch.elapsedMilliseconds}', |
| 548 | ); | 586 | ); |
| 549 | - final sleepFetchStopwatch = Stopwatch()..start(); | ||
| 550 | - final sleepIntervals = await _fetchSleepIntervalsForCalculation( | ||
| 551 | - sleepStartTime, | ||
| 552 | - effectiveEndTime, | ||
| 553 | - rawDataSnapshot: rawDataSnapshot, | ||
| 554 | - readChunkDays: readChunkDays, | ||
| 555 | - ); | 587 | + final sleepIntervals = await sleepIntervalsFuture; |
| 556 | _profileLog( | 588 | _profileLog( |
| 557 | 'calculate_fetchRaw_finish userId=$userId name=sleep ' | 589 | 'calculate_fetchRaw_finish userId=$userId name=sleep ' |
| 558 | 'startTime=$sleepStartTime endTime=$effectiveEndTime ' | 590 | 'startTime=$sleepStartTime endTime=$effectiveEndTime ' |
| 559 | 'count=${sleepIntervals.length} ' | 591 | 'count=${sleepIntervals.length} ' |
| 560 | 'elapsedMs=${sleepFetchStopwatch.elapsedMilliseconds}', | 592 | 'elapsedMs=${sleepFetchStopwatch.elapsedMilliseconds}', |
| 561 | ); | 593 | ); |
| 562 | - final workoutFetchStopwatch = Stopwatch()..start(); | ||
| 563 | - final workoutIntervals = await _fetchWorkoutIntervalsForCalculation( | ||
| 564 | - heartRateStartTime, | ||
| 565 | - effectiveEndTime, | ||
| 566 | - rawDataSnapshot: rawDataSnapshot, | ||
| 567 | - readChunkDays: readChunkDays, | ||
| 568 | - ); | 594 | + final workoutIntervals = await workoutIntervalsFuture; |
| 569 | _profileLog( | 595 | _profileLog( |
| 570 | 'calculate_fetchRaw_finish userId=$userId name=workout ' | 596 | 'calculate_fetchRaw_finish userId=$userId name=workout ' |
| 571 | 'startTime=$heartRateStartTime endTime=$effectiveEndTime ' | 597 | 'startTime=$heartRateStartTime endTime=$effectiveEndTime ' |
| @@ -589,6 +615,14 @@ class OHOSHealthRawDataCoreService { | @@ -589,6 +615,14 @@ class OHOSHealthRawDataCoreService { | ||
| 589 | 'needSleep=${_needsNewResult(latestRawSleepTime, latestSleepResultTime)}', | 615 | 'needSleep=${_needsNewResult(latestRawSleepTime, latestSleepResultTime)}', |
| 590 | ); | 616 | ); |
| 591 | 617 | ||
| 618 | + // Sleep has no result dependency on either stress calculation. Run its | ||
| 619 | + // CPU work alongside the HRV -> realtime-stress isolate; only storage is | ||
| 620 | + // sequenced later to avoid concurrent writes to the result database. | ||
| 621 | + final sleepCalculationStopwatch = Stopwatch()..start(); | ||
| 622 | + final sleepCalculationFuture = _calculateSleepResults( | ||
| 623 | + userId: userId, | ||
| 624 | + sleepIntervals: sleepIntervals, | ||
| 625 | + ); | ||
| 592 | final isolateStopwatch = Stopwatch()..start(); | 626 | final isolateStopwatch = Stopwatch()..start(); |
| 593 | final result = await Isolate.run( | 627 | final result = await Isolate.run( |
| 594 | () => HuaweiHealthRawStressCalculator(userId: userId).calculate( | 628 | () => HuaweiHealthRawStressCalculator(userId: userId).calculate( |
| @@ -671,15 +705,14 @@ class OHOSHealthRawDataCoreService { | @@ -671,15 +705,14 @@ class OHOSHealthRawDataCoreService { | ||
| 671 | 'count=${dailyStressPoints.length} ' | 705 | 'count=${dailyStressPoints.length} ' |
| 672 | 'elapsedMs=${dailyStopwatch.elapsedMilliseconds}', | 706 | 'elapsedMs=${dailyStopwatch.elapsedMilliseconds}', |
| 673 | ); | 707 | ); |
| 674 | - final sleepStopwatch = Stopwatch()..start(); | ||
| 675 | - final sleepResults = await _calculateAndStoreSleepResults( | 708 | + final sleepResults = await sleepCalculationFuture; |
| 709 | + await _storeSleepResults( | ||
| 676 | userId: userId, | 710 | userId: userId, |
| 677 | - sleepIntervals: sleepIntervals, | ||
| 678 | - latestSleepResultTime: latestSleepResultTime, | 711 | + results: sleepResults, |
| 679 | ); | 712 | ); |
| 680 | _profileLog( | 713 | _profileLog( |
| 681 | 'calculate_sleep_finish userId=$userId count=${sleepResults.length} ' | 714 | 'calculate_sleep_finish userId=$userId count=${sleepResults.length} ' |
| 682 | - 'elapsedMs=${sleepStopwatch.elapsedMilliseconds}', | 715 | + 'elapsedMs=${sleepCalculationStopwatch.elapsedMilliseconds}', |
| 683 | ); | 716 | ); |
| 684 | _logInfo( | 717 | _logInfo( |
| 685 | 'calculate_daily_sleep_stored userId=$userId ' | 718 | 'calculate_daily_sleep_stored userId=$userId ' |
| @@ -1440,31 +1473,59 @@ class OHOSHealthRawDataCoreService { | @@ -1440,31 +1473,59 @@ class OHOSHealthRawDataCoreService { | ||
| 1440 | return dailyStressPoints; | 1473 | return dailyStressPoints; |
| 1441 | } | 1474 | } |
| 1442 | 1475 | ||
| 1443 | - Future<List<HealthRawSleepResult>> _calculateAndStoreSleepResults({ | 1476 | + Future<List<HealthRawSleepResult>> _calculateSleepResults({ |
| 1444 | required int userId, | 1477 | required int userId, |
| 1445 | required List<HealthKitRawDataPoint> sleepIntervals, | 1478 | required List<HealthKitRawDataPoint> sleepIntervals, |
| 1446 | - required int? latestSleepResultTime, | ||
| 1447 | }) async { | 1479 | }) async { |
| 1448 | _logInfo( | 1480 | _logInfo( |
| 1449 | '$_sleepCalcLogMarker start userId=$userId ' | 1481 | '$_sleepCalcLogMarker start userId=$userId ' |
| 1450 | - 'sleepIntervals=${sleepIntervals.length} ' | ||
| 1451 | - 'latestSleepResultTime=$latestSleepResultTime', | 1482 | + 'sleepIntervals=${sleepIntervals.length}', |
| 1452 | ); | 1483 | ); |
| 1453 | if (sleepIntervals.isEmpty) { | 1484 | if (sleepIntervals.isEmpty) { |
| 1454 | _logInfo('$_sleepCalcLogMarker no_raw_sleep userId=$userId'); | 1485 | _logInfo('$_sleepCalcLogMarker no_raw_sleep userId=$userId'); |
| 1455 | return const <HealthRawSleepResult>[]; | 1486 | return const <HealthRawSleepResult>[]; |
| 1456 | } | 1487 | } |
| 1488 | + final stopwatch = Stopwatch()..start(); | ||
| 1489 | + final results = await Isolate.run( | ||
| 1490 | + () => _calculateSleepResultsSync( | ||
| 1491 | + userId: userId, | ||
| 1492 | + sleepIntervals: sleepIntervals, | ||
| 1493 | + ), | ||
| 1494 | + debugName: 'OHOSHealthSleepCalculator', | ||
| 1495 | + ); | ||
| 1496 | + _profileLog( | ||
| 1497 | + 'sleep_isolate_finish userId=$userId ' | ||
| 1498 | + 'input=${sleepIntervals.length} result=${results.length} ' | ||
| 1499 | + 'elapsedMs=${stopwatch.elapsedMilliseconds}', | ||
| 1500 | + ); | ||
| 1501 | + return results; | ||
| 1502 | + } | ||
| 1503 | + | ||
| 1504 | + Future<void> _storeSleepResults({ | ||
| 1505 | + required int userId, | ||
| 1506 | + required List<HealthRawSleepResult> results, | ||
| 1507 | + }) async { | ||
| 1508 | + final sleepStoreStopwatch = Stopwatch()..start(); | ||
| 1509 | + await _localStore.upsertSleepResults(userId: userId, results: results); | ||
| 1510 | + _profileLog( | ||
| 1511 | + 'sleep_store_finish userId=$userId stored=${results.length} ' | ||
| 1512 | + 'elapsedMs=${sleepStoreStopwatch.elapsedMilliseconds}', | ||
| 1513 | + ); | ||
| 1514 | + _logInfo( | ||
| 1515 | + '$_sleepCalcLogMarker stored userId=$userId stored=${results.length}', | ||
| 1516 | + ); | ||
| 1517 | + } | ||
| 1518 | + | ||
| 1519 | + static List<HealthRawSleepResult> _calculateSleepResultsSync({ | ||
| 1520 | + required int userId, | ||
| 1521 | + required List<HealthKitRawDataPoint> sleepIntervals, | ||
| 1522 | + }) { | ||
| 1457 | final days = { | 1523 | final days = { |
| 1458 | for (final interval in sleepIntervals) _localDay(interval.endTime), | 1524 | for (final interval in sleepIntervals) _localDay(interval.endTime), |
| 1459 | }.toList() | 1525 | }.toList() |
| 1460 | ..sort((a, b) => a.compareTo(b)); | 1526 | ..sort((a, b) => a.compareTo(b)); |
| 1461 | - _logInfo( | ||
| 1462 | - '$_sleepCalcLogMarker days userId=$userId ' | ||
| 1463 | - 'days=${days.map(_dateKeyFromDateTime).toList()}', | ||
| 1464 | - ); | ||
| 1465 | final results = <HealthRawSleepResult>[]; | 1527 | final results = <HealthRawSleepResult>[]; |
| 1466 | for (final day in days) { | 1528 | for (final day in days) { |
| 1467 | - final dayStopwatch = Stopwatch()..start(); | ||
| 1468 | final calculation = HealthSleepCalculator.calculateDay( | 1529 | final calculation = HealthSleepCalculator.calculateDay( |
| 1469 | day: day, | 1530 | day: day, |
| 1470 | sleepIntervals: sleepIntervals, | 1531 | sleepIntervals: sleepIntervals, |
| @@ -1473,49 +1534,13 @@ class OHOSHealthRawDataCoreService { | @@ -1473,49 +1534,13 @@ class OHOSHealthRawDataCoreService { | ||
| 1473 | final score = calculation.score; | 1534 | final score = calculation.score; |
| 1474 | final state = calculation.state; | 1535 | final state = calculation.state; |
| 1475 | if (merged == null || score == null || state == null) { | 1536 | if (merged == null || score == null || state == null) { |
| 1476 | - _profileLog( | ||
| 1477 | - 'sleep_day_finish userId=$userId ' | ||
| 1478 | - 'date=${_dateKeyFromDateTime(day)} hasResult=false ' | ||
| 1479 | - 'reason=missing_score_or_state ' | ||
| 1480 | - 'elapsedMs=${dayStopwatch.elapsedMilliseconds}', | ||
| 1481 | - ); | ||
| 1482 | - _logInfo( | ||
| 1483 | - '$_sleepCalcLogMarker skip_invalid userId=$userId ' | ||
| 1484 | - 'date=${_dateKeyFromDateTime(day)} ' | ||
| 1485 | - 'reason=missing_score_or_state ' | ||
| 1486 | - 'score=$score state=${state?.value}', | ||
| 1487 | - ); | ||
| 1488 | continue; | 1537 | continue; |
| 1489 | } | 1538 | } |
| 1490 | if (!calculation.hasValidSleep) { | 1539 | if (!calculation.hasValidSleep) { |
| 1491 | - _profileLog( | ||
| 1492 | - 'sleep_day_finish userId=$userId ' | ||
| 1493 | - 'date=${_dateKeyFromDateTime(day)} hasResult=false ' | ||
| 1494 | - 'reason=no_valid_sleep ' | ||
| 1495 | - 'elapsedMs=${dayStopwatch.elapsedMilliseconds}', | ||
| 1496 | - ); | ||
| 1497 | - _logInfo( | ||
| 1498 | - '$_sleepCalcLogMarker skip_invalid userId=$userId ' | ||
| 1499 | - 'date=${_dateKeyFromDateTime(day)} reason=no_valid_sleep ' | ||
| 1500 | - 'durationSeconds=${calculation.durationSeconds}', | ||
| 1501 | - ); | ||
| 1502 | - continue; | ||
| 1503 | - } | ||
| 1504 | - if (latestSleepResultTime != null && | ||
| 1505 | - merged.endTime <= latestSleepResultTime) { | ||
| 1506 | - _profileLog( | ||
| 1507 | - 'sleep_day_finish userId=$userId ' | ||
| 1508 | - 'date=${_dateKeyFromDateTime(day)} hasResult=false ' | ||
| 1509 | - 'reason=existing mergedEnd=${merged.endTime} ' | ||
| 1510 | - 'elapsedMs=${dayStopwatch.elapsedMilliseconds}', | ||
| 1511 | - ); | ||
| 1512 | - _logInfo( | ||
| 1513 | - '$_sleepCalcLogMarker skip_existing userId=$userId ' | ||
| 1514 | - 'date=${_dateKeyFromDateTime(day)} mergedEnd=${merged.endTime} ' | ||
| 1515 | - 'latestSleepResultTime=$latestSleepResultTime', | ||
| 1516 | - ); | ||
| 1517 | continue; | 1540 | continue; |
| 1518 | } | 1541 | } |
| 1542 | + // A newer result does not prove that older days were calculated. | ||
| 1543 | + // Reconcile every day in the input; the store preserves unchanged uploads. | ||
| 1519 | results.add( | 1544 | results.add( |
| 1520 | HealthRawSleepResult( | 1545 | HealthRawSleepResult( |
| 1521 | userId: userId, | 1546 | userId: userId, |
| @@ -1529,28 +1554,7 @@ class OHOSHealthRawDataCoreService { | @@ -1529,28 +1554,7 @@ class OHOSHealthRawDataCoreService { | ||
| 1529 | uploaded: false, | 1554 | uploaded: false, |
| 1530 | ), | 1555 | ), |
| 1531 | ); | 1556 | ); |
| 1532 | - _profileLog( | ||
| 1533 | - 'sleep_day_finish userId=$userId ' | ||
| 1534 | - 'date=${_dateKeyFromDateTime(day)} hasResult=true ' | ||
| 1535 | - 'sleepMinutes=${calculation.summary.sleepMinutes} ' | ||
| 1536 | - 'elapsedMs=${dayStopwatch.elapsedMilliseconds}', | ||
| 1537 | - ); | ||
| 1538 | - _logInfo( | ||
| 1539 | - '$_sleepCalcLogMarker result userId=$userId ' | ||
| 1540 | - 'date=${_dateKeyFromDateTime(day)} start=${merged.startTime} ' | ||
| 1541 | - 'end=${merged.endTime} score=$score state=${state.value} ' | ||
| 1542 | - 'sleepMinutes=${calculation.summary.sleepMinutes}', | ||
| 1543 | - ); | ||
| 1544 | } | 1557 | } |
| 1545 | - final sleepStoreStopwatch = Stopwatch()..start(); | ||
| 1546 | - await _localStore.upsertSleepResults(userId: userId, results: results); | ||
| 1547 | - _profileLog( | ||
| 1548 | - 'sleep_store_finish userId=$userId stored=${results.length} ' | ||
| 1549 | - 'elapsedMs=${sleepStoreStopwatch.elapsedMilliseconds}', | ||
| 1550 | - ); | ||
| 1551 | - _logInfo( | ||
| 1552 | - '$_sleepCalcLogMarker stored userId=$userId stored=${results.length}', | ||
| 1553 | - ); | ||
| 1554 | return results; | 1558 | return results; |
| 1555 | } | 1559 | } |
| 1556 | 1560 | ||
| @@ -1626,15 +1630,30 @@ class OHOSHealthRawDataCoreService { | @@ -1626,15 +1630,30 @@ class OHOSHealthRawDataCoreService { | ||
| 1626 | }) async { | 1630 | }) async { |
| 1627 | final rawDataSource = _rawDataSource; | 1631 | final rawDataSource = _rawDataSource; |
| 1628 | if (rawDataSnapshot != null && rawDataSource is OhosHealthRawDataSource) { | 1632 | if (rawDataSnapshot != null && rawDataSource is OhosHealthRawDataSource) { |
| 1629 | - final points = rawDataSource.getRawDataFromSnapshot( | ||
| 1630 | - snapshot: rawDataSnapshot, | ||
| 1631 | - dataType: dataType, | ||
| 1632 | - startTime: startTime, | ||
| 1633 | - endTime: endTime, | ||
| 1634 | - )..sort((a, b) => a.endTime.compareTo(b.endTime)); | 1633 | + // The snapshot is complete for this fetch, but it does not include the |
| 1634 | + // older local context required for baselines and smoothing. Overlay the | ||
| 1635 | + // fetched values on the indexed local range before calculating. | ||
| 1636 | + final localPoints = await rawDataSource.getRawData( | ||
| 1637 | + dataType, | ||
| 1638 | + startTime, | ||
| 1639 | + endTime, | ||
| 1640 | + ); | ||
| 1641 | + final pointsByTime = <int, HealthKitRawDataPoint>{ | ||
| 1642 | + for (final point in localPoints) point.endTime: point, | ||
| 1643 | + for (final point in rawDataSource.getRawDataFromSnapshot( | ||
| 1644 | + snapshot: rawDataSnapshot, | ||
| 1645 | + dataType: dataType, | ||
| 1646 | + startTime: startTime, | ||
| 1647 | + endTime: endTime, | ||
| 1648 | + )) | ||
| 1649 | + point.endTime: point, | ||
| 1650 | + }; | ||
| 1651 | + final points = pointsByTime.values.toList() | ||
| 1652 | + ..sort((a, b) => a.endTime.compareTo(b.endTime)); | ||
| 1635 | _logInfo( | 1653 | _logInfo( |
| 1636 | - '$_calculateLogMarker raw_snapshot_hit dataType=$dataType ' | ||
| 1637 | - 'startTime=$startTime endTime=$endTime count=${points.length}', | 1654 | + '$_calculateLogMarker raw_local_snapshot_merged ' |
| 1655 | + 'dataType=$dataType startTime=$startTime endTime=$endTime ' | ||
| 1656 | + 'local=${localPoints.length} merged=${points.length}', | ||
| 1638 | ); | 1657 | ); |
| 1639 | return points; | 1658 | return points; |
| 1640 | } | 1659 | } |
| @@ -1669,24 +1688,35 @@ class OHOSHealthRawDataCoreService { | @@ -1669,24 +1688,35 @@ class OHOSHealthRawDataCoreService { | ||
| 1669 | required OhosHealthRawDataMemorySnapshot? rawDataSnapshot, | 1688 | required OhosHealthRawDataMemorySnapshot? rawDataSnapshot, |
| 1670 | required int readChunkDays, | 1689 | required int readChunkDays, |
| 1671 | }) async { | 1690 | }) async { |
| 1691 | + // The snapshot contains only this sync's response, not all persisted sleep. | ||
| 1692 | + // Read the bounded local range once and overlay the snapshot so calculation | ||
| 1693 | + // is complete even while the snapshot is still being written in background. | ||
| 1694 | + final groups = await _rawDataSource.getRawSleepData(startTime, endTime); | ||
| 1695 | + final pointsByStart = <int, HealthKitRawDataPoint>{ | ||
| 1696 | + for (final point in groups.expand((group) => group.sleepDataPoints)) | ||
| 1697 | + point.startTime: point, | ||
| 1698 | + }; | ||
| 1672 | final rawDataSource = _rawDataSource; | 1699 | final rawDataSource = _rawDataSource; |
| 1673 | if (rawDataSnapshot != null && rawDataSource is OhosHealthRawDataSource) { | 1700 | if (rawDataSnapshot != null && rawDataSource is OhosHealthRawDataSource) { |
| 1674 | - final points = rawDataSource.getRawSleepIntervalsFromSnapshot( | 1701 | + for (final point in rawDataSource.getRawSleepIntervalsFromSnapshot( |
| 1675 | snapshot: rawDataSnapshot, | 1702 | snapshot: rawDataSnapshot, |
| 1676 | startTime: startTime, | 1703 | startTime: startTime, |
| 1677 | endTime: endTime, | 1704 | endTime: endTime, |
| 1678 | - )..sort((a, b) => a.endTime.compareTo(b.endTime)); | ||
| 1679 | - _logInfo( | ||
| 1680 | - '$_calculateLogMarker sleep_snapshot_hit ' | ||
| 1681 | - 'startTime=$startTime endTime=$endTime count=${points.length}', | ||
| 1682 | - ); | ||
| 1683 | - return points; | 1705 | + )) { |
| 1706 | + // OHOS raw sleep uses from_time as its unique key. | ||
| 1707 | + pointsByStart[point.startTime] = point; | ||
| 1708 | + } | ||
| 1684 | } | 1709 | } |
| 1685 | - return _fetchSleepIntervalsInChunks( | ||
| 1686 | - startTime, | ||
| 1687 | - endTime, | ||
| 1688 | - readChunkDays: readChunkDays, | 1710 | + final points = pointsByStart.values |
| 1711 | + .where( | ||
| 1712 | + (point) => point.endTime >= startTime && point.startTime <= endTime) | ||
| 1713 | + .toList() | ||
| 1714 | + ..sort((a, b) => a.endTime.compareTo(b.endTime)); | ||
| 1715 | + _logInfo( | ||
| 1716 | + '$_calculateLogMarker sleep_local_snapshot_merged ' | ||
| 1717 | + 'startTime=$startTime endTime=$endTime count=${points.length}', | ||
| 1689 | ); | 1718 | ); |
| 1719 | + return points; | ||
| 1690 | } | 1720 | } |
| 1691 | 1721 | ||
| 1692 | Future<List<HealthKitRawWorkoutDataPoint>> _fetchWorkoutIntervalsInChunks( | 1722 | Future<List<HealthKitRawWorkoutDataPoint>> _fetchWorkoutIntervalsInChunks( |
| @@ -1715,14 +1745,25 @@ class OHOSHealthRawDataCoreService { | @@ -1715,14 +1745,25 @@ class OHOSHealthRawDataCoreService { | ||
| 1715 | }) async { | 1745 | }) async { |
| 1716 | final rawDataSource = _rawDataSource; | 1746 | final rawDataSource = _rawDataSource; |
| 1717 | if (rawDataSnapshot != null && rawDataSource is OhosHealthRawDataSource) { | 1747 | if (rawDataSnapshot != null && rawDataSource is OhosHealthRawDataSource) { |
| 1718 | - final points = rawDataSource.getRawWorkoutDataFromSnapshot( | ||
| 1719 | - snapshot: rawDataSnapshot, | ||
| 1720 | - startTime: startTime, | ||
| 1721 | - endTime: endTime, | ||
| 1722 | - )..sort((a, b) => a.endTime.compareTo(b.endTime)); | 1748 | + final localPoints = await rawDataSource.getRawWorkoutData( |
| 1749 | + startTime, | ||
| 1750 | + endTime, | ||
| 1751 | + ); | ||
| 1752 | + final pointsByStart = <int, HealthKitRawWorkoutDataPoint>{ | ||
| 1753 | + for (final point in localPoints) point.startTime: point, | ||
| 1754 | + for (final point in rawDataSource.getRawWorkoutDataFromSnapshot( | ||
| 1755 | + snapshot: rawDataSnapshot, | ||
| 1756 | + startTime: startTime, | ||
| 1757 | + endTime: endTime, | ||
| 1758 | + )) | ||
| 1759 | + point.startTime: point, | ||
| 1760 | + }; | ||
| 1761 | + final points = pointsByStart.values.toList() | ||
| 1762 | + ..sort((a, b) => a.endTime.compareTo(b.endTime)); | ||
| 1723 | _logInfo( | 1763 | _logInfo( |
| 1724 | - '$_calculateLogMarker workout_snapshot_hit ' | ||
| 1725 | - 'startTime=$startTime endTime=$endTime count=${points.length}', | 1764 | + '$_calculateLogMarker workout_local_snapshot_merged ' |
| 1765 | + 'startTime=$startTime endTime=$endTime ' | ||
| 1766 | + 'local=${localPoints.length} merged=${points.length}', | ||
| 1726 | ); | 1767 | ); |
| 1727 | return points; | 1768 | return points; |
| 1728 | } | 1769 | } |
| @@ -89,12 +89,16 @@ class OhosHealthRawDataCalculationSyncSnapshot { | @@ -89,12 +89,16 @@ class OhosHealthRawDataCalculationSyncSnapshot { | ||
| 89 | const OhosHealthRawDataCalculationSyncSnapshot({ | 89 | const OhosHealthRawDataCalculationSyncSnapshot({ |
| 90 | required this.results, | 90 | required this.results, |
| 91 | required this.rawData, | 91 | required this.rawData, |
| 92 | + required this.newCalculationDataTypes, | ||
| 92 | this.activityGoal, | 93 | this.activityGoal, |
| 93 | this.storeFuture, | 94 | this.storeFuture, |
| 94 | }); | 95 | }); |
| 95 | 96 | ||
| 96 | final List<OhosHealthRawDataSyncResult> results; | 97 | final List<OhosHealthRawDataSyncResult> results; |
| 97 | final OhosHealthRawDataMemorySnapshot rawData; | 98 | final OhosHealthRawDataMemorySnapshot rawData; |
| 99 | + final Set<int> newCalculationDataTypes; | ||
| 98 | final V2ActivityTarget? activityGoal; | 100 | final V2ActivityTarget? activityGoal; |
| 99 | final Future<void>? storeFuture; | 101 | final Future<void>? storeFuture; |
| 102 | + | ||
| 103 | + bool get hasNewCalculationData => newCalculationDataTypes.isNotEmpty; | ||
| 100 | } | 104 | } |
| @@ -310,6 +310,7 @@ class OhosHealthRawDataSyncService { | @@ -310,6 +310,7 @@ class OhosHealthRawDataSyncService { | ||
| 310 | final rawData = OhosHealthRawDataMemorySnapshot( | 310 | final rawData = OhosHealthRawDataMemorySnapshot( |
| 311 | results: results, | 311 | results: results, |
| 312 | ); | 312 | ); |
| 313 | + final newCalculationDataTypes = await _newCalculationDataTypes(results); | ||
| 313 | final pageCount = results.fold<int>( | 314 | final pageCount = results.fold<int>( |
| 314 | 0, | 315 | 0, |
| 315 | (sum, result) => sum + result.pageCount, | 316 | (sum, result) => sum + result.pageCount, |
| @@ -335,7 +336,8 @@ class OhosHealthRawDataSyncService { | @@ -335,7 +336,8 @@ class OhosHealthRawDataSyncService { | ||
| 335 | 'calculation_sync_snapshot_finish ' | 336 | 'calculation_sync_snapshot_finish ' |
| 336 | 'dataTypes=${resolvedDataTypes.join(',')} ' | 337 | 'dataTypes=${resolvedDataTypes.join(',')} ' |
| 337 | 'pageCount=$pageCount fetchedCount=$fetchedCount ' | 338 | 'pageCount=$pageCount fetchedCount=$fetchedCount ' |
| 338 | - 'snapshotCount=${rawData.length}', | 339 | + 'snapshotCount=${rawData.length} ' |
| 340 | + 'newCalculationDataTypes=${newCalculationDataTypes.join(',')}', | ||
| 339 | ); | 341 | ); |
| 340 | _log( | 342 | _log( |
| 341 | '$timingLogMarker fetch_all_finish ' | 343 | '$timingLogMarker fetch_all_finish ' |
| @@ -356,6 +358,7 @@ class OhosHealthRawDataSyncService { | @@ -356,6 +358,7 @@ class OhosHealthRawDataSyncService { | ||
| 356 | return OhosHealthRawDataCalculationSyncSnapshot( | 358 | return OhosHealthRawDataCalculationSyncSnapshot( |
| 357 | results: results, | 359 | results: results, |
| 358 | rawData: rawData, | 360 | rawData: rawData, |
| 361 | + newCalculationDataTypes: newCalculationDataTypes, | ||
| 359 | activityGoal: activityGoal, | 362 | activityGoal: activityGoal, |
| 360 | storeFuture: storeFuture, | 363 | storeFuture: storeFuture, |
| 361 | ); | 364 | ); |
| @@ -385,6 +388,48 @@ class OhosHealthRawDataSyncService { | @@ -385,6 +388,48 @@ class OhosHealthRawDataSyncService { | ||
| 385 | } | 388 | } |
| 386 | } | 389 | } |
| 387 | 390 | ||
| 391 | + Future<Set<int>> _newCalculationDataTypes( | ||
| 392 | + List<OhosHealthRawDataSyncResult> results, | ||
| 393 | + ) async { | ||
| 394 | + final triggerDataTypes = <int>{ | ||
| 395 | + HealthDataUploadType.heartRate.type, | ||
| 396 | + HealthDataUploadType.hrv.type, | ||
| 397 | + OhosHealthRawDataType.sleepAnalysis, | ||
| 398 | + }; | ||
| 399 | + final types = <int>{}; | ||
| 400 | + for (final result in results) { | ||
| 401 | + final dataType = result.dataType; | ||
| 402 | + if (!triggerDataTypes.contains(dataType) || result.rawItems.isEmpty) { | ||
| 403 | + continue; | ||
| 404 | + } | ||
| 405 | + final incomingTimes = result.rawItems | ||
| 406 | + .map((item) => _calculationTimestamp(dataType, item)) | ||
| 407 | + .toSet(); | ||
| 408 | + if (incomingTimes.isEmpty) continue; | ||
| 409 | + final startTime = incomingTimes.reduce(math.min); | ||
| 410 | + final endTime = incomingTimes.reduce(math.max); | ||
| 411 | + final localItems = await _localStore.queryRawData( | ||
| 412 | + dataType: dataType, | ||
| 413 | + startTime: startTime, | ||
| 414 | + endTime: endTime, | ||
| 415 | + ); | ||
| 416 | + final existingTimes = localItems | ||
| 417 | + .map((item) => _calculationTimestamp(dataType, item)) | ||
| 418 | + .toSet(); | ||
| 419 | + if (incomingTimes.any((time) => !existingTimes.contains(time))) { | ||
| 420 | + types.add(dataType); | ||
| 421 | + } | ||
| 422 | + } | ||
| 423 | + return Set<int>.unmodifiable(types); | ||
| 424 | + } | ||
| 425 | + | ||
| 426 | + int _calculationTimestamp(int dataType, OhosHealthRawDataItem item) { | ||
| 427 | + if (dataType == OhosHealthRawDataType.sleepAnalysis) { | ||
| 428 | + return _numPayload(item.payload, 'to_time') ?? item.dataTime; | ||
| 429 | + } | ||
| 430 | + return _numPayload(item.payload, 'time') ?? item.dataTime; | ||
| 431 | + } | ||
| 432 | + | ||
| 388 | Future<V2ActivityTarget?> _fetchActivityGoalForCalculationSync() async { | 433 | Future<V2ActivityTarget?> _fetchActivityGoalForCalculationSync() async { |
| 389 | final stopwatch = Stopwatch()..start(); | 434 | final stopwatch = Stopwatch()..start(); |
| 390 | try { | 435 | try { |
| @@ -705,9 +750,18 @@ class OhosHealthRawDataSyncService { | @@ -705,9 +750,18 @@ class OhosHealthRawDataSyncService { | ||
| 705 | for (final result in results) { | 750 | for (final result in results) { |
| 706 | if (result.rawItems.isEmpty) continue; | 751 | if (result.rawItems.isEmpty) continue; |
| 707 | final typeStopwatch = Stopwatch()..start(); | 752 | final typeStopwatch = Stopwatch()..start(); |
| 753 | + // Requests finish out of order. Persist each table chronologically after | ||
| 754 | + // every type/segment has been fetched, using its SQLite unique time key. | ||
| 755 | + final sortedItems = [...result.rawItems]..sort((a, b) { | ||
| 756 | + final keyCompare = _dedupeKeyTime(dataType: result.dataType, item: a) | ||
| 757 | + .compareTo(_dedupeKeyTime(dataType: result.dataType, item: b)); | ||
| 758 | + return keyCompare != 0 | ||
| 759 | + ? keyCompare | ||
| 760 | + : a.dataTime.compareTo(b.dataTime); | ||
| 761 | + }); | ||
| 708 | final count = await _localStore.upsertRawDataBatch( | 762 | final count = await _localStore.upsertRawDataBatch( |
| 709 | dataType: result.dataType, | 763 | dataType: result.dataType, |
| 710 | - items: result.rawItems, | 764 | + items: sortedItems, |
| 711 | ); | 765 | ); |
| 712 | storedCount += count; | 766 | storedCount += count; |
| 713 | _log( | 767 | _log( |
| 1 | +import 'dart:async'; | ||
| 2 | + | ||
| 1 | import 'package:doublefeel_flutter/core/services/raw_data_service/health_raw_data_source.dart'; | 3 | import 'package:doublefeel_flutter/core/services/raw_data_service/health_raw_data_source.dart'; |
| 2 | import 'package:doublefeel_flutter/core/services/raw_data_service/health_raw_models.dart'; | 4 | import 'package:doublefeel_flutter/core/services/raw_data_service/health_raw_models.dart'; |
| 3 | import 'package:doublefeel_flutter/core/services/raw_data_service/platform_ios/apple_health_raw_data_core_service.dart'; | 5 | import 'package:doublefeel_flutter/core/services/raw_data_service/platform_ios/apple_health_raw_data_core_service.dart'; |
| @@ -10,6 +12,66 @@ import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart'; | @@ -10,6 +12,66 @@ import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart'; | ||
| 10 | import 'package:flutter_test/flutter_test.dart'; | 12 | import 'package:flutter_test/flutter_test.dart'; |
| 11 | 13 | ||
| 12 | void main() { | 14 | void main() { |
| 15 | + for (final hasSnapshotSleep in [false, true]) { | ||
| 16 | + test( | ||
| 17 | + 'OHOS repairs September 6 sleep with newer result; ' | ||
| 18 | + 'snapshot=$hasSnapshotSleep and raw write pending', () async { | ||
| 19 | + int seconds(DateTime date) => date.millisecondsSinceEpoch ~/ 1000; | ||
| 20 | + final start = seconds(DateTime(2026, 9, 5, 23)); | ||
| 21 | + final middle = seconds(DateTime(2026, 9, 6, 3)); | ||
| 22 | + final end = seconds(DateTime(2026, 9, 6, 7)); | ||
| 23 | + final gate = Completer<void>(); | ||
| 24 | + addTearDown(() { | ||
| 25 | + if (!gate.isCompleted) gate.complete(); | ||
| 26 | + }); | ||
| 27 | + OhosHealthRawDataItem sleep(int from, int to) => OhosHealthRawDataItem( | ||
| 28 | + dataType: 4, | ||
| 29 | + dataTime: to, | ||
| 30 | + payload: {'from_time': from, 'to_time': to}, | ||
| 31 | + ); | ||
| 32 | + final rawStore = _FakeOhosHealthRawDataLocalStore( | ||
| 33 | + latestDataTime: end, | ||
| 34 | + storeGate: gate.future, | ||
| 35 | + itemsByDataType: { | ||
| 36 | + OhosHealthRawDataType.sleepAnalysis: [ | ||
| 37 | + sleep(start, hasSnapshotSleep ? middle : end), | ||
| 38 | + ], | ||
| 39 | + }, | ||
| 40 | + ); | ||
| 41 | + final store = _FakeHealthRawStressLocalStore() | ||
| 42 | + ..latestSleepTime = seconds(DateTime(2026, 9, 7, 7)); | ||
| 43 | + final service = OHOSHealthRawDataCoreService( | ||
| 44 | + rawDataSource: OhosHealthRawDataSource( | ||
| 45 | + syncService: OhosHealthRawDataSyncService( | ||
| 46 | + remoteDataSource: _HistoricalBackfillRemoteDataSource( | ||
| 47 | + hrvItems: const [], | ||
| 48 | + heartRateItems: const [], | ||
| 49 | + sleepItems: hasSnapshotSleep ? [sleep(middle, end)] : const [], | ||
| 50 | + ), | ||
| 51 | + localStore: rawStore, | ||
| 52 | + ), | ||
| 53 | + ), | ||
| 54 | + localStore: store, | ||
| 55 | + userIdProvider: () => 42, | ||
| 56 | + uploadResultsAfterCalculation: false, | ||
| 57 | + healthReadAuthorizationChecker: () async => true, | ||
| 58 | + ); | ||
| 59 | + | ||
| 60 | + final result = await service.startCoreCaculate( | ||
| 61 | + endTime: seconds(DateTime(2026, 9, 7, 12)), | ||
| 62 | + ); | ||
| 63 | + | ||
| 64 | + expect(result.sleepResults, hasLength(1)); | ||
| 65 | + expect(result.sleepResults.single.date, end); | ||
| 66 | + expect(result.sleepResults.single.startDate, start); | ||
| 67 | + expect(result.sleepResults.single.sleepMinutes, 8 * 60); | ||
| 68 | + expect(store.sleepResults.single.date, end); | ||
| 69 | + expect(rawStore.sleepQueryCount, 1); | ||
| 70 | + expect(gate.isCompleted, isFalse); | ||
| 71 | + expect(rawStore.storedBatches, isEmpty); | ||
| 72 | + }); | ||
| 73 | + } | ||
| 74 | + | ||
| 13 | test('OHOS core calculates and stores all Huawei result tables', () async { | 75 | test('OHOS core calculates and stores all Huawei result tables', () async { |
| 14 | final day = DateTime.now().subtract(const Duration(days: 7)); | 76 | final day = DateTime.now().subtract(const Duration(days: 7)); |
| 15 | final base = | 77 | final base = |
| @@ -515,10 +577,12 @@ class _HistoricalBackfillRemoteDataSource | @@ -515,10 +577,12 @@ class _HistoricalBackfillRemoteDataSource | ||
| 515 | _HistoricalBackfillRemoteDataSource({ | 577 | _HistoricalBackfillRemoteDataSource({ |
| 516 | required this.hrvItems, | 578 | required this.hrvItems, |
| 517 | required this.heartRateItems, | 579 | required this.heartRateItems, |
| 580 | + this.sleepItems = const [], | ||
| 518 | }); | 581 | }); |
| 519 | 582 | ||
| 520 | final List<OhosHealthRawDataItem> hrvItems; | 583 | final List<OhosHealthRawDataItem> hrvItems; |
| 521 | final List<OhosHealthRawDataItem> heartRateItems; | 584 | final List<OhosHealthRawDataItem> heartRateItems; |
| 585 | + final List<OhosHealthRawDataItem> sleepItems; | ||
| 522 | final calls = <_RemoteRawCall>[]; | 586 | final calls = <_RemoteRawCall>[]; |
| 523 | 587 | ||
| 524 | @override | 588 | @override |
| @@ -534,6 +598,9 @@ class _HistoricalBackfillRemoteDataSource | @@ -534,6 +598,9 @@ class _HistoricalBackfillRemoteDataSource | ||
| 534 | endTime: endTime, | 598 | endTime: endTime, |
| 535 | ), | 599 | ), |
| 536 | ); | 600 | ); |
| 601 | + if (dataType == OhosHealthRawDataType.sleepAnalysis) { | ||
| 602 | + return OhosHealthRawDataPage(items: sleepItems); | ||
| 603 | + } | ||
| 537 | if (dataType == 1) return OhosHealthRawDataPage(items: hrvItems); | 604 | if (dataType == 1) return OhosHealthRawDataPage(items: hrvItems); |
| 538 | if (dataType == 2) return OhosHealthRawDataPage(items: heartRateItems); | 605 | if (dataType == 2) return OhosHealthRawDataPage(items: heartRateItems); |
| 539 | return const OhosHealthRawDataPage(items: <OhosHealthRawDataItem>[]); | 606 | return const OhosHealthRawDataPage(items: <OhosHealthRawDataItem>[]); |
| @@ -559,6 +626,7 @@ class _RemoteRawCall { | @@ -559,6 +626,7 @@ class _RemoteRawCall { | ||
| 559 | 626 | ||
| 560 | class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore { | 627 | class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore { |
| 561 | _FakeOhosHealthRawDataLocalStore({ | 628 | _FakeOhosHealthRawDataLocalStore({ |
| 629 | + this.storeGate, | ||
| 562 | int? latestDataTime, | 630 | int? latestDataTime, |
| 563 | Map<int, int>? latestDataTimeByType, | 631 | Map<int, int>? latestDataTimeByType, |
| 564 | Map<int, List<OhosHealthRawDataItem>>? itemsByDataType, | 632 | Map<int, List<OhosHealthRawDataItem>>? itemsByDataType, |
| @@ -567,6 +635,8 @@ class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore { | @@ -567,6 +635,8 @@ class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore { | ||
| 567 | _itemsByDataType = | 635 | _itemsByDataType = |
| 568 | itemsByDataType ?? const <int, List<OhosHealthRawDataItem>>{}; | 636 | itemsByDataType ?? const <int, List<OhosHealthRawDataItem>>{}; |
| 569 | 637 | ||
| 638 | + final Future<void>? storeGate; | ||
| 639 | + int sleepQueryCount = 0; | ||
| 570 | final int? _latestDataTime; | 640 | final int? _latestDataTime; |
| 571 | final Map<int, int> _latestDataTimeByType; | 641 | final Map<int, int> _latestDataTimeByType; |
| 572 | final Map<int, List<OhosHealthRawDataItem>> _itemsByDataType; | 642 | final Map<int, List<OhosHealthRawDataItem>> _itemsByDataType; |
| @@ -583,13 +653,15 @@ class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore { | @@ -583,13 +653,15 @@ class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore { | ||
| 583 | required int startTime, | 653 | required int startTime, |
| 584 | required int endTime, | 654 | required int endTime, |
| 585 | }) async { | 655 | }) async { |
| 656 | + if (dataType == OhosHealthRawDataType.sleepAnalysis) sleepQueryCount++; | ||
| 586 | return [ | 657 | return [ |
| 587 | ...(_itemsByDataType[dataType] ?? const <OhosHealthRawDataItem>[]), | 658 | ...(_itemsByDataType[dataType] ?? const <OhosHealthRawDataItem>[]), |
| 588 | ...storedBatches.expand((batch) => batch), | 659 | ...storedBatches.expand((batch) => batch), |
| 589 | ] | 660 | ] |
| 590 | .where( | 661 | .where( |
| 591 | (item) => | 662 | (item) => |
| 592 | - item.dataType == dataType && | 663 | + (dataType == OhosHealthRawDataType.sleepAnalysis || |
| 664 | + item.dataType == dataType) && | ||
| 593 | item.dataTime >= startTime && | 665 | item.dataTime >= startTime && |
| 594 | item.dataTime <= endTime, | 666 | item.dataTime <= endTime, |
| 595 | ) | 667 | ) |
| @@ -609,6 +681,7 @@ class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore { | @@ -609,6 +681,7 @@ class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore { | ||
| 609 | required int dataType, | 681 | required int dataType, |
| 610 | required List<OhosHealthRawDataItem> items, | 682 | required List<OhosHealthRawDataItem> items, |
| 611 | }) async { | 683 | }) async { |
| 684 | + if (storeGate != null) await storeGate; | ||
| 612 | storedBatches.add(items); | 685 | storedBatches.add(items); |
| 613 | return items.length; | 686 | return items.length; |
| 614 | } | 687 | } |
| @@ -683,6 +756,7 @@ class _FakeHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -683,6 +756,7 @@ class _FakeHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 683 | final realtimeStressPoints = <HealthRawRealtimeStressPoint>[]; | 756 | final realtimeStressPoints = <HealthRawRealtimeStressPoint>[]; |
| 684 | final dailyStressPoints = <HealthRawDailyStressPoint>[]; | 757 | final dailyStressPoints = <HealthRawDailyStressPoint>[]; |
| 685 | final sleepResults = <HealthRawSleepResult>[]; | 758 | final sleepResults = <HealthRawSleepResult>[]; |
| 759 | + int? latestSleepTime; | ||
| 686 | 760 | ||
| 687 | @override | 761 | @override |
| 688 | Future<void> ensureReadable(int userId) async {} | 762 | Future<void> ensureReadable(int userId) async {} |
| @@ -711,7 +785,7 @@ class _FakeHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -711,7 +785,7 @@ class _FakeHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 711 | 785 | ||
| 712 | @override | 786 | @override |
| 713 | Future<int?> latestSleepResultTime(int userId) async { | 787 | Future<int?> latestSleepResultTime(int userId) async { |
| 714 | - return null; | 788 | + return latestSleepTime; |
| 715 | } | 789 | } |
| 716 | 790 | ||
| 717 | @override | 791 | @override |
| @@ -11,6 +11,128 @@ import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart'; | @@ -11,6 +11,128 @@ import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart'; | ||
| 11 | import 'package:flutter_test/flutter_test.dart'; | 11 | import 'package:flutter_test/flutter_test.dart'; |
| 12 | 12 | ||
| 13 | void main() { | 13 | void main() { |
| 14 | + test('calculation snapshot waits for every segment and stores sorted batches', | ||
| 15 | + () async { | ||
| 16 | + final remote = _ControlledSnapshotRemote(); | ||
| 17 | + final local = _FakeOhosHealthRawDataLocalStore(); | ||
| 18 | + final service = OhosHealthRawDataSyncService( | ||
| 19 | + remoteDataSource: remote, | ||
| 20 | + localStore: local, | ||
| 21 | + ); | ||
| 22 | + var snapshotReady = false; | ||
| 23 | + final future = service.syncCalculationRawDataSnapshot( | ||
| 24 | + startTime: _unixSeconds(DateTime(2026, 1, 30)), | ||
| 25 | + endTime: _unixSeconds(DateTime(2026, 2, 22)), | ||
| 26 | + dataTypes: [2, OhosHealthRawDataType.sleepAnalysis], | ||
| 27 | + ).then((snapshot) { | ||
| 28 | + snapshotReady = true; | ||
| 29 | + return snapshot; | ||
| 30 | + }); | ||
| 31 | + await Future<void>.delayed(Duration.zero); | ||
| 32 | + expect(remote.requests, hasLength(4)); | ||
| 33 | + | ||
| 34 | + // Newer ranges/types finish first; the oldest heart-rate range stays pending. | ||
| 35 | + for (var i = remote.requests.length - 1; i > 0; i--) { | ||
| 36 | + remote.complete(i); | ||
| 37 | + await Future<void>.delayed(Duration.zero); | ||
| 38 | + expect(snapshotReady, isFalse); | ||
| 39 | + expect(local.storedBatches, isEmpty); | ||
| 40 | + } | ||
| 41 | + remote.complete(0); | ||
| 42 | + final snapshot = await future; | ||
| 43 | + await snapshot.storeFuture; | ||
| 44 | + | ||
| 45 | + expect(snapshot.rawData.length, 8); | ||
| 46 | + expect( | ||
| 47 | + snapshot.newCalculationDataTypes, | ||
| 48 | + containsAll(<int>[2, OhosHealthRawDataType.sleepAnalysis]), | ||
| 49 | + ); | ||
| 50 | + expect(snapshot.results.fold<int>(0, (n, r) => n + r.pageCount), 4); | ||
| 51 | + expect(local.storedBatches, hasLength(2)); | ||
| 52 | + expect(local.storedBatches.map((batch) => batch.length), [6, 2]); | ||
| 53 | + for (final batch in local.storedBatches) { | ||
| 54 | + final times = batch.map((item) => item.dataTime).toList(); | ||
| 55 | + expect(times, orderedEquals([...times]..sort())); | ||
| 56 | + } | ||
| 57 | + }); | ||
| 58 | + | ||
| 59 | + test('failed snapshot segment prevents partial batch storage', () async { | ||
| 60 | + final remote = _ControlledSnapshotRemote(); | ||
| 61 | + final local = _FakeOhosHealthRawDataLocalStore(); | ||
| 62 | + final service = OhosHealthRawDataSyncService( | ||
| 63 | + remoteDataSource: remote, | ||
| 64 | + localStore: local, | ||
| 65 | + ); | ||
| 66 | + final future = service.syncCalculationRawDataSnapshot( | ||
| 67 | + startTime: _unixSeconds(DateTime(2026, 1, 30)), | ||
| 68 | + endTime: _unixSeconds(DateTime(2026, 2, 22)), | ||
| 69 | + dataTypes: [2], | ||
| 70 | + ); | ||
| 71 | + final failure = expectLater(future, throwsStateError); | ||
| 72 | + await Future<void>.delayed(Duration.zero); | ||
| 73 | + expect(remote.requests, hasLength(3)); | ||
| 74 | + remote.complete(2); | ||
| 75 | + remote.complete(1); | ||
| 76 | + await Future<void>.delayed(Duration.zero); | ||
| 77 | + expect(local.storedBatches, isEmpty); | ||
| 78 | + remote.requests.first.response.completeError(StateError('oldest failed')); | ||
| 79 | + await failure; | ||
| 80 | + expect(local.storedBatches, isEmpty); | ||
| 81 | + }); | ||
| 82 | + | ||
| 83 | + test('calculation snapshot ignores existing HR and sleep time keys', | ||
| 84 | + () async { | ||
| 85 | + const heartRateTime = 1769904001; | ||
| 86 | + const sleepEndTime = 1769907600; | ||
| 87 | + final remote = _FakeOhosHealthRawDataRemoteDataSource([ | ||
| 88 | + const OhosHealthRawDataPage( | ||
| 89 | + items: [ | ||
| 90 | + OhosHealthRawDataItem( | ||
| 91 | + dataType: 2, | ||
| 92 | + dataTime: heartRateTime, | ||
| 93 | + payload: {'time': heartRateTime, 'value': 70}, | ||
| 94 | + ), | ||
| 95 | + ], | ||
| 96 | + ), | ||
| 97 | + const OhosHealthRawDataPage( | ||
| 98 | + items: [ | ||
| 99 | + OhosHealthRawDataItem( | ||
| 100 | + dataType: OhosHealthRawDataType.sleepAnalysis, | ||
| 101 | + dataTime: sleepEndTime, | ||
| 102 | + payload: {'from_time': 1769886000, 'to_time': sleepEndTime}, | ||
| 103 | + ), | ||
| 104 | + ], | ||
| 105 | + ), | ||
| 106 | + ]); | ||
| 107 | + final local = _FakeOhosHealthRawDataLocalStore( | ||
| 108 | + existingItems: const [ | ||
| 109 | + OhosHealthRawDataItem( | ||
| 110 | + dataType: 2, | ||
| 111 | + dataTime: heartRateTime, | ||
| 112 | + payload: {'time': heartRateTime, 'value': 70}, | ||
| 113 | + ), | ||
| 114 | + OhosHealthRawDataItem( | ||
| 115 | + dataType: OhosHealthRawDataType.sleepAnalysis, | ||
| 116 | + dataTime: sleepEndTime, | ||
| 117 | + payload: {'from_time': 1769886000, 'to_time': sleepEndTime}, | ||
| 118 | + ), | ||
| 119 | + ], | ||
| 120 | + ); | ||
| 121 | + final service = OhosHealthRawDataSyncService( | ||
| 122 | + remoteDataSource: remote, | ||
| 123 | + localStore: local, | ||
| 124 | + ); | ||
| 125 | + | ||
| 126 | + final snapshot = await service.syncCalculationRawDataSnapshot( | ||
| 127 | + startTime: 1769817600, | ||
| 128 | + endTime: 1769907600, | ||
| 129 | + dataTypes: const [2, OhosHealthRawDataType.sleepAnalysis], | ||
| 130 | + ); | ||
| 131 | + | ||
| 132 | + expect(snapshot.newCalculationDataTypes, isEmpty); | ||
| 133 | + expect(snapshot.hasNewCalculationData, isFalse); | ||
| 134 | + }); | ||
| 135 | + | ||
| 14 | test('syncRawData starts from latest local data time and stores fetched data', | 136 | test('syncRawData starts from latest local data time and stores fetched data', |
| 15 | () async { | 137 | () async { |
| 16 | final remote = _FakeOhosHealthRawDataRemoteDataSource([ | 138 | final remote = _FakeOhosHealthRawDataRemoteDataSource([ |
| @@ -375,7 +497,8 @@ void main() { | @@ -375,7 +497,8 @@ void main() { | ||
| 375 | expect(result.pageCount, 3); | 497 | expect(result.pageCount, 3); |
| 376 | }); | 498 | }); |
| 377 | 499 | ||
| 378 | - test('syncRawData limits segment fetch concurrency to ten', () async { | 500 | + test('syncRawData starts every segment within the two-month fetch range', |
| 501 | + () async { | ||
| 379 | final start = _unixSeconds(DateTime(2026, 1)); | 502 | final start = _unixSeconds(DateTime(2026, 1)); |
| 380 | final end = _unixSeconds(DateTime(2026, 4, 30)); | 503 | final end = _unixSeconds(DateTime(2026, 4, 30)); |
| 381 | final gate = Completer<void>(); | 504 | final gate = Completer<void>(); |
| @@ -393,18 +516,18 @@ void main() { | @@ -393,18 +516,18 @@ void main() { | ||
| 393 | startTime: start, | 516 | startTime: start, |
| 394 | endTime: end, | 517 | endTime: end, |
| 395 | ); | 518 | ); |
| 396 | - for (var i = 0; i < 20 && remote.calls.length < 10; i += 1) { | 519 | + for (var i = 0; i < 20 && remote.calls.length < 6; i += 1) { |
| 397 | await Future<void>.delayed(const Duration(milliseconds: 1)); | 520 | await Future<void>.delayed(const Duration(milliseconds: 1)); |
| 398 | } | 521 | } |
| 399 | 522 | ||
| 400 | - expect(remote.calls, hasLength(10)); | 523 | + expect(remote.calls, hasLength(6)); |
| 401 | 524 | ||
| 402 | gate.complete(); | 525 | gate.complete(); |
| 403 | final result = await sync; | 526 | final result = await sync; |
| 404 | 527 | ||
| 405 | - expect(result.segmentCount, 12); | ||
| 406 | - expect(result.pageCount, 12); | ||
| 407 | - expect(remote.calls, hasLength(12)); | 528 | + expect(result.segmentCount, 6); |
| 529 | + expect(result.pageCount, 6); | ||
| 530 | + expect(remote.calls, hasLength(6)); | ||
| 408 | }); | 531 | }); |
| 409 | 532 | ||
| 410 | test('syncRawData joins duplicate in-flight syncs', () async { | 533 | test('syncRawData joins duplicate in-flight syncs', () async { |
| @@ -807,11 +930,14 @@ class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore { | @@ -807,11 +930,14 @@ class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore { | ||
| 807 | _FakeOhosHealthRawDataLocalStore({ | 930 | _FakeOhosHealthRawDataLocalStore({ |
| 808 | int? latestDataTime, | 931 | int? latestDataTime, |
| 809 | Map<int, int>? latestDataTimeByType, | 932 | Map<int, int>? latestDataTimeByType, |
| 933 | + List<OhosHealthRawDataItem> existingItems = const <OhosHealthRawDataItem>[], | ||
| 810 | }) : _latestDataTime = latestDataTime, | 934 | }) : _latestDataTime = latestDataTime, |
| 811 | - _latestDataTimeByType = latestDataTimeByType ?? const <int, int>{}; | 935 | + _latestDataTimeByType = latestDataTimeByType ?? const <int, int>{}, |
| 936 | + _existingItems = existingItems; | ||
| 812 | 937 | ||
| 813 | final int? _latestDataTime; | 938 | final int? _latestDataTime; |
| 814 | final Map<int, int> _latestDataTimeByType; | 939 | final Map<int, int> _latestDataTimeByType; |
| 940 | + final List<OhosHealthRawDataItem> _existingItems; | ||
| 815 | final List<List<OhosHealthRawDataItem>> storedBatches = | 941 | final List<List<OhosHealthRawDataItem>> storedBatches = |
| 816 | <List<OhosHealthRawDataItem>>[]; | 942 | <List<OhosHealthRawDataItem>>[]; |
| 817 | V2ActivityTarget? activityGoal; | 943 | V2ActivityTarget? activityGoal; |
| @@ -836,8 +962,10 @@ class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore { | @@ -836,8 +962,10 @@ class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore { | ||
| 836 | required int startTime, | 962 | required int startTime, |
| 837 | required int endTime, | 963 | required int endTime, |
| 838 | }) async { | 964 | }) async { |
| 839 | - return storedBatches | ||
| 840 | - .expand((batch) => batch) | 965 | + return <OhosHealthRawDataItem>[ |
| 966 | + ..._existingItems, | ||
| 967 | + ...storedBatches.expand((batch) => batch), | ||
| 968 | + ] | ||
| 841 | .where( | 969 | .where( |
| 842 | (item) => | 970 | (item) => |
| 843 | item.dataType == dataType && | 971 | item.dataType == dataType && |
| @@ -967,3 +1095,41 @@ int _dateKey(int seconds) { | @@ -967,3 +1095,41 @@ int _dateKey(int seconds) { | ||
| 967 | final dateTime = DateTime.fromMillisecondsSinceEpoch(seconds * 1000); | 1095 | final dateTime = DateTime.fromMillisecondsSinceEpoch(seconds * 1000); |
| 968 | return dateTime.year * 10000 + dateTime.month * 100 + dateTime.day; | 1096 | return dateTime.year * 10000 + dateTime.month * 100 + dateTime.day; |
| 969 | } | 1097 | } |
| 1098 | + | ||
| 1099 | +class _ControlledSnapshotRemote implements OhosHealthRawDataRemoteDataSource { | ||
| 1100 | + final requests = <({ | ||
| 1101 | + int dataType, | ||
| 1102 | + int start, | ||
| 1103 | + Completer<OhosHealthRawDataPage> response | ||
| 1104 | + })>[]; | ||
| 1105 | + | ||
| 1106 | + @override | ||
| 1107 | + Future<OhosHealthRawDataPage> fetchRawDataPage({ | ||
| 1108 | + required int dataType, | ||
| 1109 | + required int startTime, | ||
| 1110 | + required int endTime, | ||
| 1111 | + }) { | ||
| 1112 | + final response = Completer<OhosHealthRawDataPage>(); | ||
| 1113 | + requests.add((dataType: dataType, start: startTime, response: response)); | ||
| 1114 | + return response.future; | ||
| 1115 | + } | ||
| 1116 | + | ||
| 1117 | + void complete(int index) { | ||
| 1118 | + final request = requests[index]; | ||
| 1119 | + request.response.complete(OhosHealthRawDataPage(items: [ | ||
| 1120 | + for (final offset in [2, 1]) | ||
| 1121 | + OhosHealthRawDataItem( | ||
| 1122 | + dataType: request.dataType, | ||
| 1123 | + dataTime: request.start + offset, | ||
| 1124 | + payload: { | ||
| 1125 | + 'time': request.start + offset, | ||
| 1126 | + 'from_time': request.start + offset, | ||
| 1127 | + 'to_time': request.start + offset, | ||
| 1128 | + }, | ||
| 1129 | + ), | ||
| 1130 | + ])); | ||
| 1131 | + } | ||
| 1132 | + | ||
| 1133 | + @override | ||
| 1134 | + Future<V2ActivityTarget?> fetchActivityGoal() async => null; | ||
| 1135 | +} |
-
Please register or login to post a comment