@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,464 @@
|
|
|
1
|
+
package com.carivaexercisesdk.infrastructure.healthconnect
|
|
2
|
+
|
|
3
|
+
import androidx.health.connect.client.HealthConnectClient
|
|
4
|
+
import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord
|
|
5
|
+
import androidx.health.connect.client.records.BasalMetabolicRateRecord
|
|
6
|
+
import androidx.health.connect.client.records.DistanceRecord
|
|
7
|
+
import androidx.health.connect.client.records.ExerciseSessionRecord
|
|
8
|
+
import androidx.health.connect.client.records.Record
|
|
9
|
+
import androidx.health.connect.client.records.StepsRecord
|
|
10
|
+
import androidx.health.connect.client.records.TotalCaloriesBurnedRecord
|
|
11
|
+
import androidx.health.connect.client.request.ReadRecordsRequest
|
|
12
|
+
import androidx.health.connect.client.time.TimeRangeFilter
|
|
13
|
+
import com.carivaexercisesdk.domain.exercise.services.HealthConnectModuleInternals
|
|
14
|
+
import com.carivaexercisesdk.PagedResult
|
|
15
|
+
import com.carivaexercisesdk.application.exercise.dto.BasalReadResultDto
|
|
16
|
+
import com.carivaexercisesdk.application.exercise.port.ExerciseRecordPort
|
|
17
|
+
import com.carivaexercisesdk.collectPagedRecords
|
|
18
|
+
import com.carivaexercisesdk.domain.connection.valueobject.HealthConnectStatus
|
|
19
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
20
|
+
import java.time.Instant
|
|
21
|
+
import kotlin.reflect.KClass
|
|
22
|
+
import android.util.Log
|
|
23
|
+
import kotlinx.coroutines.Dispatchers
|
|
24
|
+
import kotlinx.coroutines.withContext
|
|
25
|
+
|
|
26
|
+
internal class HealthConnectExerciseRecordAdapter(
|
|
27
|
+
private val reactApplicationContext: ReactApplicationContext,
|
|
28
|
+
private val healthConnectClient: HealthConnectClient,
|
|
29
|
+
private val datasourceAllowlistProvider: () -> Set<String>,
|
|
30
|
+
private val datasourcePackageAllowlistProvider: () -> Set<String>
|
|
31
|
+
) : ExerciseRecordPort {
|
|
32
|
+
|
|
33
|
+
override suspend fun getHealthConnectStatus(): HealthConnectStatus {
|
|
34
|
+
val sdkStatus = HealthConnectClient.getSdkStatus(reactApplicationContext)
|
|
35
|
+
return when (sdkStatus) {
|
|
36
|
+
HealthConnectClient.SDK_AVAILABLE -> HealthConnectStatus.AVAILABLE
|
|
37
|
+
HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED ->
|
|
38
|
+
HealthConnectStatus.PROVIDER_UPDATE_REQUIRED
|
|
39
|
+
HealthConnectClient.SDK_UNAVAILABLE -> HealthConnectStatus.UNAVAILABLE
|
|
40
|
+
else -> HealthConnectStatus.UNKNOWN
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
override suspend fun readStepsIntervals(
|
|
45
|
+
startInstant: Instant,
|
|
46
|
+
endInstant: Instant,
|
|
47
|
+
includeManualWithDevice: Boolean
|
|
48
|
+
): List<HealthConnectModuleInternals.IntervalInput> {
|
|
49
|
+
return safeReadRecordsWithPagination(
|
|
50
|
+
label = "steps",
|
|
51
|
+
recordType = StepsRecord::class,
|
|
52
|
+
startInstant = startInstant,
|
|
53
|
+
endInstant = endInstant
|
|
54
|
+
)
|
|
55
|
+
.mapNotNull { record ->
|
|
56
|
+
runCatching {
|
|
57
|
+
if (!filterRecord(record.metadata, includeManualWithDevice)) {
|
|
58
|
+
return@runCatching null
|
|
59
|
+
}
|
|
60
|
+
HealthConnectModuleInternals.IntervalInput(
|
|
61
|
+
startMs = record.startTime.toEpochMilli(),
|
|
62
|
+
endMs = record.endTime.toEpochMilli(),
|
|
63
|
+
totalValue = record.count.toDouble(),
|
|
64
|
+
sourceType = resolveSourceType(record.metadata),
|
|
65
|
+
packageName = resolvePackageName(record.metadata),
|
|
66
|
+
deviceModel =
|
|
67
|
+
HealthConnectModuleInternals.resolveDeviceModel(
|
|
68
|
+
record.metadata
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
.getOrNull()
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
override suspend fun readActiveTimeIntervals(
|
|
77
|
+
startInstant: Instant,
|
|
78
|
+
endInstant: Instant,
|
|
79
|
+
includeManualWithDevice: Boolean
|
|
80
|
+
): List<HealthConnectModuleInternals.IntervalInput> {
|
|
81
|
+
return safeReadRecordsWithPagination(
|
|
82
|
+
label = "active_time",
|
|
83
|
+
recordType = ExerciseSessionRecord::class,
|
|
84
|
+
startInstant = startInstant,
|
|
85
|
+
endInstant = endInstant
|
|
86
|
+
)
|
|
87
|
+
.mapNotNull { record ->
|
|
88
|
+
runCatching {
|
|
89
|
+
if (!filterRecord(record.metadata, includeManualWithDevice)) {
|
|
90
|
+
return@runCatching null
|
|
91
|
+
}
|
|
92
|
+
HealthConnectModuleInternals.IntervalInput(
|
|
93
|
+
startMs = record.startTime.toEpochMilli(),
|
|
94
|
+
endMs = record.endTime.toEpochMilli(),
|
|
95
|
+
totalValue =
|
|
96
|
+
(record.endTime.toEpochMilli() -
|
|
97
|
+
record.startTime.toEpochMilli())
|
|
98
|
+
.toDouble(),
|
|
99
|
+
sourceType = resolveSourceType(record.metadata),
|
|
100
|
+
packageName = resolvePackageName(record.metadata),
|
|
101
|
+
deviceModel =
|
|
102
|
+
HealthConnectModuleInternals.resolveDeviceModel(
|
|
103
|
+
record.metadata
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
.getOrNull()
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
override suspend fun readCaloriesIntervals(
|
|
112
|
+
startInstant: Instant,
|
|
113
|
+
endInstant: Instant,
|
|
114
|
+
includeManualWithDevice: Boolean
|
|
115
|
+
): List<HealthConnectModuleInternals.IntervalInput> {
|
|
116
|
+
return safeReadRecordsWithPagination(
|
|
117
|
+
label = "active_calories",
|
|
118
|
+
recordType = ActiveCaloriesBurnedRecord::class,
|
|
119
|
+
startInstant = startInstant,
|
|
120
|
+
endInstant = endInstant
|
|
121
|
+
)
|
|
122
|
+
.mapNotNull { record ->
|
|
123
|
+
runCatching {
|
|
124
|
+
if (!filterRecord(record.metadata, includeManualWithDevice)) {
|
|
125
|
+
return@runCatching null
|
|
126
|
+
}
|
|
127
|
+
HealthConnectModuleInternals.IntervalInput(
|
|
128
|
+
startMs = record.startTime.toEpochMilli(),
|
|
129
|
+
endMs = record.endTime.toEpochMilli(),
|
|
130
|
+
totalValue = record.energy.inKilocalories,
|
|
131
|
+
sourceType = resolveSourceType(record.metadata),
|
|
132
|
+
packageName = resolvePackageName(record.metadata),
|
|
133
|
+
deviceModel =
|
|
134
|
+
HealthConnectModuleInternals.resolveDeviceModel(
|
|
135
|
+
record.metadata
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
.getOrNull()
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
override suspend fun readDistanceIntervals(
|
|
144
|
+
startInstant: Instant,
|
|
145
|
+
endInstant: Instant,
|
|
146
|
+
includeManualWithDevice: Boolean
|
|
147
|
+
): List<HealthConnectModuleInternals.IntervalInput> {
|
|
148
|
+
return safeReadRecordsWithPagination(
|
|
149
|
+
label = "distance",
|
|
150
|
+
recordType = DistanceRecord::class,
|
|
151
|
+
startInstant = startInstant,
|
|
152
|
+
endInstant = endInstant
|
|
153
|
+
)
|
|
154
|
+
.mapNotNull { record ->
|
|
155
|
+
runCatching {
|
|
156
|
+
if (!filterRecord(record.metadata, includeManualWithDevice)) {
|
|
157
|
+
return@runCatching null
|
|
158
|
+
}
|
|
159
|
+
HealthConnectModuleInternals.IntervalInput(
|
|
160
|
+
startMs = record.startTime.toEpochMilli(),
|
|
161
|
+
endMs = record.endTime.toEpochMilli(),
|
|
162
|
+
totalValue = record.distance.inMeters,
|
|
163
|
+
sourceType = resolveSourceType(record.metadata),
|
|
164
|
+
packageName = resolvePackageName(record.metadata),
|
|
165
|
+
deviceModel =
|
|
166
|
+
HealthConnectModuleInternals.resolveDeviceModel(
|
|
167
|
+
record.metadata
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
.getOrNull()
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
override suspend fun readTotalCaloriesIntervals(
|
|
176
|
+
startInstant: Instant,
|
|
177
|
+
endInstant: Instant,
|
|
178
|
+
includeManualWithDevice: Boolean
|
|
179
|
+
): List<HealthConnectModuleInternals.IntervalInput> {
|
|
180
|
+
return safeReadRecordsWithPagination(
|
|
181
|
+
label = "total_calories",
|
|
182
|
+
recordType = TotalCaloriesBurnedRecord::class,
|
|
183
|
+
startInstant = startInstant,
|
|
184
|
+
endInstant = endInstant
|
|
185
|
+
)
|
|
186
|
+
.mapNotNull { record ->
|
|
187
|
+
runCatching {
|
|
188
|
+
if (!filterRecord(record.metadata, includeManualWithDevice)) {
|
|
189
|
+
return@runCatching null
|
|
190
|
+
}
|
|
191
|
+
HealthConnectModuleInternals.IntervalInput(
|
|
192
|
+
startMs = record.startTime.toEpochMilli(),
|
|
193
|
+
endMs = record.endTime.toEpochMilli(),
|
|
194
|
+
totalValue = record.energy.inKilocalories,
|
|
195
|
+
sourceType = resolveSourceType(record.metadata),
|
|
196
|
+
packageName = resolvePackageName(record.metadata),
|
|
197
|
+
deviceModel =
|
|
198
|
+
HealthConnectModuleInternals.resolveDeviceModel(
|
|
199
|
+
record.metadata
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
.getOrNull()
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
override suspend fun readBasalCaloriesIntervals(
|
|
208
|
+
startInstant: Instant,
|
|
209
|
+
endInstant: Instant
|
|
210
|
+
): BasalReadResultDto {
|
|
211
|
+
val inRangeRecords =
|
|
212
|
+
safeReadRecordsWithPagination(
|
|
213
|
+
label = "basal_in_range",
|
|
214
|
+
recordType = BasalMetabolicRateRecord::class,
|
|
215
|
+
startInstant = startInstant,
|
|
216
|
+
endInstant = endInstant
|
|
217
|
+
)
|
|
218
|
+
val beforeRangeRecords =
|
|
219
|
+
safeReadRecordsWithPagination(
|
|
220
|
+
label = "basal_before_range",
|
|
221
|
+
recordType = BasalMetabolicRateRecord::class,
|
|
222
|
+
startInstant = Instant.EPOCH,
|
|
223
|
+
endInstant = startInstant
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
val millisPerDay = 86_400_000.0
|
|
227
|
+
val records =
|
|
228
|
+
inRangeRecords
|
|
229
|
+
.mapNotNull { record ->
|
|
230
|
+
runCatching {
|
|
231
|
+
if (filterRecord(record.metadata)) {
|
|
232
|
+
record
|
|
233
|
+
} else {
|
|
234
|
+
null
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
.getOrNull()
|
|
238
|
+
}
|
|
239
|
+
.sortedBy { it.time.toEpochMilli() }
|
|
240
|
+
val latestBeforeSince =
|
|
241
|
+
beforeRangeRecords
|
|
242
|
+
.mapNotNull { record ->
|
|
243
|
+
runCatching {
|
|
244
|
+
if (filterRecord(record.metadata)) {
|
|
245
|
+
record
|
|
246
|
+
} else {
|
|
247
|
+
null
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
.getOrNull()
|
|
251
|
+
}
|
|
252
|
+
.filter { it.time < startInstant }
|
|
253
|
+
.maxByOrNull { it.time.toEpochMilli() }
|
|
254
|
+
|
|
255
|
+
val intervals = mutableListOf<HealthConnectModuleInternals.IntervalInput>()
|
|
256
|
+
var basalSeedApplied = false
|
|
257
|
+
var seededFromBeforeSince = false
|
|
258
|
+
var basalSeedAgeHours: Double? = null
|
|
259
|
+
val warnings = linkedSetOf<String>()
|
|
260
|
+
|
|
261
|
+
if (latestBeforeSince != null) {
|
|
262
|
+
val seedAgeMs = startInstant.toEpochMilli() - latestBeforeSince.time.toEpochMilli()
|
|
263
|
+
val seedStartMs = startInstant.toEpochMilli()
|
|
264
|
+
val seedEndMs =
|
|
265
|
+
minOf(
|
|
266
|
+
records.firstOrNull()?.time?.toEpochMilli() ?: endInstant.toEpochMilli(),
|
|
267
|
+
endInstant.toEpochMilli()
|
|
268
|
+
)
|
|
269
|
+
if (seedEndMs > seedStartMs) {
|
|
270
|
+
basalSeedApplied = true
|
|
271
|
+
seededFromBeforeSince = true
|
|
272
|
+
basalSeedAgeHours = seedAgeMs.toDouble() / MILLIS_PER_HOUR
|
|
273
|
+
intervals.add(
|
|
274
|
+
buildBasalInterval(
|
|
275
|
+
startMs = seedStartMs,
|
|
276
|
+
endMs = seedEndMs,
|
|
277
|
+
kcalPerDay = latestBeforeSince.basalMetabolicRate.inKilocaloriesPerDay,
|
|
278
|
+
packageName = resolvePackageName(latestBeforeSince.metadata),
|
|
279
|
+
sourceType = resolveSourceType(latestBeforeSince.metadata),
|
|
280
|
+
deviceModel =
|
|
281
|
+
HealthConnectModuleInternals.resolveDeviceModel(
|
|
282
|
+
latestBeforeSince.metadata
|
|
283
|
+
),
|
|
284
|
+
millisPerDay = millisPerDay
|
|
285
|
+
)
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
val inRangeIntervals =
|
|
291
|
+
records.mapIndexedNotNull { index, record ->
|
|
292
|
+
val startMs = maxOf(record.time.toEpochMilli(), startInstant.toEpochMilli())
|
|
293
|
+
val nextMs =
|
|
294
|
+
records.getOrNull(index + 1)?.time?.toEpochMilli() ?: endInstant.toEpochMilli()
|
|
295
|
+
val endMs = minOf(nextMs, endInstant.toEpochMilli())
|
|
296
|
+
if (endMs <= startMs) {
|
|
297
|
+
return@mapIndexedNotNull null
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
buildBasalInterval(
|
|
301
|
+
startMs = startMs,
|
|
302
|
+
endMs = endMs,
|
|
303
|
+
kcalPerDay = record.basalMetabolicRate.inKilocaloriesPerDay,
|
|
304
|
+
packageName = resolvePackageName(record.metadata),
|
|
305
|
+
sourceType = resolveSourceType(record.metadata),
|
|
306
|
+
deviceModel = HealthConnectModuleInternals.resolveDeviceModel(record.metadata),
|
|
307
|
+
millisPerDay = millisPerDay
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
intervals.addAll(inRangeIntervals)
|
|
311
|
+
|
|
312
|
+
return BasalReadResultDto(
|
|
313
|
+
intervals = intervals.sortedBy { it.startMs },
|
|
314
|
+
basalSeedApplied = basalSeedApplied,
|
|
315
|
+
seededFromBeforeSince = seededFromBeforeSince,
|
|
316
|
+
basalSeedAgeHours = basalSeedAgeHours,
|
|
317
|
+
warnings = warnings
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private fun buildBasalInterval(
|
|
322
|
+
startMs: Long,
|
|
323
|
+
endMs: Long,
|
|
324
|
+
kcalPerDay: Double,
|
|
325
|
+
packageName: String,
|
|
326
|
+
sourceType: String,
|
|
327
|
+
deviceModel: String,
|
|
328
|
+
millisPerDay: Double
|
|
329
|
+
): HealthConnectModuleInternals.IntervalInput {
|
|
330
|
+
val durationMs = (endMs - startMs).toDouble()
|
|
331
|
+
val basalCaloriesForInterval = kcalPerDay * (durationMs / millisPerDay)
|
|
332
|
+
return HealthConnectModuleInternals.IntervalInput(
|
|
333
|
+
startMs = startMs,
|
|
334
|
+
endMs = endMs,
|
|
335
|
+
totalValue = basalCaloriesForInterval,
|
|
336
|
+
sourceType = sourceType,
|
|
337
|
+
packageName = packageName,
|
|
338
|
+
deviceModel = deviceModel
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private suspend fun <T : Record> readRecordsWithPagination(
|
|
343
|
+
recordType: KClass<T>,
|
|
344
|
+
startInstant: Instant,
|
|
345
|
+
endInstant: Instant
|
|
346
|
+
): List<T> {
|
|
347
|
+
return withContext(Dispatchers.IO) {
|
|
348
|
+
collectPagedRecords { pageToken ->
|
|
349
|
+
val response =
|
|
350
|
+
healthConnectClient.readRecords(
|
|
351
|
+
ReadRecordsRequest(
|
|
352
|
+
recordType = recordType,
|
|
353
|
+
timeRangeFilter = TimeRangeFilter.between(startInstant, endInstant),
|
|
354
|
+
pageToken = pageToken
|
|
355
|
+
)
|
|
356
|
+
)
|
|
357
|
+
PagedResult(
|
|
358
|
+
records = response.records,
|
|
359
|
+
nextPageToken = response.pageToken
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private suspend fun <T : Record> safeReadRecordsWithPagination(
|
|
366
|
+
label: String,
|
|
367
|
+
recordType: KClass<T>,
|
|
368
|
+
startInstant: Instant,
|
|
369
|
+
endInstant: Instant
|
|
370
|
+
): List<T> {
|
|
371
|
+
return runCatching {
|
|
372
|
+
readRecordsWithPagination(
|
|
373
|
+
recordType = recordType,
|
|
374
|
+
startInstant = startInstant,
|
|
375
|
+
endInstant = endInstant
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
.getOrElse { error ->
|
|
379
|
+
Log.w(
|
|
380
|
+
TAG,
|
|
381
|
+
"Failed to read records for $label. Returning empty list to preserve getExerciseData availability.",
|
|
382
|
+
error
|
|
383
|
+
)
|
|
384
|
+
emptyList()
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private fun resolveSourceType(metadata: Any?): String {
|
|
389
|
+
if (metadata == null) {
|
|
390
|
+
return "unknown"
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
val recordingMethod =
|
|
394
|
+
try {
|
|
395
|
+
metadata.javaClass
|
|
396
|
+
.methods
|
|
397
|
+
.firstOrNull { it.name == "getRecordingMethod" && it.parameterCount == 0 }
|
|
398
|
+
?.invoke(metadata)
|
|
399
|
+
} catch (_: Throwable) {
|
|
400
|
+
null
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return HealthConnectModuleInternals.normalizeSourceType(recordingMethod)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private fun filterRecord(metadata: Any?): Boolean {
|
|
407
|
+
return filterRecord(metadata, includeManualWithDevice = true)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private fun filterRecord(metadata: Any?, includeManualWithDevice: Boolean): Boolean {
|
|
411
|
+
val trustResult = HealthConnectModuleInternals.evaluateRecordTrust(metadata)
|
|
412
|
+
if (!trustResult.allowed) {
|
|
413
|
+
return false
|
|
414
|
+
}
|
|
415
|
+
if (!includeManualWithDevice &&
|
|
416
|
+
HealthConnectModuleInternals.normalizeSourceType(trustResult.sourceType) ==
|
|
417
|
+
"manual_input") {
|
|
418
|
+
return false
|
|
419
|
+
}
|
|
420
|
+
return HealthConnectModuleInternals.isRecordAllowed(
|
|
421
|
+
trustResult.sourceType,
|
|
422
|
+
datasourceAllowlistProvider()
|
|
423
|
+
) && isPackageAllowed(metadata)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
private fun isPackageAllowed(metadata: Any?): Boolean {
|
|
427
|
+
return HealthConnectModuleInternals.isPackageAllowed(
|
|
428
|
+
packageName = resolvePackageName(metadata),
|
|
429
|
+
packageAllowlist = datasourcePackageAllowlistProvider()
|
|
430
|
+
)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private fun resolvePackageName(metadata: Any?): String {
|
|
434
|
+
if (metadata == null) {
|
|
435
|
+
return ""
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
val dataOrigin =
|
|
439
|
+
try {
|
|
440
|
+
metadata.javaClass
|
|
441
|
+
.methods
|
|
442
|
+
.firstOrNull { it.name == "getDataOrigin" && it.parameterCount == 0 }
|
|
443
|
+
?.invoke(metadata)
|
|
444
|
+
} catch (_: Throwable) {
|
|
445
|
+
null
|
|
446
|
+
} ?: return ""
|
|
447
|
+
|
|
448
|
+
return try {
|
|
449
|
+
(dataOrigin.javaClass
|
|
450
|
+
.methods
|
|
451
|
+
.firstOrNull { it.name == "getPackageName" && it.parameterCount == 0 }
|
|
452
|
+
?.invoke(dataOrigin) as? String)
|
|
453
|
+
?.trim()
|
|
454
|
+
.orEmpty()
|
|
455
|
+
} catch (_: Throwable) {
|
|
456
|
+
""
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private companion object {
|
|
461
|
+
private const val MILLIS_PER_HOUR = 3_600_000.0
|
|
462
|
+
private const val TAG = "HCExerciseRecordAdapter"
|
|
463
|
+
}
|
|
464
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
package com.carivaexercisesdk.infrastructure.persistence
|
|
2
|
+
|
|
3
|
+
import com.carivaexercisesdk.application.datasource.port.DatasourcePolicyRepository
|
|
4
|
+
import com.carivaexercisesdk.domain.datasource.entity.DatasourcePolicy
|
|
5
|
+
import com.carivaexercisesdk.domain.datasource.valueobject.DatasourceType
|
|
6
|
+
|
|
7
|
+
class InMemoryDatasourcePolicyRepository : DatasourcePolicyRepository {
|
|
8
|
+
private var state =
|
|
9
|
+
DatasourcePolicy(
|
|
10
|
+
allowlist = DatasourceType.entries.toSet(),
|
|
11
|
+
packageAllowlist = emptySet()
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
override fun get(): DatasourcePolicy {
|
|
15
|
+
return state
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
override fun save(policy: DatasourcePolicy) {
|
|
19
|
+
state = policy
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
package com.carivaexercisesdk.infrastructure.reactnative
|
|
2
|
+
|
|
3
|
+
import com.carivaexercisesdk.application.connection.dto.ConnectResultDto
|
|
4
|
+
import com.facebook.react.bridge.Arguments
|
|
5
|
+
import com.facebook.react.bridge.WritableMap
|
|
6
|
+
|
|
7
|
+
object ConnectResultMapper {
|
|
8
|
+
fun toWritableMap(result: ConnectResultDto): WritableMap {
|
|
9
|
+
return Arguments.createMap().apply {
|
|
10
|
+
putBoolean("ready", result.ready)
|
|
11
|
+
putBoolean("healthConnectInstalled", result.healthConnectInstalled)
|
|
12
|
+
putString("healthConnectStatus", result.healthConnectStatus.name)
|
|
13
|
+
putBoolean("hasPermissions", result.hasPermissions)
|
|
14
|
+
putString("nextAction", result.nextAction.wireValue)
|
|
15
|
+
putArray(
|
|
16
|
+
"requestedPermissions",
|
|
17
|
+
Arguments.createArray().apply {
|
|
18
|
+
result.requestedPermissions.forEach { permission -> pushString(permission) }
|
|
19
|
+
}
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
package com.carivaexercisesdk.infrastructure.reactnative
|
|
2
|
+
|
|
3
|
+
import com.carivaexercisesdk.domain.datasource.valueobject.DatasourceType
|
|
4
|
+
import org.json.JSONArray
|
|
5
|
+
import org.json.JSONObject
|
|
6
|
+
|
|
7
|
+
internal object DatasourcePolicyJsonParser {
|
|
8
|
+
private const val DATASOURCE_ALLOWLIST_KEY = "allowlist"
|
|
9
|
+
private const val DATASOURCE_SCOPE_KEY = "scope"
|
|
10
|
+
private const val DATASOURCE_SCOPE_PACKAGES_KEY = "packages"
|
|
11
|
+
|
|
12
|
+
fun parseDatasourceAllowlist(config: JSONObject): List<String> {
|
|
13
|
+
val parsedAllowlist = parseStringArray(config.optJSONArray(DATASOURCE_ALLOWLIST_KEY))
|
|
14
|
+
return resolveAllowlist(
|
|
15
|
+
hasAllowlistKey = config.has(DATASOURCE_ALLOWLIST_KEY),
|
|
16
|
+
parsedAllowlist = parsedAllowlist
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
fun parseDatasourceScopePackages(config: JSONObject): List<String> {
|
|
21
|
+
val scope = config.optJSONObject(DATASOURCE_SCOPE_KEY) ?: return emptyList()
|
|
22
|
+
return resolveScopePackages(parseStringArray(scope.optJSONArray(DATASOURCE_SCOPE_PACKAGES_KEY)))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
fun parseStringArray(array: JSONArray?): List<String> {
|
|
26
|
+
if (array == null) {
|
|
27
|
+
return emptyList()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return buildList {
|
|
31
|
+
for (i in 0 until array.length()) {
|
|
32
|
+
(array[i] as? String)?.let { add(it) }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
internal fun resolveAllowlist(
|
|
38
|
+
hasAllowlistKey: Boolean,
|
|
39
|
+
parsedAllowlist: List<String>
|
|
40
|
+
): List<String> {
|
|
41
|
+
if (parsedAllowlist.isNotEmpty() || hasAllowlistKey) {
|
|
42
|
+
return parsedAllowlist
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return DatasourceType.entries.map { it.wireValue }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
internal fun resolveScopePackages(parsedScopePackages: List<String>?): List<String> {
|
|
49
|
+
return parsedScopePackages ?: emptyList()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
package com.carivaexercisesdk.infrastructure.reactnative
|
|
2
|
+
|
|
3
|
+
import com.carivaexercisesdk.domain.datasource.entity.DatasourcePolicy
|
|
4
|
+
import com.facebook.react.bridge.Arguments
|
|
5
|
+
import com.facebook.react.bridge.WritableMap
|
|
6
|
+
|
|
7
|
+
object DatasourcePolicyMapper {
|
|
8
|
+
internal data class CanonicalDatasourceConfig(
|
|
9
|
+
val allowlist: List<String>,
|
|
10
|
+
val packages: List<String>
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
internal fun toCanonicalConfig(policy: DatasourcePolicy): CanonicalDatasourceConfig {
|
|
14
|
+
return CanonicalDatasourceConfig(
|
|
15
|
+
allowlist = policy.allowlist.map { it.wireValue }.sorted(),
|
|
16
|
+
packages = policy.packageAllowlist.sorted()
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
fun toConfigMap(policy: DatasourcePolicy): WritableMap {
|
|
21
|
+
val canonical = toCanonicalConfig(policy)
|
|
22
|
+
return Arguments.createMap().apply {
|
|
23
|
+
putArray(
|
|
24
|
+
"allowlist",
|
|
25
|
+
Arguments.createArray().apply {
|
|
26
|
+
canonical.allowlist.forEach { sourceType -> pushString(sourceType) }
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
putMap(
|
|
30
|
+
"scope",
|
|
31
|
+
Arguments.createMap().apply {
|
|
32
|
+
putArray(
|
|
33
|
+
"packages",
|
|
34
|
+
Arguments.createArray().apply {
|
|
35
|
+
canonical.packages.forEach { packageName -> pushString(packageName) }
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
fun toSuccessMap(success: Boolean): WritableMap {
|
|
44
|
+
return Arguments.createMap().apply { putBoolean("success", success) }
|
|
45
|
+
}
|
|
46
|
+
}
|