@cariva-dev/exercise-sdk 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CarivaExerciseSdk.podspec +20 -0
  2. package/README.md +374 -0
  3. package/android/build.gradle +66 -0
  4. package/android/src/main/AndroidManifest.xml +40 -0
  5. package/android/src/main/java/com/carivaexercisesdk/CarivaExerciseSdkPackage.kt +30 -0
  6. package/android/src/main/java/com/carivaexercisesdk/HealthConnectModule.kt +222 -0
  7. package/android/src/main/java/com/carivaexercisesdk/HealthConnectPermissionUsageActivity.kt +11 -0
  8. package/android/src/main/java/com/carivaexercisesdk/HealthConnectPermissionsRationaleActivity.kt +11 -0
  9. package/android/src/main/java/com/carivaexercisesdk/Pagination.kt +23 -0
  10. package/android/src/main/java/com/carivaexercisesdk/application/connection/command/ConnectCommand.kt +12 -0
  11. package/android/src/main/java/com/carivaexercisesdk/application/connection/dto/ConnectResultDto.kt +13 -0
  12. package/android/src/main/java/com/carivaexercisesdk/application/connection/handler/ConnectHandler.kt +79 -0
  13. package/android/src/main/java/com/carivaexercisesdk/application/connection/port/HealthConnectConnectionPort.kt +16 -0
  14. package/android/src/main/java/com/carivaexercisesdk/application/connection/port/PermissionRequestPort.kt +5 -0
  15. package/android/src/main/java/com/carivaexercisesdk/application/datasource/command/SetDatasourcePolicyCommand.kt +6 -0
  16. package/android/src/main/java/com/carivaexercisesdk/application/datasource/handler/GetDatasourcePolicyHandler.kt +10 -0
  17. package/android/src/main/java/com/carivaexercisesdk/application/datasource/handler/SetDatasourcePolicyHandler.kt +22 -0
  18. package/android/src/main/java/com/carivaexercisesdk/application/datasource/port/DatasourcePolicyRepository.kt +9 -0
  19. package/android/src/main/java/com/carivaexercisesdk/application/exercise/dto/BasalReadResultDto.kt +11 -0
  20. package/android/src/main/java/com/carivaexercisesdk/application/exercise/dto/ExerciseDataDto.kt +41 -0
  21. package/android/src/main/java/com/carivaexercisesdk/application/exercise/handler/GetExerciseDataHandler.kt +346 -0
  22. package/android/src/main/java/com/carivaexercisesdk/application/exercise/port/ExerciseRecordPort.kt +45 -0
  23. package/android/src/main/java/com/carivaexercisesdk/application/exercise/query/GetExerciseDataQuery.kt +9 -0
  24. package/android/src/main/java/com/carivaexercisesdk/domain/connection/entity/ConnectionAggregate.kt +13 -0
  25. package/android/src/main/java/com/carivaexercisesdk/domain/connection/service/ConnectionDecisionService.kt +37 -0
  26. package/android/src/main/java/com/carivaexercisesdk/domain/connection/valueobject/ConnectionNextAction.kt +8 -0
  27. package/android/src/main/java/com/carivaexercisesdk/domain/connection/valueobject/HealthConnectStatus.kt +8 -0
  28. package/android/src/main/java/com/carivaexercisesdk/domain/connection/valueobject/PermissionScope.kt +36 -0
  29. package/android/src/main/java/com/carivaexercisesdk/domain/datasource/entity/DatasourcePolicy.kt +8 -0
  30. package/android/src/main/java/com/carivaexercisesdk/domain/datasource/valueobject/DatasourceType.kt +14 -0
  31. package/android/src/main/java/com/carivaexercisesdk/domain/exercise/services/ExerciseDomainService.kt +89 -0
  32. package/android/src/main/java/com/carivaexercisesdk/domain/exercise/services/HealthConnectCaloriesFallbackMerger.kt +351 -0
  33. package/android/src/main/java/com/carivaexercisesdk/domain/exercise/services/HealthConnectInternalsConstants.kt +55 -0
  34. package/android/src/main/java/com/carivaexercisesdk/domain/exercise/services/HealthConnectModuleInternals.kt +316 -0
  35. package/android/src/main/java/com/carivaexercisesdk/domain/exercise/services/HealthConnectOverlapNormalizer.kt +400 -0
  36. package/android/src/main/java/com/carivaexercisesdk/domain/exercise/services/HealthConnectSourceTrust.kt +249 -0
  37. package/android/src/main/java/com/carivaexercisesdk/domain/exercise/services/HealthConnectUnifiedRecordBuilder.kt +316 -0
  38. package/android/src/main/java/com/carivaexercisesdk/infrastructure/healthconnect/HealthConnectConnectionAdapter.kt +156 -0
  39. package/android/src/main/java/com/carivaexercisesdk/infrastructure/healthconnect/HealthConnectExerciseRecordAdapter.kt +464 -0
  40. package/android/src/main/java/com/carivaexercisesdk/infrastructure/persistence/InMemoryDatasourcePolicyRepository.kt +21 -0
  41. package/android/src/main/java/com/carivaexercisesdk/infrastructure/reactnative/ConnectResultMapper.kt +23 -0
  42. package/android/src/main/java/com/carivaexercisesdk/infrastructure/reactnative/DatasourcePolicyJsonParser.kt +51 -0
  43. package/android/src/main/java/com/carivaexercisesdk/infrastructure/reactnative/DatasourcePolicyMapper.kt +46 -0
  44. package/android/src/main/java/com/carivaexercisesdk/infrastructure/reactnative/ExerciseDataMapper.kt +99 -0
  45. package/android/src/test/java/com/carivaexercisesdk/ArchitectureDependencyRuleTest.kt +60 -0
  46. package/android/src/test/java/com/carivaexercisesdk/HealthConnectModuleDatasourceParserTest.kt +69 -0
  47. package/android/src/test/java/com/carivaexercisesdk/HealthConnectModuleInternalsTest.kt +406 -0
  48. package/android/src/test/java/com/carivaexercisesdk/application/connection/handler/ConnectHandlerTest.kt +153 -0
  49. package/android/src/test/java/com/carivaexercisesdk/application/datasource/handler/DatasourcePolicyRoundTripTest.kt +63 -0
  50. package/android/src/test/java/com/carivaexercisesdk/application/datasource/handler/SetDatasourcePolicyHandlerTest.kt +42 -0
  51. package/android/src/test/java/com/carivaexercisesdk/domain/connection/service/ConnectionDecisionServiceTest.kt +68 -0
  52. package/android/src/test/java/com/carivaexercisesdk/infrastructure/reactnative/DatasourcePolicyMapperTest.kt +22 -0
  53. package/ios/CarivaExerciseSdk.h +5 -0
  54. package/ios/CarivaExerciseSdk.mm +7 -0
  55. package/lib/module/connect/index.js +7 -0
  56. package/lib/module/connect/index.js.map +1 -0
  57. package/lib/module/datasource/index.js +10 -0
  58. package/lib/module/datasource/index.js.map +1 -0
  59. package/lib/module/exercise/index.js +7 -0
  60. package/lib/module/exercise/index.js.map +1 -0
  61. package/lib/module/index.js +6 -0
  62. package/lib/module/index.js.map +1 -0
  63. package/lib/module/native/module.js +13 -0
  64. package/lib/module/native/module.js.map +1 -0
  65. package/lib/module/package.json +1 -0
  66. package/lib/typescript/package.json +1 -0
  67. package/lib/typescript/src/connect/index.d.ts +16 -0
  68. package/lib/typescript/src/connect/index.d.ts.map +1 -0
  69. package/lib/typescript/src/datasource/index.d.ts +12 -0
  70. package/lib/typescript/src/datasource/index.d.ts.map +1 -0
  71. package/lib/typescript/src/exercise/index.d.ts +64 -0
  72. package/lib/typescript/src/exercise/index.d.ts.map +1 -0
  73. package/lib/typescript/src/index.d.ts +4 -0
  74. package/lib/typescript/src/index.d.ts.map +1 -0
  75. package/lib/typescript/src/native/module.d.ts +14 -0
  76. package/lib/typescript/src/native/module.d.ts.map +1 -0
  77. package/package.json +127 -0
  78. package/src/connect/index.ts +34 -0
  79. package/src/datasource/index.ts +20 -0
  80. package/src/exercise/index.ts +75 -0
  81. package/src/index.tsx +22 -0
  82. package/src/native/module.ts +23 -0
