@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,99 @@
1
+ package com.carivaexercisesdk.infrastructure.reactnative
2
+
3
+ import com.carivaexercisesdk.application.exercise.dto.ExerciseDataDto
4
+ import com.facebook.react.bridge.Arguments
5
+ import com.facebook.react.bridge.WritableMap
6
+
7
+ internal object ExerciseDataMapper {
8
+ fun toWritableMap(data: ExerciseDataDto): WritableMap {
9
+ return Arguments.createMap().apply {
10
+ putDouble("totalSteps", data.totalSteps)
11
+ putDouble("totalActiveTimeMillis", data.totalActiveTimeMillis)
12
+ // Backward-compatible alias retained for one release cycle.
13
+ putDouble("totalActiveTime", data.totalActiveTimeMillis)
14
+ putDouble("totalKcal", data.totalKcal)
15
+ putDouble("totalDistanceMeters", data.totalDistanceMeters)
16
+ putString("timeZone", data.timeZone)
17
+ putArray("records", toRecordArray(data))
18
+
19
+ if (data.warnings.isNotEmpty()) {
20
+ putArray(
21
+ "warnings",
22
+ Arguments.createArray().apply {
23
+ data.warnings.forEach { warning -> pushString(warning) }
24
+ }
25
+ )
26
+ }
27
+
28
+ putMap(
29
+ "integrity",
30
+ Arguments.createMap().apply {
31
+ putBoolean("projectionPreserved", data.integrityProjectionPreserved)
32
+ putInt("roundingScale", data.integrityRoundingScale)
33
+ }
34
+ )
35
+
36
+ data.diagnostics?.let { diagnostics ->
37
+ putMap(
38
+ "diagnostics",
39
+ Arguments.createMap().apply {
40
+ putInt("fallbackUsedSegments", diagnostics.fallbackUsedSegments)
41
+ putInt("fallbackSuppressedSegments", diagnostics.fallbackSuppressedSegments)
42
+ putInt("idleGateSuppressedSegments", diagnostics.idleGateSuppressedSegments)
43
+ putBoolean("basalSeedApplied", diagnostics.basalSeedApplied)
44
+ putBoolean("seededFromBeforeSince", diagnostics.seededFromBeforeSince)
45
+ diagnostics.basalSeedAgeHours?.let { putDouble("basalSeedAgeHours", it) }
46
+ }
47
+ )
48
+ }
49
+
50
+ data.debug?.let { debug ->
51
+ putMap(
52
+ "debug",
53
+ Arguments.createMap().apply {
54
+ putDouble("directActiveCaloriesKcal", debug.directActiveCaloriesKcal)
55
+ putDouble("totalCaloriesKcal", debug.totalCaloriesKcal)
56
+ putDouble("basalCaloriesKcal", debug.basalCaloriesKcal)
57
+ putDouble("mergedActiveCaloriesKcal", debug.mergedActiveCaloriesKcal)
58
+ putDouble("fallbackContributionKcal", debug.fallbackContributionKcal)
59
+ putInt("fallbackUsedSegments", debug.fallbackUsedSegments)
60
+ putInt("fallbackSuppressedSegments", debug.fallbackSuppressedSegments)
61
+ putInt("idleGateSuppressedSegments", debug.idleGateSuppressedSegments)
62
+ putBoolean("basalSeedApplied", debug.basalSeedApplied)
63
+ putBoolean("seededFromBeforeSince", debug.seededFromBeforeSince)
64
+ debug.basalSeedAgeHours?.let { putDouble("basalSeedAgeHours", it) }
65
+ putInt("basalIntervalsCount", debug.basalIntervalsCount)
66
+ }
67
+ )
68
+ }
69
+ }
70
+ }
71
+
72
+ private fun toRecordArray(data: ExerciseDataDto) =
73
+ Arguments.createArray().apply {
74
+ data.records.forEach { record ->
75
+ pushMap(
76
+ Arguments.createMap().apply {
77
+ putDouble("steps", record.steps)
78
+ putDouble("activeCalories", record.activeCalories)
79
+ putString("activeCaloriesUnit", record.activeCaloriesUnit)
80
+ putDouble("distance", record.distance)
81
+ putString("distanceUnit", record.distanceUnit)
82
+ putDouble("activeTime", record.activeTime)
83
+ putString("activeTimeUnit", record.activeTimeUnit)
84
+ putString("startDate", record.startDate)
85
+ putString("endDate", record.endDate)
86
+ putMap(
87
+ "meta",
88
+ Arguments.createMap().apply {
89
+ putString("source", record.meta.source)
90
+ putString("device", record.meta.device)
91
+ putString("deviceModel", record.meta.deviceModel)
92
+ putString("trustLevel", record.meta.trustLevel)
93
+ }
94
+ )
95
+ }
96
+ )
97
+ }
98
+ }
99
+ }
@@ -0,0 +1,60 @@
1
+ package com.carivaexercisesdk
2
+
3
+ import java.io.File
4
+ import org.junit.Assert.assertTrue
5
+ import org.junit.Test
6
+
7
+ class ArchitectureDependencyRuleTest {
8
+
9
+ @Test
10
+ fun domainLayer_hasNoAndroidOrReactImports() {
11
+ val violations =
12
+ findForbiddenImports(
13
+ layerPath = "src/main/java/com/carivaexercisesdk/domain",
14
+ forbiddenPrefixes = listOf("android.", "androidx.", "com.facebook.react.")
15
+ )
16
+
17
+ assertTrue("Domain layer import violations:\n${violations.joinToString("\n")}", violations.isEmpty())
18
+ }
19
+
20
+ @Test
21
+ fun applicationLayer_hasNoAndroidOrReactImports() {
22
+ val violations =
23
+ findForbiddenImports(
24
+ layerPath = "src/main/java/com/carivaexercisesdk/application",
25
+ forbiddenPrefixes = listOf("android.", "androidx.", "com.facebook.react.")
26
+ )
27
+
28
+ assertTrue(
29
+ "Application layer import violations:\n${violations.joinToString("\n")}",
30
+ violations.isEmpty()
31
+ )
32
+ }
33
+
34
+ private fun findForbiddenImports(
35
+ layerPath: String,
36
+ forbiddenPrefixes: List<String>
37
+ ): List<String> {
38
+ val layerDir = File(layerPath)
39
+ if (!layerDir.exists()) {
40
+ return emptyList()
41
+ }
42
+
43
+ val violations = mutableListOf<String>()
44
+ layerDir.walkTopDown().filter { it.isFile && it.extension == "kt" }.forEach { file ->
45
+ file.readLines().forEachIndexed { index, line ->
46
+ val trimmed = line.trim()
47
+ if (!trimmed.startsWith("import ")) {
48
+ return@forEachIndexed
49
+ }
50
+
51
+ val importValue = trimmed.removePrefix("import ").trim()
52
+ if (forbiddenPrefixes.any(importValue::startsWith)) {
53
+ violations += "${file.path}:${index + 1} -> $importValue"
54
+ }
55
+ }
56
+ }
57
+
58
+ return violations
59
+ }
60
+ }
@@ -0,0 +1,69 @@
1
+ package com.carivaexercisesdk
2
+
3
+ import com.carivaexercisesdk.domain.datasource.valueobject.DatasourceType
4
+ import com.carivaexercisesdk.infrastructure.reactnative.DatasourcePolicyJsonParser
5
+ import org.junit.Assert.assertEquals
6
+ import org.junit.Test
7
+
8
+ class HealthConnectModuleDatasourceParserTest {
9
+ @Test
10
+ fun resolveAllowlist_defaultsToAllSources_whenKeyMissing() {
11
+ val parsed =
12
+ DatasourcePolicyJsonParser.resolveAllowlist(
13
+ hasAllowlistKey = false,
14
+ parsedAllowlist = emptyList()
15
+ )
16
+
17
+ assertEquals(
18
+ DatasourceType.entries.map { it.wireValue }.toSet(),
19
+ parsed.toSet()
20
+ )
21
+ }
22
+
23
+ @Test
24
+ fun resolveAllowlist_keepsExplicitEmptyArray() {
25
+ val parsed =
26
+ DatasourcePolicyJsonParser.resolveAllowlist(
27
+ hasAllowlistKey = true,
28
+ parsedAllowlist = emptyList()
29
+ )
30
+
31
+ assertEquals(emptyList<String>(), parsed)
32
+ }
33
+
34
+ @Test
35
+ fun resolveScopePackages_keepsProvidedValues() {
36
+ val parsed =
37
+ DatasourcePolicyJsonParser.resolveScopePackages(
38
+ listOf(" com.fit.app ", "com.google.fit")
39
+ )
40
+
41
+ assertEquals(listOf(" com.fit.app ", "com.google.fit"), parsed)
42
+ }
43
+
44
+ @Test
45
+ fun resolveScopePackages_returnsEmpty_whenScopeMissing() {
46
+ val parsed = DatasourcePolicyJsonParser.resolveScopePackages(null)
47
+ assertEquals(emptyList<String>(), parsed)
48
+ }
49
+
50
+ @Test
51
+ fun resolveAllowlist_keepsRawValuesForDownstreamNormalization() {
52
+ val parsed =
53
+ DatasourcePolicyJsonParser.resolveAllowlist(
54
+ hasAllowlistKey = true,
55
+ parsedAllowlist = listOf("device", " app ", "unknown_token")
56
+ )
57
+
58
+ assertEquals(listOf("device", " app ", "unknown_token"), parsed)
59
+ }
60
+
61
+ @Test
62
+ fun resolveScopePackages_readsScopePackageValues() {
63
+ val parsed =
64
+ DatasourcePolicyJsonParser.resolveScopePackages(
65
+ listOf(" com.fit.app ", "com.google.fit")
66
+ )
67
+ assertEquals(listOf(" com.fit.app ", "com.google.fit"), parsed)
68
+ }
69
+ }
@@ -0,0 +1,406 @@
1
+ package com.carivaexercisesdk
2
+
3
+ import com.carivaexercisesdk.domain.exercise.services.HealthConnectModuleInternals
4
+ import java.time.ZoneId
5
+ import kotlin.random.Random
6
+ import kotlinx.coroutines.runBlocking
7
+ import org.junit.Assert.assertEquals
8
+ import org.junit.Assert.assertFalse
9
+ import org.junit.Assert.assertTrue
10
+ import org.junit.Test
11
+
12
+ class HealthConnectModuleInternalsTest {
13
+ private val utc = ZoneId.of("UTC")
14
+
15
+ @Test
16
+ fun evaluateRecordTrust_manualWithDevice_allowedAsLowConfidence() {
17
+ val trust =
18
+ HealthConnectModuleInternals.evaluateRecordTrust(
19
+ FakeMetadata(recordingMethod = 3, device = FakeDevice(manufacturer = "ACME"))
20
+ )
21
+
22
+ assertTrue(trust.allowed)
23
+ assertEquals("manual_input", trust.sourceType)
24
+ assertEquals("low_confidence", trust.trustLevel)
25
+ }
26
+
27
+ @Test
28
+ fun evaluateRecordTrust_manualWithoutDevice_rejected() {
29
+ val trust =
30
+ HealthConnectModuleInternals.evaluateRecordTrust(
31
+ FakeMetadata(recordingMethod = 3, device = null)
32
+ )
33
+
34
+ assertFalse(trust.allowed)
35
+ assertEquals("manual_input", trust.sourceType)
36
+ assertEquals("rejected", trust.trustLevel)
37
+ }
38
+
39
+ @Test
40
+ fun evaluateRecordTrust_unknownWithDevice_allowedAsLowConfidence() {
41
+ val trust =
42
+ HealthConnectModuleInternals.evaluateRecordTrust(
43
+ FakeMetadata(recordingMethod = 99, device = FakeDevice(model = "Tracker"))
44
+ )
45
+
46
+ assertTrue(trust.allowed)
47
+ assertEquals("unknown", trust.sourceType)
48
+ assertEquals("low_confidence", trust.trustLevel)
49
+ }
50
+
51
+ @Test
52
+ fun normalizeOverlaps_stepsSamePriority_isDeterministicAndNoDoubleCount() {
53
+ val intervalA =
54
+ HealthConnectModuleInternals.IntervalInput(
55
+ startMs = 0,
56
+ endMs = 2_000,
57
+ totalValue = 100.0,
58
+ sourceType = "device",
59
+ packageName = "a.pkg",
60
+ deviceModel = "A"
61
+ )
62
+ val intervalB =
63
+ HealthConnectModuleInternals.IntervalInput(
64
+ startMs = 1_000,
65
+ endMs = 3_000,
66
+ totalValue = 120.0,
67
+ sourceType = "device",
68
+ packageName = "b.pkg",
69
+ deviceModel = "B"
70
+ )
71
+
72
+ val resultForward =
73
+ HealthConnectModuleInternals.normalizeOverlaps(
74
+ metricType = HealthConnectModuleInternals.MetricType.STEPS,
75
+ rawIntervals = listOf(intervalA, intervalB),
76
+ sinceMs = 0,
77
+ untilMs = 3_000,
78
+ bucketPeriod = "hourly",
79
+ zoneId = utc
80
+ )
81
+ val resultReversed =
82
+ HealthConnectModuleInternals.normalizeOverlaps(
83
+ metricType = HealthConnectModuleInternals.MetricType.STEPS,
84
+ rawIntervals = listOf(intervalB, intervalA),
85
+ sinceMs = 0,
86
+ untilMs = 3_000,
87
+ bucketPeriod = "hourly",
88
+ zoneId = utc
89
+ )
90
+
91
+ assertEquals(160.0, resultForward.segments.sumOf { it.value }, 1e-6)
92
+ assertSegmentsEqual(resultForward.segments, resultReversed.segments)
93
+ }
94
+
95
+ @Test
96
+ fun mergeCaloriesWithFallback_gapFillOnly_keepsPrimaryWhenCovered() {
97
+ val primary =
98
+ normalizationResult(
99
+ listOf(
100
+ segment(
101
+ startMs = 0,
102
+ endMs = 1_000,
103
+ value = 10.0,
104
+ sourceType = "device"
105
+ )
106
+ )
107
+ )
108
+ val total =
109
+ normalizationResult(
110
+ listOf(
111
+ segment(
112
+ startMs = 0,
113
+ endMs = 1_000,
114
+ value = 30.0,
115
+ sourceType = "device"
116
+ )
117
+ )
118
+ )
119
+ val basal =
120
+ normalizationResult(
121
+ listOf(
122
+ segment(
123
+ startMs = 0,
124
+ endMs = 1_000,
125
+ value = 10.0,
126
+ sourceType = "device"
127
+ )
128
+ )
129
+ )
130
+ val stepsEvidence =
131
+ listOf(segment(startMs = 0, endMs = 1_000, value = 1.0, sourceType = "device"))
132
+
133
+ val merged =
134
+ HealthConnectModuleInternals.mergeCaloriesWithFallback(
135
+ primaryActiveCalories = primary,
136
+ totalCalories = total,
137
+ basalCalories = basal,
138
+ stepsEvidenceSegments = stepsEvidence
139
+ )
140
+
141
+ assertEquals(10.0, merged.segments.sumOf { it.value }, 1e-6)
142
+ assertEquals(0, merged.diagnostics?.fallbackUsedSegments ?: -1)
143
+ }
144
+
145
+ @Test
146
+ fun mergeCaloriesWithFallback_fillsOnlyUncoveredWindows() {
147
+ val primary =
148
+ normalizationResult(
149
+ listOf(
150
+ segment(
151
+ startMs = 0,
152
+ endMs = 500,
153
+ value = 10.0,
154
+ sourceType = "device"
155
+ )
156
+ )
157
+ )
158
+ val total =
159
+ normalizationResult(
160
+ listOf(
161
+ segment(
162
+ startMs = 0,
163
+ endMs = 1_000,
164
+ value = 40.0,
165
+ sourceType = "device"
166
+ )
167
+ )
168
+ )
169
+ val basal =
170
+ normalizationResult(
171
+ listOf(
172
+ segment(
173
+ startMs = 0,
174
+ endMs = 1_000,
175
+ value = 20.0,
176
+ sourceType = "device"
177
+ )
178
+ )
179
+ )
180
+ val stepsEvidence =
181
+ listOf(segment(startMs = 0, endMs = 1_000, value = 1.0, sourceType = "device"))
182
+
183
+ val merged =
184
+ HealthConnectModuleInternals.mergeCaloriesWithFallback(
185
+ primaryActiveCalories = primary,
186
+ totalCalories = total,
187
+ basalCalories = basal,
188
+ stepsEvidenceSegments = stepsEvidence
189
+ )
190
+
191
+ assertEquals(20.0, merged.segments.sumOf { it.value }, 1e-6)
192
+ assertEquals(1, merged.diagnostics?.fallbackUsedSegments ?: -1)
193
+ }
194
+
195
+ @Test
196
+ fun collectPagedRecords_readsAllPages() = runBlocking {
197
+ val calls = mutableListOf<String>()
198
+ val pages =
199
+ mapOf(
200
+ "" to PagedResult(records = listOf(1, 2), nextPageToken = "p2"),
201
+ "p2" to PagedResult(records = listOf(3), nextPageToken = "p3"),
202
+ "p3" to PagedResult(records = listOf(4, 5), nextPageToken = "")
203
+ )
204
+
205
+ val result =
206
+ collectPagedRecords { token ->
207
+ calls += token
208
+ pages.getValue(token)
209
+ }
210
+
211
+ assertEquals(listOf("", "p2", "p3"), calls)
212
+ assertEquals(listOf(1, 2, 3, 4, 5), result)
213
+ }
214
+
215
+ @Test
216
+ fun collectPagedRecords_stopsWhenNextPageTokenMissing() = runBlocking {
217
+ var callCount = 0
218
+
219
+ val result =
220
+ collectPagedRecords<Int> {
221
+ callCount += 1
222
+ PagedResult(records = listOf(42), nextPageToken = null)
223
+ }
224
+
225
+ assertEquals(1, callCount)
226
+ assertEquals(listOf(42), result)
227
+ }
228
+
229
+ @Test
230
+ fun isRecordAllowed_respectsAllowlistMembership() {
231
+ val allowlist = setOf("device", "app")
232
+
233
+ assertTrue(HealthConnectModuleInternals.isRecordAllowed("device", allowlist))
234
+ assertFalse(HealthConnectModuleInternals.isRecordAllowed("manual_input", allowlist))
235
+ }
236
+
237
+ @Test
238
+ fun normalizePackageAllowlist_andIsPackageAllowed_followPolicyRules() {
239
+ val packageAllowlist =
240
+ HealthConnectModuleInternals.normalizePackageAllowlist(
241
+ listOf(" COM.FIT.APP ", "", "com.fit.app", "com.google.fit")
242
+ )
243
+
244
+ assertEquals(setOf("com.fit.app", "com.google.fit"), packageAllowlist)
245
+ assertTrue(HealthConnectModuleInternals.isPackageAllowed("com.fit.app", packageAllowlist))
246
+ assertTrue(
247
+ HealthConnectModuleInternals.isPackageAllowed(
248
+ " COM.GOOGLE.FIT ",
249
+ packageAllowlist
250
+ )
251
+ )
252
+ assertFalse(HealthConnectModuleInternals.isPackageAllowed("", packageAllowlist))
253
+ assertFalse(HealthConnectModuleInternals.isPackageAllowed(null, packageAllowlist))
254
+ assertTrue(HealthConnectModuleInternals.isPackageAllowed("", emptySet()))
255
+ }
256
+
257
+ @Test
258
+ fun policyFiltering_monotonicForRandomizedDataset() {
259
+ val random = Random(20260226)
260
+ val sourcePool = listOf("manual_input", "device", "app", "unknown")
261
+ val packagePool = listOf("com.a.fit", "com.b.tracker")
262
+ val broadAllowlist = sourcePool.toSet()
263
+ val narrowAllowlist = setOf("device", "app")
264
+ val packageScope = setOf("com.a.fit")
265
+
266
+ repeat(200) {
267
+ val samples =
268
+ List(40) {
269
+ HealthConnectModuleInternals.IntervalInput(
270
+ startMs = 0,
271
+ endMs = 1000,
272
+ totalValue = random.nextDouble(0.1, 200.0),
273
+ sourceType = sourcePool[random.nextInt(sourcePool.size)],
274
+ packageName = packagePool[random.nextInt(packagePool.size)],
275
+ deviceModel = "model"
276
+ )
277
+ }
278
+
279
+ val unrestrictedTotal =
280
+ samples
281
+ .filter { HealthConnectModuleInternals.isRecordAllowed(it.sourceType, broadAllowlist) }
282
+ .sumOf { it.totalValue }
283
+ val narrowSourceTotal =
284
+ samples
285
+ .filter { HealthConnectModuleInternals.isRecordAllowed(it.sourceType, narrowAllowlist) }
286
+ .sumOf { it.totalValue }
287
+ val narrowSourceAndPackageTotal =
288
+ samples
289
+ .filter { HealthConnectModuleInternals.isRecordAllowed(it.sourceType, narrowAllowlist) }
290
+ .filter {
291
+ HealthConnectModuleInternals.isPackageAllowed(it.packageName, packageScope)
292
+ }
293
+ .sumOf { it.totalValue }
294
+
295
+ assertTrue(narrowSourceTotal <= unrestrictedTotal + 1e-9)
296
+ assertTrue(narrowSourceAndPackageTotal <= narrowSourceTotal + 1e-9)
297
+ }
298
+ }
299
+
300
+ @Test
301
+ fun policyFiltering_isDeterministicUnderInputReordering() {
302
+ val random = Random(20260227)
303
+ val sourcePool = listOf("manual_input", "device", "app", "unknown")
304
+ val packagePool = listOf("com.a.fit", "com.b.tracker")
305
+ val allowlist = setOf("device", "app", "unknown")
306
+ val packageScope = setOf("com.a.fit")
307
+
308
+ val samples =
309
+ List(80) {
310
+ HealthConnectModuleInternals.IntervalInput(
311
+ startMs = 0,
312
+ endMs = 1000,
313
+ totalValue = random.nextDouble(0.1, 200.0),
314
+ sourceType = sourcePool[random.nextInt(sourcePool.size)],
315
+ packageName = packagePool[random.nextInt(packagePool.size)],
316
+ deviceModel = "model"
317
+ )
318
+ }
319
+
320
+ fun signature(records: List<HealthConnectModuleInternals.IntervalInput>): List<String> {
321
+ return records
322
+ .filter { HealthConnectModuleInternals.isRecordAllowed(it.sourceType, allowlist) }
323
+ .filter { HealthConnectModuleInternals.isPackageAllowed(it.packageName, packageScope) }
324
+ .map { record ->
325
+ listOf(
326
+ record.startMs.toString(),
327
+ record.endMs.toString(),
328
+ record.totalValue.toString(),
329
+ HealthConnectModuleInternals.normalizeSourceType(record.sourceType),
330
+ record.packageName.trim().lowercase()
331
+ )
332
+ .joinToString("|")
333
+ }
334
+ .sorted()
335
+ }
336
+
337
+ val baselineSignature = signature(samples)
338
+ repeat(40) {
339
+ val shuffled = samples.shuffled(random)
340
+ assertEquals(baselineSignature, signature(shuffled))
341
+ }
342
+ }
343
+
344
+ private fun normalizationResult(
345
+ segments: List<HealthConnectModuleInternals.NormalizedSegment>
346
+ ): HealthConnectModuleInternals.NormalizationResult {
347
+ return HealthConnectModuleInternals.NormalizationResult(
348
+ segments = segments,
349
+ stats = HealthConnectModuleInternals.NormalizationStats(observedIntervals = segments.size),
350
+ overlapStrategy = "proportional"
351
+ )
352
+ }
353
+
354
+ private fun segment(
355
+ startMs: Long,
356
+ endMs: Long,
357
+ value: Double,
358
+ sourceType: String
359
+ ): HealthConnectModuleInternals.NormalizedSegment {
360
+ return HealthConnectModuleInternals.NormalizedSegment(
361
+ startMs = startMs,
362
+ endMs = endMs,
363
+ value = value,
364
+ sourceType = sourceType,
365
+ packageName = "pkg",
366
+ deviceModel = "model"
367
+ )
368
+ }
369
+
370
+ private fun assertSegmentsEqual(
371
+ expected: List<HealthConnectModuleInternals.NormalizedSegment>,
372
+ actual: List<HealthConnectModuleInternals.NormalizedSegment>
373
+ ) {
374
+ assertEquals(expected.size, actual.size)
375
+
376
+ expected.zip(actual).forEach { (left, right) ->
377
+ assertEquals(left.startMs, right.startMs)
378
+ assertEquals(left.endMs, right.endMs)
379
+ assertEquals(left.sourceType, right.sourceType)
380
+ assertEquals(left.packageName, right.packageName)
381
+ assertEquals(left.deviceModel, right.deviceModel)
382
+ assertEquals(left.value, right.value, 1e-9)
383
+ }
384
+ }
385
+
386
+ private class FakeMetadata(
387
+ private val recordingMethod: Any?,
388
+ private val device: Any?
389
+ ) {
390
+ fun getRecordingMethod(): Any? = recordingMethod
391
+
392
+ fun getDevice(): Any? = device
393
+ }
394
+
395
+ private class FakeDevice(
396
+ private val manufacturer: String? = null,
397
+ private val model: String? = null,
398
+ private val type: Int = 0
399
+ ) {
400
+ fun getManufacturer(): String? = manufacturer
401
+
402
+ fun getModel(): String? = model
403
+
404
+ fun getType(): Int = type
405
+ }
406
+ }