@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
package/android/src/main/java/com/carivaexercisesdk/infrastructure/reactnative/ExerciseDataMapper.kt
ADDED
|
@@ -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
|
+
}
|
package/android/src/test/java/com/carivaexercisesdk/HealthConnectModuleDatasourceParserTest.kt
ADDED
|
@@ -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
|
+
}
|