@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,400 @@
1
+ package com.carivaexercisesdk.domain.exercise.services
2
+
3
+ import java.time.Duration
4
+ import java.time.Instant
5
+ import java.time.ZoneId
6
+
7
+ internal object HealthConnectOverlapNormalizer {
8
+ fun normalizeOverlaps(
9
+ metricType: HealthConnectModuleInternals.MetricType,
10
+ rawIntervals: List<HealthConnectModuleInternals.IntervalInput>,
11
+ sinceMs: Long,
12
+ untilMs: Long,
13
+ bucketPeriod: String,
14
+ zoneId: ZoneId
15
+ ): HealthConnectModuleInternals.NormalizationResult {
16
+ val valid =
17
+ rawIntervals.mapNotNull { interval ->
18
+ val clampedStart = maxOf(interval.startMs, sinceMs)
19
+ val clampedEnd = minOf(interval.endMs, untilMs)
20
+ if (clampedEnd <= clampedStart) {
21
+ null
22
+ } else {
23
+ HealthConnectModuleInternals.IntervalInput(
24
+ startMs = clampedStart,
25
+ endMs = clampedEnd,
26
+ totalValue = interval.totalValue,
27
+ sourceType = HealthConnectSourceTrust.normalizeSourceType(interval.sourceType),
28
+ packageName = interval.packageName,
29
+ deviceModel = interval.deviceModel
30
+ )
31
+ }
32
+ }
33
+
34
+ if (valid.isEmpty()) {
35
+ return HealthConnectModuleInternals.NormalizationResult(
36
+ segments = emptyList(),
37
+ stats = HealthConnectModuleInternals.NormalizationStats(observedIntervals = 0),
38
+ overlapStrategy = resolveOverlapStrategy(metricType)
39
+ )
40
+ }
41
+
42
+ val atomicResult = buildAtomicSegments(metricType, valid)
43
+ val atomic = atomicResult.segments
44
+ val merged = mergeAdjacentSegments(atomic)
45
+ val bucketized =
46
+ bucketize(
47
+ segments = merged,
48
+ zoneId = zoneId,
49
+ bucketPeriod = HealthConnectModuleInternals.normalizeBucketPeriod(bucketPeriod)
50
+ )
51
+ val bucketWarnings = mutableListOf<String>()
52
+ if (bucketized.guardTriggered) {
53
+ bucketWarnings.add("bucket_boundary_guard_triggered")
54
+ }
55
+ val unknownSourceCount =
56
+ bucketized.segments.count {
57
+ it.sourceType == HealthConnectInternalsConstants.SOURCE_UNKNOWN
58
+ }
59
+ if (unknownSourceCount > 0) {
60
+ bucketWarnings.add(HealthConnectInternalsConstants.WARNING_UNKNOWN_SOURCE_TYPE)
61
+ }
62
+
63
+ return HealthConnectModuleInternals.NormalizationResult(
64
+ segments = bucketized.segments,
65
+ stats =
66
+ HealthConnectModuleInternals.NormalizationStats(
67
+ droppedNonPositiveCount = atomicResult.droppedNonPositiveCount,
68
+ unknownSourceCount = unknownSourceCount,
69
+ observedIntervals = valid.size
70
+ ),
71
+ overlapStrategy = resolveOverlapStrategy(metricType),
72
+ warnings = bucketWarnings
73
+ )
74
+ }
75
+
76
+ fun nextBucketBoundary(timeMs: Long, zoneId: ZoneId, bucketPeriod: String): Long {
77
+ val bucketDuration =
78
+ if (bucketPeriod == HealthConnectInternalsConstants.SUPPORTED_DAILY) {
79
+ Duration.ofDays(1)
80
+ } else {
81
+ Duration.ofHours(1)
82
+ }
83
+ val bucketStartMs = alignToBucketStart(timeMs, zoneId, bucketDuration)
84
+ val bucketStartZoned = Instant.ofEpochMilli(bucketStartMs).atZone(zoneId)
85
+
86
+ val next =
87
+ if (bucketPeriod == HealthConnectInternalsConstants.SUPPORTED_DAILY) {
88
+ bucketStartZoned.plusDays(1)
89
+ } else {
90
+ bucketStartZoned.plusHours(1)
91
+ }
92
+ val nextMs = next.toInstant().toEpochMilli()
93
+ if (nextMs <= timeMs) {
94
+ return Instant.ofEpochMilli(timeMs).plus(bucketDuration).toEpochMilli()
95
+ }
96
+ return nextMs
97
+ }
98
+
99
+ private data class AtomicBuildResult(
100
+ val segments: List<HealthConnectModuleInternals.NormalizedSegment>,
101
+ val droppedNonPositiveCount: Int
102
+ )
103
+
104
+ private fun buildAtomicSegments(
105
+ metricType: HealthConnectModuleInternals.MetricType,
106
+ intervals: List<HealthConnectModuleInternals.IntervalInput>
107
+ ): AtomicBuildResult {
108
+ val breakpoints = intervals.flatMap { listOf(it.startMs, it.endMs) }.toSortedSet().toList()
109
+
110
+ if (breakpoints.size < 2) {
111
+ return AtomicBuildResult(emptyList(), droppedNonPositiveCount = 0)
112
+ }
113
+
114
+ val segments = mutableListOf<HealthConnectModuleInternals.NormalizedSegment>()
115
+ var droppedNonPositiveCount = 0
116
+
117
+ for (index in 0 until breakpoints.lastIndex) {
118
+ val segmentStart = breakpoints[index]
119
+ val segmentEnd = breakpoints[index + 1]
120
+ if (segmentEnd <= segmentStart) {
121
+ continue
122
+ }
123
+
124
+ val active = intervals.filter { it.startMs < segmentEnd && it.endMs > segmentStart }
125
+ if (active.isEmpty()) {
126
+ continue
127
+ }
128
+
129
+ val duration = (segmentEnd - segmentStart).toDouble()
130
+ val segment =
131
+ when (metricType) {
132
+ HealthConnectModuleInternals.MetricType.STEPS ->
133
+ resolveStepsSegment(segmentStart, segmentEnd, duration, active)
134
+ HealthConnectModuleInternals.MetricType.ACTIVE_TIME,
135
+ HealthConnectModuleInternals.MetricType.CALORIES,
136
+ HealthConnectModuleInternals.MetricType.DISTANCE ->
137
+ resolveProportionalSegment(segmentStart, segmentEnd, duration, active)
138
+ }
139
+
140
+ if (segment.value > 0.0) {
141
+ segments.add(segment)
142
+ } else {
143
+ droppedNonPositiveCount += 1
144
+ }
145
+ }
146
+
147
+ return AtomicBuildResult(segments = segments, droppedNonPositiveCount = droppedNonPositiveCount)
148
+ }
149
+
150
+ private fun resolveProportionalSegment(
151
+ segmentStart: Long,
152
+ segmentEnd: Long,
153
+ duration: Double,
154
+ active: List<HealthConnectModuleInternals.IntervalInput>
155
+ ): HealthConnectModuleInternals.NormalizedSegment {
156
+ var value = 0.0
157
+ val sourceTypes = mutableSetOf<String>()
158
+ val packageNames = mutableSetOf<String>()
159
+ val deviceModels = mutableSetOf<String>()
160
+
161
+ active.forEach { interval ->
162
+ val intervalDuration = (interval.endMs - interval.startMs).toDouble()
163
+ if (intervalDuration <= 0.0) {
164
+ return@forEach
165
+ }
166
+ value += interval.totalValue * (duration / intervalDuration)
167
+ sourceTypes.add(interval.sourceType)
168
+ if (interval.packageName.isNotEmpty()) {
169
+ packageNames.add(interval.packageName)
170
+ }
171
+ if (interval.deviceModel.isNotEmpty()) {
172
+ deviceModels.add(interval.deviceModel)
173
+ }
174
+ }
175
+
176
+ return HealthConnectModuleInternals.NormalizedSegment(
177
+ startMs = segmentStart,
178
+ endMs = segmentEnd,
179
+ value = value,
180
+ sourceType = resolveMergedSourceType(sourceTypes),
181
+ packageName = resolveMergedPackageName(packageNames),
182
+ deviceModel = resolveMergedDeviceModel(deviceModels)
183
+ )
184
+ }
185
+
186
+ private data class StepWinnerCandidate(
187
+ val interval: HealthConnectModuleInternals.IntervalInput,
188
+ val overlapDuration: Double,
189
+ val intervalDuration: Double
190
+ )
191
+
192
+ private fun resolveStepsSegment(
193
+ segmentStart: Long,
194
+ segmentEnd: Long,
195
+ duration: Double,
196
+ active: List<HealthConnectModuleInternals.IntervalInput>
197
+ ): HealthConnectModuleInternals.NormalizedSegment {
198
+ val winnerPriority = active.maxOf { HealthConnectSourceTrust.resolveSourcePriority(it.sourceType) }
199
+ val winnerGroup =
200
+ active.filter {
201
+ HealthConnectSourceTrust.resolveSourcePriority(it.sourceType) == winnerPriority
202
+ }
203
+
204
+ val winner =
205
+ winnerGroup
206
+ .mapNotNull { interval ->
207
+ val intervalDuration = (interval.endMs - interval.startMs).toDouble()
208
+ if (intervalDuration <= 0.0) {
209
+ return@mapNotNull null
210
+ }
211
+ val overlapStart = maxOf(segmentStart, interval.startMs)
212
+ val overlapEnd = minOf(segmentEnd, interval.endMs)
213
+ val overlapDuration = (overlapEnd - overlapStart).toDouble()
214
+ if (overlapDuration <= 0.0) {
215
+ return@mapNotNull null
216
+ }
217
+ StepWinnerCandidate(
218
+ interval = interval,
219
+ overlapDuration = overlapDuration,
220
+ intervalDuration = intervalDuration
221
+ )
222
+ }
223
+ .sortedWith(
224
+ compareByDescending<StepWinnerCandidate> { it.overlapDuration }
225
+ .thenByDescending { it.intervalDuration }
226
+ .thenBy { it.interval.packageName }
227
+ .thenBy { it.interval.startMs }
228
+ .thenBy { it.interval.endMs }
229
+ )
230
+ .firstOrNull()
231
+
232
+ return HealthConnectModuleInternals.NormalizedSegment(
233
+ startMs = segmentStart,
234
+ endMs = segmentEnd,
235
+ value =
236
+ winner?.let { candidate ->
237
+ candidate.interval.totalValue * (duration / candidate.intervalDuration)
238
+ } ?: 0.0,
239
+ sourceType = winner?.interval?.sourceType ?: HealthConnectInternalsConstants.SOURCE_UNKNOWN,
240
+ packageName = winner?.interval?.packageName.orEmpty(),
241
+ deviceModel = winner?.interval?.deviceModel.orEmpty()
242
+ )
243
+ }
244
+
245
+ private fun mergeAdjacentSegments(
246
+ segments: List<HealthConnectModuleInternals.NormalizedSegment>
247
+ ): List<HealthConnectModuleInternals.NormalizedSegment> {
248
+ if (segments.isEmpty()) {
249
+ return emptyList()
250
+ }
251
+
252
+ val sorted = segments.sortedBy { it.startMs }
253
+ val merged = mutableListOf<HealthConnectModuleInternals.NormalizedSegment>()
254
+
255
+ sorted.forEach { current ->
256
+ val previous = merged.lastOrNull()
257
+ if (previous != null &&
258
+ previous.endMs == current.startMs &&
259
+ previous.sourceType == current.sourceType &&
260
+ previous.packageName == current.packageName &&
261
+ previous.deviceModel == current.deviceModel &&
262
+ hasSameRate(previous, current)
263
+ ) {
264
+ merged[merged.lastIndex] =
265
+ previous.copy(endMs = current.endMs, value = previous.value + current.value)
266
+ } else {
267
+ merged.add(current)
268
+ }
269
+ }
270
+
271
+ return merged
272
+ }
273
+
274
+ private fun hasSameRate(
275
+ left: HealthConnectModuleInternals.NormalizedSegment,
276
+ right: HealthConnectModuleInternals.NormalizedSegment
277
+ ): Boolean {
278
+ val leftDuration = (left.endMs - left.startMs).toDouble()
279
+ val rightDuration = (right.endMs - right.startMs).toDouble()
280
+ if (leftDuration <= 0.0 || rightDuration <= 0.0) {
281
+ return false
282
+ }
283
+
284
+ val leftRate = left.value / leftDuration
285
+ val rightRate = right.value / rightDuration
286
+ return kotlin.math.abs(leftRate - rightRate) < 1e-9
287
+ }
288
+
289
+ private data class BucketizeResult(
290
+ val segments: List<HealthConnectModuleInternals.NormalizedSegment>,
291
+ val guardTriggered: Boolean
292
+ )
293
+
294
+ private fun bucketize(
295
+ segments: List<HealthConnectModuleInternals.NormalizedSegment>,
296
+ zoneId: ZoneId,
297
+ bucketPeriod: String
298
+ ): BucketizeResult {
299
+ if (segments.isEmpty()) {
300
+ return BucketizeResult(emptyList(), guardTriggered = false)
301
+ }
302
+
303
+ val result = mutableListOf<HealthConnectModuleInternals.NormalizedSegment>()
304
+ var guardTriggered = false
305
+
306
+ segments.forEach { segment ->
307
+ var cursor = segment.startMs
308
+ while (cursor < segment.endMs) {
309
+ val bucketEnd = nextBucketBoundary(cursor, zoneId, bucketPeriod)
310
+ if (bucketEnd <= cursor) {
311
+ guardTriggered = true
312
+ val clippedEnd = segment.endMs
313
+ val clippedDuration = (clippedEnd - cursor).toDouble()
314
+ val fullDuration = (segment.endMs - segment.startMs).toDouble()
315
+ if (clippedDuration > 0.0 && fullDuration > 0.0) {
316
+ val clippedValue = segment.value * (clippedDuration / fullDuration)
317
+ result.add(
318
+ HealthConnectModuleInternals.NormalizedSegment(
319
+ startMs = cursor,
320
+ endMs = clippedEnd,
321
+ value = clippedValue,
322
+ sourceType = segment.sourceType,
323
+ packageName = segment.packageName,
324
+ deviceModel = segment.deviceModel
325
+ )
326
+ )
327
+ }
328
+ break
329
+ }
330
+ val clippedEnd = minOf(segment.endMs, bucketEnd)
331
+ val clippedDuration = (clippedEnd - cursor).toDouble()
332
+ val fullDuration = (segment.endMs - segment.startMs).toDouble()
333
+
334
+ if (clippedDuration > 0.0 && fullDuration > 0.0) {
335
+ val clippedValue = segment.value * (clippedDuration / fullDuration)
336
+ result.add(
337
+ HealthConnectModuleInternals.NormalizedSegment(
338
+ startMs = cursor,
339
+ endMs = clippedEnd,
340
+ value = clippedValue,
341
+ sourceType = segment.sourceType,
342
+ packageName = segment.packageName,
343
+ deviceModel = segment.deviceModel
344
+ )
345
+ )
346
+ }
347
+
348
+ cursor = clippedEnd
349
+ }
350
+ }
351
+
352
+ return BucketizeResult(segments = result, guardTriggered = guardTriggered)
353
+ }
354
+
355
+ private fun alignToBucketStart(timeMs: Long, zoneId: ZoneId, bucketDuration: Duration): Long {
356
+ val instant = Instant.ofEpochMilli(timeMs)
357
+ val zoned = instant.atZone(zoneId)
358
+
359
+ return if (bucketDuration == Duration.ofDays(1)) {
360
+ zoned.toLocalDate().atStartOfDay(zoneId).toInstant().toEpochMilli()
361
+ } else {
362
+ zoned.withMinute(0).withSecond(0).withNano(0).toInstant().toEpochMilli()
363
+ }
364
+ }
365
+
366
+ private fun resolveOverlapStrategy(metricType: HealthConnectModuleInternals.MetricType): String {
367
+ return when (metricType) {
368
+ HealthConnectModuleInternals.MetricType.STEPS ->
369
+ HealthConnectInternalsConstants.OVERLAP_STRATEGY_SOURCE_PRIORITY
370
+ HealthConnectModuleInternals.MetricType.ACTIVE_TIME,
371
+ HealthConnectModuleInternals.MetricType.CALORIES,
372
+ HealthConnectModuleInternals.MetricType.DISTANCE ->
373
+ HealthConnectInternalsConstants.OVERLAP_STRATEGY_PROPORTIONAL
374
+ }
375
+ }
376
+
377
+ private fun resolveMergedSourceType(sourceTypes: Set<String>): String {
378
+ return when (sourceTypes.size) {
379
+ 0 -> HealthConnectInternalsConstants.SOURCE_UNKNOWN
380
+ 1 -> sourceTypes.first()
381
+ else -> HealthConnectInternalsConstants.SOURCE_UNKNOWN
382
+ }
383
+ }
384
+
385
+ private fun resolveMergedPackageName(packageNames: Set<String>): String {
386
+ return when (packageNames.size) {
387
+ 0 -> ""
388
+ 1 -> packageNames.first()
389
+ else -> "multiple"
390
+ }
391
+ }
392
+
393
+ private fun resolveMergedDeviceModel(deviceModels: Set<String>): String {
394
+ return when (deviceModels.size) {
395
+ 0 -> ""
396
+ 1 -> deviceModels.first()
397
+ else -> deviceModels.toList().sorted().joinToString(", ")
398
+ }
399
+ }
400
+ }
@@ -0,0 +1,249 @@
1
+ package com.carivaexercisesdk.domain.exercise.services
2
+
3
+ internal object HealthConnectSourceTrust {
4
+ fun normalizeSourceType(rawValue: String): String {
5
+ return when (rawValue.trim().lowercase()) {
6
+ HealthConnectInternalsConstants.SOURCE_MANUAL_INPUT,
7
+ "manual",
8
+ "manual_entry" -> HealthConnectInternalsConstants.SOURCE_MANUAL_INPUT
9
+ HealthConnectInternalsConstants.SOURCE_DEVICE,
10
+ "automatic",
11
+ "automatically_recorded" -> HealthConnectInternalsConstants.SOURCE_DEVICE
12
+ HealthConnectInternalsConstants.SOURCE_APP,
13
+ "actively_recorded" -> HealthConnectInternalsConstants.SOURCE_APP
14
+ HealthConnectInternalsConstants.SOURCE_UNKNOWN -> HealthConnectInternalsConstants.SOURCE_UNKNOWN
15
+ else -> HealthConnectInternalsConstants.SOURCE_UNKNOWN
16
+ }
17
+ }
18
+
19
+ fun normalizeSourceType(rawValue: Any?): String {
20
+ return when (rawValue) {
21
+ is Number ->
22
+ when (safeToInt(rawValue) ?: Int.MIN_VALUE) {
23
+ 1 -> HealthConnectInternalsConstants.SOURCE_APP
24
+ 2 -> HealthConnectInternalsConstants.SOURCE_DEVICE
25
+ 3 -> HealthConnectInternalsConstants.SOURCE_MANUAL_INPUT
26
+ else -> HealthConnectInternalsConstants.SOURCE_UNKNOWN
27
+ }
28
+ is String -> normalizeSourceType(rawValue)
29
+ else -> normalizeSourceType(rawValue?.toString().orEmpty())
30
+ }
31
+ }
32
+
33
+ fun evaluateRecordTrust(metadata: Any?): HealthConnectModuleInternals.RecordTrustResult {
34
+ if (metadata == null) {
35
+ return HealthConnectModuleInternals.RecordTrustResult(
36
+ allowed = false,
37
+ reason = HealthConnectInternalsConstants.TRUST_REASON_MALFORMED_METADATA,
38
+ sourceType = HealthConnectInternalsConstants.SOURCE_UNKNOWN,
39
+ trustLevel = HealthConnectInternalsConstants.TRUST_LEVEL_REJECTED
40
+ )
41
+ }
42
+
43
+ val recordingMethodResult = invokeGetter(metadata, "getRecordingMethod")
44
+ if (recordingMethodResult.invocationFailed) {
45
+ return HealthConnectModuleInternals.RecordTrustResult(
46
+ allowed = false,
47
+ reason = HealthConnectInternalsConstants.TRUST_REASON_MALFORMED_METADATA,
48
+ sourceType = HealthConnectInternalsConstants.SOURCE_UNKNOWN,
49
+ trustLevel = HealthConnectInternalsConstants.TRUST_LEVEL_REJECTED
50
+ )
51
+ }
52
+
53
+ val sourceType = normalizeSourceType(recordingMethodResult.value)
54
+ return when (sourceType) {
55
+ HealthConnectInternalsConstants.SOURCE_MANUAL_INPUT -> {
56
+ val deviceResult = invokeGetter(metadata, "getDevice")
57
+ if (deviceResult.invocationFailed) {
58
+ return HealthConnectModuleInternals.RecordTrustResult(
59
+ allowed = false,
60
+ reason = HealthConnectInternalsConstants.TRUST_REASON_MALFORMED_METADATA,
61
+ sourceType = HealthConnectInternalsConstants.SOURCE_UNKNOWN,
62
+ trustLevel = HealthConnectInternalsConstants.TRUST_LEVEL_REJECTED
63
+ )
64
+ }
65
+
66
+ val hasDeviceIdentity = hasDeviceIdentity(deviceResult.value)
67
+ if (hasDeviceIdentity) {
68
+ HealthConnectModuleInternals.RecordTrustResult(
69
+ allowed = true,
70
+ reason =
71
+ HealthConnectInternalsConstants
72
+ .TRUST_REASON_MANUAL_WITH_DEVICE_LOW_CONFIDENCE,
73
+ sourceType = HealthConnectInternalsConstants.SOURCE_MANUAL_INPUT,
74
+ trustLevel = HealthConnectInternalsConstants.TRUST_LEVEL_LOW_CONFIDENCE
75
+ )
76
+ } else {
77
+ HealthConnectModuleInternals.RecordTrustResult(
78
+ allowed = false,
79
+ reason = HealthConnectInternalsConstants.TRUST_REASON_MANUAL_INPUT,
80
+ sourceType = HealthConnectInternalsConstants.SOURCE_MANUAL_INPUT,
81
+ trustLevel = HealthConnectInternalsConstants.TRUST_LEVEL_REJECTED
82
+ )
83
+ }
84
+ }
85
+ HealthConnectInternalsConstants.SOURCE_APP,
86
+ HealthConnectInternalsConstants.SOURCE_DEVICE ->
87
+ HealthConnectModuleInternals.RecordTrustResult(
88
+ allowed = true,
89
+ reason = HealthConnectInternalsConstants.TRUST_REASON_ALLOWED,
90
+ sourceType = sourceType,
91
+ trustLevel = HealthConnectInternalsConstants.TRUST_LEVEL_TRUSTED
92
+ )
93
+ else -> {
94
+ val deviceResult = invokeGetter(metadata, "getDevice")
95
+ if (deviceResult.invocationFailed) {
96
+ return HealthConnectModuleInternals.RecordTrustResult(
97
+ allowed = false,
98
+ reason = HealthConnectInternalsConstants.TRUST_REASON_MALFORMED_METADATA,
99
+ sourceType = HealthConnectInternalsConstants.SOURCE_UNKNOWN,
100
+ trustLevel = HealthConnectInternalsConstants.TRUST_LEVEL_REJECTED
101
+ )
102
+ }
103
+
104
+ val hasDeviceIdentity = hasDeviceIdentity(deviceResult.value)
105
+ HealthConnectModuleInternals.RecordTrustResult(
106
+ allowed = hasDeviceIdentity,
107
+ reason =
108
+ if (hasDeviceIdentity) {
109
+ HealthConnectInternalsConstants
110
+ .TRUST_REASON_UNKNOWN_WITH_DEVICE_LOW_CONFIDENCE
111
+ } else {
112
+ HealthConnectInternalsConstants.TRUST_REASON_UNKNOWN_WITHOUT_DEVICE
113
+ },
114
+ sourceType = HealthConnectInternalsConstants.SOURCE_UNKNOWN,
115
+ trustLevel =
116
+ if (hasDeviceIdentity) {
117
+ HealthConnectInternalsConstants.TRUST_LEVEL_LOW_CONFIDENCE
118
+ } else {
119
+ HealthConnectInternalsConstants.TRUST_LEVEL_REJECTED
120
+ }
121
+ )
122
+ }
123
+ }
124
+ }
125
+
126
+ fun resolveTrustLevelFromSource(sourceType: String): String {
127
+ return when (normalizeSourceType(sourceType)) {
128
+ HealthConnectInternalsConstants.SOURCE_DEVICE,
129
+ HealthConnectInternalsConstants.SOURCE_APP -> HealthConnectInternalsConstants.TRUST_LEVEL_TRUSTED
130
+ else -> HealthConnectInternalsConstants.TRUST_LEVEL_LOW_CONFIDENCE
131
+ }
132
+ }
133
+
134
+ fun resolveDeviceModel(metadata: Any?): String {
135
+ if (metadata == null) {
136
+ return ""
137
+ }
138
+
139
+ val deviceResult = invokeGetter(metadata, "getDevice")
140
+ if (deviceResult.invocationFailed) {
141
+ return ""
142
+ }
143
+
144
+ val device = deviceResult.value ?: return ""
145
+ val manufacturer = readNonBlankGetterValue(device, "getManufacturer")
146
+ val model = readNonBlankGetterValue(device, "getModel")
147
+
148
+ val combined = listOf(manufacturer, model).filter { it.isNotEmpty() }.joinToString(" ").trim()
149
+ if (combined.isNotEmpty()) {
150
+ return combined
151
+ }
152
+
153
+ val fallback = device.toString().trim()
154
+ if (fallback.isEmpty() || fallback == "null") {
155
+ return ""
156
+ }
157
+ if (Regex("^[\\w.$]+@[0-9a-fA-F]+$").matches(fallback)) {
158
+ return ""
159
+ }
160
+ return fallback
161
+ }
162
+
163
+ fun isRecordAllowed(sourceType: String, allowlist: Set<String>): Boolean {
164
+ return allowlist.contains(normalizeSourceType(sourceType))
165
+ }
166
+
167
+ fun resolveSourcePriority(sourceType: String): Int {
168
+ return HealthConnectInternalsConstants.SOURCE_PRIORITY[normalizeSourceType(sourceType)]
169
+ ?: HealthConnectInternalsConstants.SOURCE_PRIORITY.getValue(
170
+ HealthConnectInternalsConstants.SOURCE_UNKNOWN
171
+ )
172
+ }
173
+
174
+ private data class GetterResult(val value: Any?, val invocationFailed: Boolean)
175
+
176
+ private fun invokeGetter(target: Any, methodName: String): GetterResult {
177
+ val method =
178
+ (target.javaClass.methods.firstOrNull { candidate ->
179
+ candidate.name == methodName && candidate.parameterCount == 0
180
+ }
181
+ ?: target.javaClass.declaredMethods.firstOrNull { candidate ->
182
+ candidate.name == methodName && candidate.parameterCount == 0
183
+ })
184
+ ?: return GetterResult(value = null, invocationFailed = false)
185
+
186
+ return try {
187
+ if (!method.isAccessible) {
188
+ method.isAccessible = true
189
+ }
190
+ GetterResult(value = method.invoke(target), invocationFailed = false)
191
+ } catch (_: Throwable) {
192
+ GetterResult(value = null, invocationFailed = true)
193
+ }
194
+ }
195
+
196
+ private fun hasDeviceIdentity(device: Any?): Boolean {
197
+ if (device == null) {
198
+ return false
199
+ }
200
+
201
+ if (hasNonBlankGetterValue(device, "getManufacturer")) {
202
+ return true
203
+ }
204
+ if (hasNonBlankGetterValue(device, "getModel")) {
205
+ return true
206
+ }
207
+ if (hasPositiveNumericGetterValue(device, "getType")) {
208
+ return true
209
+ }
210
+
211
+ val text = device.toString().trim()
212
+ if (text.isEmpty() || text == "null") {
213
+ return false
214
+ }
215
+ if (Regex("^[\\w.$]+@[0-9a-fA-F]+$").matches(text)) {
216
+ return false
217
+ }
218
+ return true
219
+ }
220
+
221
+ private fun hasNonBlankGetterValue(target: Any, methodName: String): Boolean {
222
+ return readNonBlankGetterValue(target, methodName).isNotEmpty()
223
+ }
224
+
225
+ private fun hasPositiveNumericGetterValue(target: Any, methodName: String): Boolean {
226
+ val result = invokeGetter(target, methodName)
227
+ if (result.invocationFailed) {
228
+ return false
229
+ }
230
+ val value = result.value
231
+ if (value !is Number) {
232
+ return false
233
+ }
234
+ return (safeToInt(value) ?: 0) > 0
235
+ }
236
+
237
+ private fun readNonBlankGetterValue(target: Any, methodName: String): String {
238
+ val result = invokeGetter(target, methodName)
239
+ if (result.invocationFailed) {
240
+ return ""
241
+ }
242
+ val text = result.value?.toString()?.trim().orEmpty()
243
+ return if (text.isNotEmpty() && text != "null") text else ""
244
+ }
245
+
246
+ private fun safeToInt(value: Number): Int? {
247
+ return runCatching { value.toInt() }.getOrNull()
248
+ }
249
+ }