@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,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
+ }