@@ -0,0 +1,346 @@
1
+ package com.carivaexercisesdk.application.exercise.handler
2
+
3
+ import com.carivaexercisesdk.domain.exercise.services.HealthConnectModuleInternals
4
+ import com.carivaexercisesdk.application.exercise.dto.BasalReadResultDto
5
+ import com.carivaexercisesdk.application.exercise.dto.ExerciseDataDto
6
+ import com.carivaexercisesdk.application.exercise.dto.ExerciseDebugDto
7
+ import com.carivaexercisesdk.application.exercise.dto.ExerciseDiagnosticsDto
8
+ import com.carivaexercisesdk.application.exercise.port.ExerciseRecordPort
9
+ import com.carivaexercisesdk.application.exercise.query.GetExerciseDataQuery
10
+ import com.carivaexercisesdk.domain.connection.valueobject.HealthConnectStatus
11
+ import com.carivaexercisesdk.domain.exercise.services.ExerciseDomainService
12
+ import java.time.Instant
13
+ import java.time.ZoneId
14
+ import kotlinx.coroutines.async
15
+ import kotlinx.coroutines.coroutineScope
16
+
17
+ internal class GetExerciseDataHandler(private val exerciseRecordPort: ExerciseRecordPort) {
18
+ suspend fun handle(query: GetExerciseDataQuery): ExerciseDataDto {
19
+ val healthConnectStatus = exerciseRecordPort.getHealthConnectStatus()
20
+ if (healthConnectStatus != HealthConnectStatus.AVAILABLE) {
21
+ throw ExerciseDataException(
22
+ code = "E_HEALTH_CONNECT_UNAVAILABLE",
23
+ detail = "Health Connect SDK status is not available: $healthConnectStatus"
24
+ )
25
+ }
26
+
27
+ val startInstant = Instant.ofEpochMilli(query.sinceMillis)
28
+ val endInstant = Instant.ofEpochMilli(query.untilMillis)
29
+
30
+ if (!ExerciseDomainService.isValidPeriod(startInstant, endInstant)) {
31
+ throw ExerciseDataException(
32
+ code = "E_INVALID_PERIOD",
33
+ detail = "Maximum period is 90 days and start must be <= end."
34
+ )
35
+ }
36
+
37
+ if (!ExerciseDomainService.isSupportedBucketPeriod(query.bucketPeriod)) {
38
+ throw ExerciseDataException(
39
+ code = "E_UNSUPPORTED_BUCKET_PERIOD",
40
+ detail =
41
+ "Unsupported bucketPeriod '${query.bucketPeriod}'. Supported values: hourly, daily."
42
+ )
43
+ }
44
+
45
+ val normalizedBucketPeriod = ExerciseDomainService.normalizeBucketPeriod(query.bucketPeriod)
46
+ val zoneId = ZoneId.systemDefault()
47
+
48
+ val includeSteps = query.type == "steps" || query.type == "all"
49
+ val includeActiveTimes = query.type == "activetimes" || query.type == "all"
50
+ val includeCalories = query.type == "calories" || query.type == "all"
51
+ val includeDistances = query.type == "distances" || query.type == "all"
52
+
53
+ if (!includeSteps && !includeActiveTimes && !includeCalories && !includeDistances) {
54
+ throw ExerciseDataException(
55
+ code = "E_UNSUPPORTED_TYPE",
56
+ detail =
57
+ "Unsupported type '${query.type}'. Supported values: steps, activetimes, calories, distances, all."
58
+ )
59
+ }
60
+
61
+ val normalizedSteps =
62
+ if (includeSteps) {
63
+ ExerciseDomainService.normalizeOverlaps(
64
+ metricType = HealthConnectModuleInternals.MetricType.STEPS,
65
+ rawIntervals = exerciseRecordPort.readStepsIntervals(startInstant, endInstant),
66
+ sinceMs = query.sinceMillis,
67
+ untilMs = query.untilMillis,
68
+ bucketPeriod = normalizedBucketPeriod,
69
+ zoneId = zoneId
70
+ )
71
+ } else {
72
+ ExerciseDomainService.emptyNormalizationResult("source_priority")
73
+ }
74
+
75
+ val normalizedActiveTimes =
76
+ if (includeActiveTimes) {
77
+ ExerciseDomainService.normalizeOverlaps(
78
+ metricType = HealthConnectModuleInternals.MetricType.ACTIVE_TIME,
79
+ rawIntervals =
80
+ exerciseRecordPort.readActiveTimeIntervals(startInstant, endInstant),
81
+ sinceMs = query.sinceMillis,
82
+ untilMs = query.untilMillis,
83
+ bucketPeriod = normalizedBucketPeriod,
84
+ zoneId = zoneId
85
+ )
86
+ } else {
87
+ ExerciseDomainService.emptyNormalizationResult("proportional")
88
+ }
89
+
90
+ val normalizedDistances =
91
+ if (includeDistances) {
92
+ ExerciseDomainService.normalizeOverlaps(
93
+ metricType = HealthConnectModuleInternals.MetricType.DISTANCE,
94
+ rawIntervals = exerciseRecordPort.readDistanceIntervals(startInstant, endInstant),
95
+ sinceMs = query.sinceMillis,
96
+ untilMs = query.untilMillis,
97
+ bucketPeriod = normalizedBucketPeriod,
98
+ zoneId = zoneId
99
+ )
100
+ } else {
101
+ ExerciseDomainService.emptyNormalizationResult("proportional")
102
+ }
103
+
104
+ val caloriesFallbackEvidence =
105
+ if (includeCalories && query.type == "calories") {
106
+ loadCaloriesFallbackEvidence(
107
+ startInstant = startInstant,
108
+ endInstant = endInstant,
109
+ sinceMs = query.sinceMillis,
110
+ untilMs = query.untilMillis,
111
+ bucketPeriod = normalizedBucketPeriod,
112
+ zoneId = zoneId
113
+ )
114
+ } else {
115
+ CaloriesFallbackEvidenceDto(
116
+ stepsSegments = normalizedSteps.segments,
117
+ activeTimeSegments = normalizedActiveTimes.segments,
118
+ distanceSegments = normalizedDistances.segments
119
+ )
120
+ }
121
+
122
+ var basalReadResult = BasalReadResultDto(intervals = emptyList(), basalSeedApplied = false)
123
+ val normalizedCalories =
124
+ if (includeCalories) {
125
+ val normalizedActiveCalories =
126
+ ExerciseDomainService.normalizeOverlaps(
127
+ metricType = HealthConnectModuleInternals.MetricType.CALORIES,
128
+ rawIntervals = exerciseRecordPort.readCaloriesIntervals(startInstant, endInstant),
129
+ sinceMs = query.sinceMillis,
130
+ untilMs = query.untilMillis,
131
+ bucketPeriod = normalizedBucketPeriod,
132
+ zoneId = zoneId
133
+ )
134
+ val normalizedTotalCalories =
135
+ ExerciseDomainService.normalizeOverlaps(
136
+ metricType = HealthConnectModuleInternals.MetricType.CALORIES,
137
+ rawIntervals =
138
+ exerciseRecordPort.readTotalCaloriesIntervals(
139
+ startInstant,
140
+ endInstant
141
+ ),
142
+ sinceMs = query.sinceMillis,
143
+ untilMs = query.untilMillis,
144
+ bucketPeriod = normalizedBucketPeriod,
145
+ zoneId = zoneId
146
+ )
147
+ basalReadResult = exerciseRecordPort.readBasalCaloriesIntervals(startInstant, endInstant)
148
+ val normalizedBasalCalories =
149
+ ExerciseDomainService.normalizeOverlaps(
150
+ metricType = HealthConnectModuleInternals.MetricType.CALORIES,
151
+ rawIntervals = basalReadResult.intervals,
152
+ sinceMs = query.sinceMillis,
153
+ untilMs = query.untilMillis,
154
+ bucketPeriod = normalizedBucketPeriod,
155
+ zoneId = zoneId
156
+ )
157
+
158
+ ExerciseDomainService.mergeCaloriesWithFallback(
159
+ primaryActiveCalories = normalizedActiveCalories,
160
+ totalCalories = normalizedTotalCalories,
161
+ basalCalories = normalizedBasalCalories,
162
+ stepsEvidenceSegments = caloriesFallbackEvidence.stepsSegments,
163
+ activeTimeEvidenceSegments = caloriesFallbackEvidence.activeTimeSegments,
164
+ distanceEvidenceSegments = caloriesFallbackEvidence.distanceSegments,
165
+ basalSeedApplied = basalReadResult.basalSeedApplied,
166
+ seededFromBeforeSince = basalReadResult.seededFromBeforeSince,
167
+ basalSeedAgeHours = basalReadResult.basalSeedAgeHours
168
+ )
169
+ } else {
170
+ ExerciseDomainService.emptyNormalizationResult("proportional")
171
+ }
172
+
173
+ val unifiedRecordsResult =
174
+ ExerciseDomainService.buildUnifiedRecordsResult(
175
+ stepsSegments = normalizedSteps.segments,
176
+ activeTimeSegments = normalizedActiveTimes.segments,
177
+ caloriesSegments = normalizedCalories.segments,
178
+ distanceSegments = normalizedDistances.segments,
179
+ zoneId = zoneId,
180
+ roundingScale = 5
181
+ )
182
+
183
+ val warnings =
184
+ linkedSetOf<String>().apply {
185
+ addAll(normalizedSteps.warnings)
186
+ addAll(normalizedActiveTimes.warnings)
187
+ addAll(normalizedCalories.warnings)
188
+ addAll(normalizedDistances.warnings)
189
+ addAll(basalReadResult.warnings)
190
+ addAll(caloriesFallbackEvidence.warnings)
191
+ addAll(unifiedRecordsResult.warnings)
192
+ }
193
+
194
+ val diagnosticsDto =
195
+ normalizedCalories.diagnostics?.let { diagnostics ->
196
+ ExerciseDiagnosticsDto(
197
+ fallbackUsedSegments = diagnostics.fallbackUsedSegments,
198
+ fallbackSuppressedSegments = diagnostics.fallbackSuppressedSegments,
199
+ idleGateSuppressedSegments = diagnostics.idleGateSuppressedSegments,
200
+ basalSeedApplied = diagnostics.basalSeedApplied,
201
+ seededFromBeforeSince = basalReadResult.seededFromBeforeSince,
202
+ basalSeedAgeHours = diagnostics.basalSeedAgeHours
203
+ )
204
+ }
205
+
206
+ val debugDto =
207
+ if (query.debug) {
208
+ normalizedCalories.diagnostics?.let { diagnostics ->
209
+ ExerciseDebugDto(
210
+ directActiveCaloriesKcal = diagnostics.directActiveCaloriesKcal,
211
+ totalCaloriesKcal = diagnostics.totalCaloriesKcal,
212
+ basalCaloriesKcal = diagnostics.basalCaloriesKcal,
213
+ mergedActiveCaloriesKcal = diagnostics.mergedActiveCaloriesKcal,
214
+ fallbackContributionKcal = diagnostics.fallbackContributionKcal,
215
+ fallbackUsedSegments = diagnostics.fallbackUsedSegments,
216
+ fallbackSuppressedSegments = diagnostics.fallbackSuppressedSegments,
217
+ idleGateSuppressedSegments = diagnostics.idleGateSuppressedSegments,
218
+ basalSeedApplied = diagnostics.basalSeedApplied,
219
+ seededFromBeforeSince = basalReadResult.seededFromBeforeSince,
220
+ basalSeedAgeHours = diagnostics.basalSeedAgeHours,
221
+ basalIntervalsCount = basalReadResult.intervals.size
222
+ )
223
+ }
224
+ } else {
225
+ null
226
+ }
227
+
228
+ return ExerciseDataDto(
229
+ totalKcal = normalizedCalories.segments.sumOf { it.value },
230
+ totalSteps = normalizedSteps.segments.sumOf { it.value },
231
+ totalActiveTimeMillis = normalizedActiveTimes.segments.sumOf { it.value },
232
+ totalDistanceMeters = normalizedDistances.segments.sumOf { it.value },
233
+ timeZone = zoneId.id,
234
+ records = unifiedRecordsResult.records,
235
+ warnings = warnings.toList(),
236
+ integrityProjectionPreserved = unifiedRecordsResult.projectionPreserved,
237
+ integrityRoundingScale = unifiedRecordsResult.roundingScale,
238
+ diagnostics = diagnosticsDto,
239
+ debug = debugDto
240
+ )
241
+ }
242
+
243
+ private suspend fun loadCaloriesFallbackEvidence(
244
+ startInstant: Instant,
245
+ endInstant: Instant,
246
+ sinceMs: Long,
247
+ untilMs: Long,
248
+ bucketPeriod: String,
249
+ zoneId: ZoneId
250
+ ): CaloriesFallbackEvidenceDto = coroutineScope {
251
+ val warnings = linkedSetOf<String>()
252
+
253
+ val stepsDeferred = async {
254
+ runCatching {
255
+ ExerciseDomainService.normalizeOverlaps(
256
+ metricType = HealthConnectModuleInternals.MetricType.STEPS,
257
+ rawIntervals =
258
+ exerciseRecordPort.readStepsIntervals(
259
+ startInstant = startInstant,
260
+ endInstant = endInstant,
261
+ includeManualWithDevice = false
262
+ ),
263
+ sinceMs = sinceMs,
264
+ untilMs = untilMs,
265
+ bucketPeriod = bucketPeriod,
266
+ zoneId = zoneId
267
+ )
268
+ .segments
269
+ }
270
+ }
271
+ val activeTimeDeferred = async {
272
+ runCatching {
273
+ ExerciseDomainService.normalizeOverlaps(
274
+ metricType = HealthConnectModuleInternals.MetricType.ACTIVE_TIME,
275
+ rawIntervals =
276
+ exerciseRecordPort.readActiveTimeIntervals(
277
+ startInstant = startInstant,
278
+ endInstant = endInstant,
279
+ includeManualWithDevice = false
280
+ ),
281
+ sinceMs = sinceMs,
282
+ untilMs = untilMs,
283
+ bucketPeriod = bucketPeriod,
284
+ zoneId = zoneId
285
+ )
286
+ .segments
287
+ }
288
+ }
289
+ val distanceDeferred = async {
290
+ runCatching {
291
+ ExerciseDomainService.normalizeOverlaps(
292
+ metricType = HealthConnectModuleInternals.MetricType.DISTANCE,
293
+ rawIntervals =
294
+ exerciseRecordPort.readDistanceIntervals(
295
+ startInstant = startInstant,
296
+ endInstant = endInstant,
297
+ includeManualWithDevice = false
298
+ ),
299
+ sinceMs = sinceMs,
300
+ untilMs = untilMs,
301
+ bucketPeriod = bucketPeriod,
302
+ zoneId = zoneId
303
+ )
304
+ .segments
305
+ }
306
+ }
307
+
308
+ val stepsSegments =
309
+ stepsDeferred.await().getOrElse {
310
+ warnings.add(WARNING_MOVEMENT_EVIDENCE_PARTIALLY_UNAVAILABLE_FOR_FALLBACK_GATE)
311
+ emptyList()
312
+ }
313
+ val activeTimeSegments =
314
+ activeTimeDeferred.await().getOrElse {
315
+ warnings.add(WARNING_MOVEMENT_EVIDENCE_PARTIALLY_UNAVAILABLE_FOR_FALLBACK_GATE)
316
+ emptyList()
317
+ }
318
+ val distanceSegments =
319
+ distanceDeferred.await().getOrElse {
320
+ warnings.add(WARNING_MOVEMENT_EVIDENCE_PARTIALLY_UNAVAILABLE_FOR_FALLBACK_GATE)
321
+ emptyList()
322
+ }
323
+
324
+ CaloriesFallbackEvidenceDto(
325
+ stepsSegments = stepsSegments,
326
+ activeTimeSegments = activeTimeSegments,
327
+ distanceSegments = distanceSegments,
328
+ warnings = warnings
329
+ )
330
+ }
331
+
332
+ private data class CaloriesFallbackEvidenceDto(
333
+ val stepsSegments: List<HealthConnectModuleInternals.NormalizedSegment>,
334
+ val activeTimeSegments: List<HealthConnectModuleInternals.NormalizedSegment>,
335
+ val distanceSegments: List<HealthConnectModuleInternals.NormalizedSegment>,
336
+ val warnings: Set<String> = emptySet()
337
+ )
338
+
339
+ private companion object {
340
+ private const val WARNING_MOVEMENT_EVIDENCE_PARTIALLY_UNAVAILABLE_FOR_FALLBACK_GATE =
341
+ "movement_evidence_partially_unavailable_for_fallback_gate"
342
+ }
343
+ }
344
+
345
+ internal class ExerciseDataException(val code: String, val detail: String) :
346
+ IllegalArgumentException(detail)
@@ -0,0 +1,45 @@
1
+ package com.carivaexercisesdk.application.exercise.port
2
+
3
+ import com.carivaexercisesdk.domain.exercise.services.HealthConnectModuleInternals
4
+ import com.carivaexercisesdk.application.exercise.dto.BasalReadResultDto
5
+ import com.carivaexercisesdk.domain.connection.valueobject.HealthConnectStatus
6
+ import java.time.Instant
7
+
8
+ internal interface ExerciseRecordPort {
9
+ suspend fun getHealthConnectStatus(): HealthConnectStatus
10
+
11
+ suspend fun readStepsIntervals(
12
+ startInstant: Instant,
13
+ endInstant: Instant,
14
+ includeManualWithDevice: Boolean = true
15
+ ): List<HealthConnectModuleInternals.IntervalInput>
16
+
17
+ suspend fun readActiveTimeIntervals(
18
+ startInstant: Instant,
19
+ endInstant: Instant,
20
+ includeManualWithDevice: Boolean = true
21
+ ): List<HealthConnectModuleInternals.IntervalInput>
22
+
23
+ suspend fun readCaloriesIntervals(
24
+ startInstant: Instant,
25
+ endInstant: Instant,
26
+ includeManualWithDevice: Boolean = true
27
+ ): List<HealthConnectModuleInternals.IntervalInput>
28
+
29
+ suspend fun readDistanceIntervals(
30
+ startInstant: Instant,
31
+ endInstant: Instant,
32
+ includeManualWithDevice: Boolean = true
33
+ ): List<HealthConnectModuleInternals.IntervalInput>
34
+
35
+ suspend fun readTotalCaloriesIntervals(
36
+ startInstant: Instant,
37
+ endInstant: Instant,
38
+ includeManualWithDevice: Boolean = true
39
+ ): List<HealthConnectModuleInternals.IntervalInput>
40
+
41
+ suspend fun readBasalCaloriesIntervals(
42
+ startInstant: Instant,
43
+ endInstant: Instant
44
+ ): BasalReadResultDto
45
+ }
@@ -0,0 +1,9 @@
1
+ package com.carivaexercisesdk.application.exercise.query
2
+
3
+ data class GetExerciseDataQuery(
4
+ val sinceMillis: Long,
5
+ val untilMillis: Long,
6
+ val type: String,
7
+ val bucketPeriod: String,
8
+ val debug: Boolean
9
+ )
@@ -0,0 +1,13 @@
1
+ package com.carivaexercisesdk.domain.connection.entity
2
+
3
+ import com.carivaexercisesdk.domain.connection.valueobject.ConnectionNextAction
4
+ import com.carivaexercisesdk.domain.connection.valueobject.HealthConnectStatus
5
+
6
+ data class ConnectionAggregate(
7
+ val ready: Boolean,
8
+ val healthConnectInstalled: Boolean,
9
+ val healthConnectStatus: HealthConnectStatus,
10
+ val hasPermissions: Boolean,
11
+ val nextAction: ConnectionNextAction,
12
+ val requestedPermissions: Set<String>
13
+ )
@@ -0,0 +1,37 @@
1
+ package com.carivaexercisesdk.domain.connection.service
2
+
3
+ import com.carivaexercisesdk.domain.connection.entity.ConnectionAggregate
4
+ import com.carivaexercisesdk.domain.connection.valueobject.ConnectionNextAction
5
+ import com.carivaexercisesdk.domain.connection.valueobject.HealthConnectStatus
6
+
7
+ class ConnectionDecisionService {
8
+ fun decide(
9
+ healthConnectInstalled: Boolean,
10
+ healthConnectStatus: HealthConnectStatus,
11
+ hasPermissions: Boolean,
12
+ requestedPermissions: Set<String>
13
+ ): ConnectionAggregate {
14
+ val nextAction =
15
+ when {
16
+ !healthConnectInstalled -> ConnectionNextAction.INSTALL_HEALTH_CONNECT
17
+ healthConnectStatus != HealthConnectStatus.AVAILABLE ->
18
+ ConnectionNextAction.UPDATE_HEALTH_CONNECT
19
+ !hasPermissions -> ConnectionNextAction.REQUEST_PERMISSION
20
+ else -> ConnectionNextAction.NONE
21
+ }
22
+
23
+ val ready =
24
+ healthConnectInstalled &&
25
+ healthConnectStatus == HealthConnectStatus.AVAILABLE &&
26
+ hasPermissions
27
+
28
+ return ConnectionAggregate(
29
+ ready = ready,
30
+ healthConnectInstalled = healthConnectInstalled,
31
+ healthConnectStatus = healthConnectStatus,
32
+ hasPermissions = hasPermissions,
33
+ nextAction = nextAction,
34
+ requestedPermissions = requestedPermissions
35
+ )
36
+ }
37
+ }
@@ -0,0 +1,8 @@
1
+ package com.carivaexercisesdk.domain.connection.valueobject
2
+
3
+ enum class ConnectionNextAction(val wireValue: String) {
4
+ INSTALL_HEALTH_CONNECT("INSTALL_HEALTH_CONNECT"),
5
+ UPDATE_HEALTH_CONNECT("UPDATE_HEALTH_CONNECT"),
6
+ REQUEST_PERMISSION("REQUEST_PERMISSION"),
7
+ NONE("NONE")
8
+ }
@@ -0,0 +1,8 @@
1
+ package com.carivaexercisesdk.domain.connection.valueobject
2
+
3
+ enum class HealthConnectStatus {
4
+ AVAILABLE,
5
+ PROVIDER_UPDATE_REQUIRED,
6
+ UNAVAILABLE,
7
+ UNKNOWN
8
+ }
@@ -0,0 +1,36 @@
1
+ package com.carivaexercisesdk.domain.connection.valueobject
2
+
3
+ enum class PermissionScope(val wireValue: String) {
4
+ STEPS("steps"),
5
+ ACTIVETIMES("activetimes"),
6
+ CALORIES("calories"),
7
+ DISTANCES("distances");
8
+
9
+ companion object {
10
+ fun normalize(scopeValue: String): PermissionScope? {
11
+ return entries.firstOrNull { it.wireValue == scopeValue.trim().lowercase() }
12
+ }
13
+
14
+ fun normalizeAll(scopeValues: List<String>?): Set<PermissionScope> {
15
+ if (scopeValues.isNullOrEmpty()) {
16
+ return emptySet()
17
+ }
18
+ return scopeValues.mapNotNull(::normalize).toSet()
19
+ }
20
+
21
+ fun findUnsupported(scopeValues: List<String>?): Set<String> {
22
+ if (scopeValues.isNullOrEmpty()) {
23
+ return emptySet()
24
+ }
25
+
26
+ return scopeValues
27
+ .map { it.trim() }
28
+ .filter { normalize(it) == null }
29
+ .toSet()
30
+ }
31
+
32
+ fun defaults(): Set<PermissionScope> {
33
+ return entries.toSet()
34
+ }
35
+ }
36
+ }
@@ -0,0 +1,8 @@
1
+ package com.carivaexercisesdk.domain.datasource.entity
2
+
3
+ import com.carivaexercisesdk.domain.datasource.valueobject.DatasourceType
4
+
5
+ data class DatasourcePolicy(
6
+ val allowlist: Set<DatasourceType>,
7
+ val packageAllowlist: Set<String>
8
+ )
@@ -0,0 +1,14 @@
1
+ package com.carivaexercisesdk.domain.datasource.valueobject
2
+
3
+ enum class DatasourceType(val wireValue: String) {
4
+ MANUAL_INPUT("manual_input"),
5
+ DEVICE("device"),
6
+ APP("app"),
7
+ UNKNOWN("unknown");
8
+
9
+ companion object {
10
+ fun fromWireValue(value: String): DatasourceType? {
11
+ return entries.firstOrNull { it.wireValue == value.trim().lowercase() }
12
+ }
13
+ }
14
+ }
@@ -0,0 +1,89 @@
1
+ package com.carivaexercisesdk.domain.exercise.services
2
+
3
+ import java.time.Instant
4
+ import java.time.ZoneId
5
+
6
+ internal object ExerciseDomainService {
7
+ fun isValidPeriod(startInstant: Instant, endInstant: Instant): Boolean {
8
+ return HealthConnectModuleInternals.isValidPeriod(startInstant, endInstant)
9
+ }
10
+
11
+ fun isSupportedBucketPeriod(bucketPeriod: String): Boolean {
12
+ return HealthConnectModuleInternals.isSupportedBucketPeriod(bucketPeriod)
13
+ }
14
+
15
+ fun normalizeBucketPeriod(bucketPeriod: String): String {
16
+ return HealthConnectModuleInternals.normalizeBucketPeriod(bucketPeriod)
17
+ }
18
+
19
+ fun normalizeOverlaps(
20
+ metricType: HealthConnectModuleInternals.MetricType,
21
+ rawIntervals: List<HealthConnectModuleInternals.IntervalInput>,
22
+ sinceMs: Long,
23
+ untilMs: Long,
24
+ bucketPeriod: String,
25
+ zoneId: ZoneId
26
+ ): HealthConnectModuleInternals.NormalizationResult {
27
+ return HealthConnectModuleInternals.normalizeOverlaps(
28
+ metricType = metricType,
29
+ rawIntervals = rawIntervals,
30
+ sinceMs = sinceMs,
31
+ untilMs = untilMs,
32
+ bucketPeriod = bucketPeriod,
33
+ zoneId = zoneId
34
+ )
35
+ }
36
+
37
+ fun mergeCaloriesWithFallback(
38
+ primaryActiveCalories: HealthConnectModuleInternals.NormalizationResult,
39
+ totalCalories: HealthConnectModuleInternals.NormalizationResult,
40
+ basalCalories: HealthConnectModuleInternals.NormalizationResult,
41
+ stepsEvidenceSegments: List<HealthConnectModuleInternals.NormalizedSegment>,
42
+ activeTimeEvidenceSegments: List<HealthConnectModuleInternals.NormalizedSegment>,
43
+ distanceEvidenceSegments: List<HealthConnectModuleInternals.NormalizedSegment>,
44
+ basalSeedApplied: Boolean,
45
+ seededFromBeforeSince: Boolean,
46
+ basalSeedAgeHours: Double?
47
+ ): HealthConnectModuleInternals.NormalizationResult {
48
+ return HealthConnectModuleInternals.mergeCaloriesWithFallback(
49
+ primaryActiveCalories = primaryActiveCalories,
50
+ totalCalories = totalCalories,
51
+ basalCalories = basalCalories,
52
+ stepsEvidenceSegments = stepsEvidenceSegments,
53
+ activeTimeEvidenceSegments = activeTimeEvidenceSegments,
54
+ distanceEvidenceSegments = distanceEvidenceSegments,
55
+ basalSeedApplied = basalSeedApplied,
56
+ seededFromBeforeSince = seededFromBeforeSince,
57
+ basalSeedAgeHours = basalSeedAgeHours
58
+ )
59
+ }
60
+
61
+ fun buildUnifiedRecordsResult(
62
+ stepsSegments: List<HealthConnectModuleInternals.NormalizedSegment>,
63
+ activeTimeSegments: List<HealthConnectModuleInternals.NormalizedSegment>,
64
+ caloriesSegments: List<HealthConnectModuleInternals.NormalizedSegment>,
65
+ distanceSegments: List<HealthConnectModuleInternals.NormalizedSegment>,
66
+ zoneId: ZoneId,
67
+ roundingScale: Int
68
+ ): HealthConnectModuleInternals.UnifiedRecordsBuildResult {
69
+ return HealthConnectModuleInternals.buildUnifiedRecordsResult(
70
+ stepsSegments = stepsSegments,
71
+ activeTimeSegments = activeTimeSegments,
72
+ caloriesSegments = caloriesSegments,
73
+ distanceSegments = distanceSegments,
74
+ zoneId = zoneId,
75
+ roundingScale = roundingScale
76
+ )
77
+ }
78
+
79
+ fun emptyNormalizationResult(
80
+ overlapStrategy: String
81
+ ): HealthConnectModuleInternals.NormalizationResult {
82
+ return HealthConnectModuleInternals.NormalizationResult(
83
+ segments = emptyList(),
84
+ stats = HealthConnectModuleInternals.NormalizationStats(),
85
+ overlapStrategy = overlapStrategy,
86
+ warnings = emptyList()
87
+ )
88
+ }
89
+ }