@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.
- package/CarivaExerciseSdk.podspec +20 -0
- package/README.md +374 -0
- package/android/build.gradle +66 -0
- package/android/src/main/AndroidManifest.xml +40 -0
- package/android/src/main/java/com/carivaexercisesdk/CarivaExerciseSdkPackage.kt +30 -0
- package/android/src/main/java/com/carivaexercisesdk/HealthConnectModule.kt +222 -0
- package/android/src/main/java/com/carivaexercisesdk/HealthConnectPermissionUsageActivity.kt +11 -0
- package/android/src/main/java/com/carivaexercisesdk/HealthConnectPermissionsRationaleActivity.kt +11 -0
- package/android/src/main/java/com/carivaexercisesdk/Pagination.kt +23 -0
- package/android/src/main/java/com/carivaexercisesdk/application/connection/command/ConnectCommand.kt +12 -0
- package/android/src/main/java/com/carivaexercisesdk/application/connection/dto/ConnectResultDto.kt +13 -0
- package/android/src/main/java/com/carivaexercisesdk/application/connection/handler/ConnectHandler.kt +79 -0
- package/android/src/main/java/com/carivaexercisesdk/application/connection/port/HealthConnectConnectionPort.kt +16 -0
- package/android/src/main/java/com/carivaexercisesdk/application/connection/port/PermissionRequestPort.kt +5 -0
- package/android/src/main/java/com/carivaexercisesdk/application/datasource/command/SetDatasourcePolicyCommand.kt +6 -0
- package/android/src/main/java/com/carivaexercisesdk/application/datasource/handler/GetDatasourcePolicyHandler.kt +10 -0
- package/android/src/main/java/com/carivaexercisesdk/application/datasource/handler/SetDatasourcePolicyHandler.kt +22 -0
- package/android/src/main/java/com/carivaexercisesdk/application/datasource/port/DatasourcePolicyRepository.kt +9 -0
- package/android/src/main/java/com/carivaexercisesdk/application/exercise/dto/BasalReadResultDto.kt +11 -0
- package/android/src/main/java/com/carivaexercisesdk/application/exercise/dto/ExerciseDataDto.kt +41 -0
- package/android/src/main/java/com/carivaexercisesdk/application/exercise/handler/GetExerciseDataHandler.kt +346 -0
- package/android/src/main/java/com/carivaexercisesdk/application/exercise/port/ExerciseRecordPort.kt +45 -0
- package/android/src/main/java/com/carivaexercisesdk/application/exercise/query/GetExerciseDataQuery.kt +9 -0
- package/android/src/main/java/com/carivaexercisesdk/domain/connection/entity/ConnectionAggregate.kt +13 -0
- package/android/src/main/java/com/carivaexercisesdk/domain/connection/service/ConnectionDecisionService.kt +37 -0
- package/android/src/main/java/com/carivaexercisesdk/domain/connection/valueobject/ConnectionNextAction.kt +8 -0
- package/android/src/main/java/com/carivaexercisesdk/domain/connection/valueobject/HealthConnectStatus.kt +8 -0
- package/android/src/main/java/com/carivaexercisesdk/domain/connection/valueobject/PermissionScope.kt +36 -0
- package/android/src/main/java/com/carivaexercisesdk/domain/datasource/entity/DatasourcePolicy.kt +8 -0
- package/android/src/main/java/com/carivaexercisesdk/domain/datasource/valueobject/DatasourceType.kt +14 -0
- package/android/src/main/java/com/carivaexercisesdk/domain/exercise/services/ExerciseDomainService.kt +89 -0
- package/android/src/main/java/com/carivaexercisesdk/domain/exercise/services/HealthConnectCaloriesFallbackMerger.kt +351 -0
- package/android/src/main/java/com/carivaexercisesdk/domain/exercise/services/HealthConnectInternalsConstants.kt +55 -0
- package/android/src/main/java/com/carivaexercisesdk/domain/exercise/services/HealthConnectModuleInternals.kt +316 -0
- package/android/src/main/java/com/carivaexercisesdk/domain/exercise/services/HealthConnectOverlapNormalizer.kt +400 -0
- package/android/src/main/java/com/carivaexercisesdk/domain/exercise/services/HealthConnectSourceTrust.kt +249 -0
- package/android/src/main/java/com/carivaexercisesdk/domain/exercise/services/HealthConnectUnifiedRecordBuilder.kt +316 -0
- package/android/src/main/java/com/carivaexercisesdk/infrastructure/healthconnect/HealthConnectConnectionAdapter.kt +156 -0
- package/android/src/main/java/com/carivaexercisesdk/infrastructure/healthconnect/HealthConnectExerciseRecordAdapter.kt +464 -0
- package/android/src/main/java/com/carivaexercisesdk/infrastructure/persistence/InMemoryDatasourcePolicyRepository.kt +21 -0
- package/android/src/main/java/com/carivaexercisesdk/infrastructure/reactnative/ConnectResultMapper.kt +23 -0
- package/android/src/main/java/com/carivaexercisesdk/infrastructure/reactnative/DatasourcePolicyJsonParser.kt +51 -0
- package/android/src/main/java/com/carivaexercisesdk/infrastructure/reactnative/DatasourcePolicyMapper.kt +46 -0
- package/android/src/main/java/com/carivaexercisesdk/infrastructure/reactnative/ExerciseDataMapper.kt +99 -0
- package/android/src/test/java/com/carivaexercisesdk/ArchitectureDependencyRuleTest.kt +60 -0
- package/android/src/test/java/com/carivaexercisesdk/HealthConnectModuleDatasourceParserTest.kt +69 -0
- package/android/src/test/java/com/carivaexercisesdk/HealthConnectModuleInternalsTest.kt +406 -0
- package/android/src/test/java/com/carivaexercisesdk/application/connection/handler/ConnectHandlerTest.kt +153 -0
- package/android/src/test/java/com/carivaexercisesdk/application/datasource/handler/DatasourcePolicyRoundTripTest.kt +63 -0
- package/android/src/test/java/com/carivaexercisesdk/application/datasource/handler/SetDatasourcePolicyHandlerTest.kt +42 -0
- package/android/src/test/java/com/carivaexercisesdk/domain/connection/service/ConnectionDecisionServiceTest.kt +68 -0
- package/android/src/test/java/com/carivaexercisesdk/infrastructure/reactnative/DatasourcePolicyMapperTest.kt +22 -0
- package/ios/CarivaExerciseSdk.h +5 -0
- package/ios/CarivaExerciseSdk.mm +7 -0
- package/lib/module/connect/index.js +7 -0
- package/lib/module/connect/index.js.map +1 -0
- package/lib/module/datasource/index.js +10 -0
- package/lib/module/datasource/index.js.map +1 -0
- package/lib/module/exercise/index.js +7 -0
- package/lib/module/exercise/index.js.map +1 -0
- package/lib/module/index.js +6 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/native/module.js +13 -0
- package/lib/module/native/module.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/connect/index.d.ts +16 -0
- package/lib/typescript/src/connect/index.d.ts.map +1 -0
- package/lib/typescript/src/datasource/index.d.ts +12 -0
- package/lib/typescript/src/datasource/index.d.ts.map +1 -0
- package/lib/typescript/src/exercise/index.d.ts +64 -0
- package/lib/typescript/src/exercise/index.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +4 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/native/module.d.ts +14 -0
- package/lib/typescript/src/native/module.d.ts.map +1 -0
- package/package.json +127 -0
- package/src/connect/index.ts +34 -0
- package/src/datasource/index.ts +20 -0
- package/src/exercise/index.ts +75 -0
- package/src/index.tsx +22 -0
- 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
|
+
}
|