1 /**
<lambda>null2 * Copyright (C) 2022 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
5 * in compliance with the License. You may obtain a copy of the License at
6 *
7 * ```
8 * http://www.apache.org/licenses/LICENSE-2.0
9 * ```
10 *
11 * Unless required by applicable law or agreed to in writing, software distributed under the License
12 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
13 * or implied. See the License for the specific language governing permissions and limitations under
14 * the License.
15 */
16 package com.android.healthconnect.controller.tests.utils
17
18 import android.health.connect.datatypes.BasalMetabolicRateRecord
19 import android.health.connect.datatypes.BodyTemperatureMeasurementLocation
20 import android.health.connect.datatypes.BodyTemperatureRecord
21 import android.health.connect.datatypes.BodyWaterMassRecord
22 import android.health.connect.datatypes.DataOrigin
23 import android.health.connect.datatypes.Device
24 import android.health.connect.datatypes.DistanceRecord
25 import android.health.connect.datatypes.ExerciseCompletionGoal
26 import android.health.connect.datatypes.ExercisePerformanceGoal
27 import android.health.connect.datatypes.ExerciseSegmentType
28 import android.health.connect.datatypes.ExerciseSessionType
29 import android.health.connect.datatypes.HeartRateRecord
30 import android.health.connect.datatypes.HydrationRecord
31 import android.health.connect.datatypes.IntermenstrualBleedingRecord
32 import android.health.connect.datatypes.Metadata
33 import android.health.connect.datatypes.OxygenSaturationRecord
34 import android.health.connect.datatypes.PlannedExerciseBlock
35 import android.health.connect.datatypes.PlannedExerciseSessionRecord
36 import android.health.connect.datatypes.PlannedExerciseStep
37 import android.health.connect.datatypes.Record
38 import android.health.connect.datatypes.SleepSessionRecord
39 import android.health.connect.datatypes.StepsRecord
40 import android.health.connect.datatypes.TotalCaloriesBurnedRecord
41 import android.health.connect.datatypes.WeightRecord
42 import android.health.connect.datatypes.units.Energy
43 import android.health.connect.datatypes.units.Length
44 import android.health.connect.datatypes.units.Mass
45 import android.health.connect.datatypes.units.Percentage
46 import android.health.connect.datatypes.units.Power
47 import android.health.connect.datatypes.units.Temperature
48 import android.health.connect.datatypes.units.Velocity
49 import android.health.connect.datatypes.units.Volume
50 import androidx.test.platform.app.InstrumentationRegistry
51 import androidx.test.uiautomator.UiDevice
52 import com.android.healthconnect.controller.dataentries.units.PowerConverter
53 import com.android.healthconnect.controller.permissions.data.HealthPermission
54 import com.android.healthconnect.controller.permissions.data.HealthPermissionType
55 import com.android.healthconnect.controller.shared.app.AppMetadata
56 import com.android.healthconnect.controller.utils.TimeSource
57 import com.android.healthconnect.controller.utils.randomInstant
58 import com.android.healthconnect.controller.utils.toInstant
59 import com.android.healthconnect.controller.utils.toLocalDateTime
60 import com.google.common.truth.Truth.assertThat
61 import java.time.Instant
62 import java.time.LocalDate
63 import kotlin.random.Random
64 import org.mockito.Mockito
65 import java.time.ZoneOffset
66
67 val NOW: Instant = Instant.parse("2022-10-20T07:06:05.432Z")
68 val MIDNIGHT: Instant = Instant.parse("2022-10-20T00:00:00.000Z")
69
70 fun getHeartRateRecord(heartRateValues: List<Long>, startTime: Instant = NOW): HeartRateRecord {
71 return HeartRateRecord.Builder(
72 getMetaData(),
73 startTime,
74 startTime.plusSeconds(2),
75 heartRateValues.map { HeartRateRecord.HeartRateSample(it, NOW) })
76 .build()
77 }
78
getStepsRecordnull79 fun getStepsRecord(steps: Long, time: Instant = NOW): StepsRecord {
80 return StepsRecord.Builder(getMetaData(), time, time.plusSeconds(2), steps).build()
81 }
82
getBasalMetabolicRateRecordnull83 fun getBasalMetabolicRateRecord(calories: Long): BasalMetabolicRateRecord {
84 val watts = PowerConverter.convertWattsFromCalories(calories)
85 return BasalMetabolicRateRecord.Builder(getMetaData(), NOW, Power.fromWatts(watts)).build()
86 }
87
getDistanceRecordnull88 fun getDistanceRecord(distance: Length, time: Instant = NOW): DistanceRecord {
89 return DistanceRecord.Builder(getMetaData(), time, time.plusSeconds(2), distance).build()
90 }
91
getTotalCaloriesBurnedRecordnull92 fun getTotalCaloriesBurnedRecord(calories: Energy, time: Instant = NOW): TotalCaloriesBurnedRecord {
93 return TotalCaloriesBurnedRecord.Builder(getMetaData(), time, time.plusSeconds(2), calories)
94 .build()
95 }
96
getSleepSessionRecordnull97 fun getSleepSessionRecord(startTime: Instant = NOW): SleepSessionRecord {
98 val endTime = startTime.toLocalDateTime().plusHours(8).toInstant()
99 return SleepSessionRecord.Builder(getMetaData(), startTime, endTime).build()
100 }
101
getSleepSessionRecordnull102 fun getSleepSessionRecord(startTime: Instant, endTime: Instant): SleepSessionRecord {
103 return SleepSessionRecord.Builder(getMetaData(), startTime, endTime).build()
104 }
105
getWeightRecordnull106 fun getWeightRecord(time: Instant = NOW, weight: Mass): WeightRecord {
107 return WeightRecord.Builder(getMetaData(), time, weight).build()
108 }
109
getIntermenstrualBleedingRecordnull110 fun getIntermenstrualBleedingRecord(time: Instant): IntermenstrualBleedingRecord {
111 return IntermenstrualBleedingRecord.Builder(getMetaData(), time).build()
112 }
113
getBodyTemperatureRecordnull114 fun getBodyTemperatureRecord(
115 time: Instant,
116 location: Int,
117 temperature: Temperature
118 ): BodyTemperatureRecord {
119 return BodyTemperatureRecord.Builder(getMetaData(), time, location, temperature).build()
120 }
121
getOxygenSaturationRecordnull122 fun getOxygenSaturationRecord(time: Instant, percentage: Percentage): OxygenSaturationRecord {
123 return OxygenSaturationRecord.Builder(getMetaData(), time, percentage).build()
124 }
125
getHydrationRecordnull126 fun getHydrationRecord(startTime: Instant, endTime: Instant, volume: Volume): HydrationRecord {
127 return HydrationRecord.Builder(getMetaData(), startTime, endTime, volume).build()
128 }
129
getBodyWaterMassRecordnull130 fun getBodyWaterMassRecord(time: Instant, bodyWaterMass: Mass): BodyWaterMassRecord {
131 return BodyWaterMassRecord.Builder(getMetaData(), time, bodyWaterMass).build()
132 }
133
getRandomRecordnull134 fun getRandomRecord(healthPermissionType: HealthPermissionType, date: LocalDate): Record {
135 return when (healthPermissionType) {
136 HealthPermissionType.STEPS -> getStepsRecord(Random.nextLong(0, 5000), date.randomInstant())
137 HealthPermissionType.DISTANCE ->
138 getDistanceRecord(
139 Length.fromMeters(Random.nextDouble(0.0, 5000.0)), date.randomInstant())
140 HealthPermissionType.TOTAL_CALORIES_BURNED ->
141 getTotalCaloriesBurnedRecord(
142 Energy.fromCalories(Random.nextDouble(1500.0, 5000.0)), date.randomInstant())
143 HealthPermissionType.SLEEP -> getSleepSessionRecord(date.randomInstant())
144 else ->
145 throw IllegalArgumentException(
146 "HealthPermissionType $healthPermissionType not supported")
147 }
148 }
149
getSamplePlannedExerciseSessionRecordnull150 fun getSamplePlannedExerciseSessionRecord(): PlannedExerciseSessionRecord {
151 val exerciseBlock1 =
152 getPlannedExerciseBlock(
153 repetitions = 1,
154 description = "Warm up",
155 exerciseSteps =
156 listOf(
157 getPlannedExerciseStep(
158 exerciseSegmentType = ExerciseSegmentType.EXERCISE_SEGMENT_TYPE_RUNNING,
159 completionGoal =
160 ExerciseCompletionGoal.DistanceGoal(Length.fromMeters(1000.0)),
161 performanceGoals =
162 listOf(
163 ExercisePerformanceGoal.HeartRateGoal(100, 150),
164 ExercisePerformanceGoal.SpeedGoal(
165 Velocity.fromMetersPerSecond(25.0),
166 Velocity.fromMetersPerSecond(15.0))))))
167 val exerciseBlock2 =
168 getPlannedExerciseBlock(
169 repetitions = 1,
170 description = "Main set",
171 exerciseSteps =
172 listOf(
173 getPlannedExerciseStep(
174 exerciseSegmentType = ExerciseSegmentType.EXERCISE_SEGMENT_TYPE_RUNNING,
175 completionGoal =
176 ExerciseCompletionGoal.DistanceGoal(Length.fromMeters(4000.0)),
177 performanceGoals =
178 listOf(
179 ExercisePerformanceGoal.HeartRateGoal(150, 180),
180 ExercisePerformanceGoal.SpeedGoal(
181 Velocity.fromMetersPerSecond(50.0),
182 Velocity.fromMetersPerSecond(25.0))))))
183 val exerciseBlocks = listOf(exerciseBlock1, exerciseBlock2)
184
185 return getPlannedExerciseSessionRecord(
186 title = "Morning Run",
187 note = "Morning quick run by the park",
188 exerciseBlocks = exerciseBlocks)
189 }
190
getPlannedExerciseSessionRecordnull191 fun getPlannedExerciseSessionRecord(
192 title: String,
193 note: String,
194 exerciseBlocks: List<PlannedExerciseBlock>
195 ): PlannedExerciseSessionRecord {
196 return basePlannedExerciseSession(ExerciseSessionType.EXERCISE_SESSION_TYPE_RUNNING)
197 .setTitle(title)
198 .setNotes(note)
199 .setBlocks(exerciseBlocks)
200 .build()
201 }
202
basePlannedExerciseSessionnull203 private fun basePlannedExerciseSession(exerciseType: Int): PlannedExerciseSessionRecord.Builder {
204 val builder: PlannedExerciseSessionRecord.Builder =
205 PlannedExerciseSessionRecord.Builder(
206 getMetaData(), exerciseType, NOW, NOW.plusSeconds(3600))
207 builder.setNotes("Sample training plan notes")
208 builder.setTitle("Training plan title")
209 builder.setStartZoneOffset(ZoneOffset.UTC)
210 builder.setEndZoneOffset(ZoneOffset.UTC)
211 return builder
212 }
213
getPlannedExerciseBlocknull214 fun getPlannedExerciseBlock(
215 repetitions: Int,
216 description: String,
217 exerciseSteps: List<PlannedExerciseStep>
218 ): PlannedExerciseBlock {
219 return PlannedExerciseBlock.Builder(repetitions)
220 .setDescription(description)
221 .setSteps(exerciseSteps)
222 .build()
223 }
224
getPlannedExerciseStepnull225 fun getPlannedExerciseStep(
226 exerciseSegmentType: Int,
227 completionGoal: ExerciseCompletionGoal,
228 performanceGoals: List<ExercisePerformanceGoal>
229 ): PlannedExerciseStep {
230 return PlannedExerciseStep.Builder(
231 exerciseSegmentType, PlannedExerciseStep.EXERCISE_CATEGORY_ACTIVE, completionGoal)
232 .setPerformanceGoals(performanceGoals)
233 .build()
234 }
235
getMetaDatanull236 fun getMetaData(): Metadata {
237 return getMetaData(TEST_APP_PACKAGE_NAME)
238 }
239
getMetaDatanull240 fun getMetaData(packageName: String): Metadata {
241 val device: Device =
242 Device.Builder().setManufacturer("google").setModel("Pixel4a").setType(2).build()
243 val dataOrigin = DataOrigin.Builder().setPackageName(packageName).build()
244 return Metadata.Builder()
245 .setId("test_id")
246 .setDevice(device)
247 .setDataOrigin(dataOrigin)
248 .setClientRecordId("BMR" + Math.random().toString())
249 .build()
250 }
251
getDataOriginnull252 fun getDataOrigin(packageName: String): DataOrigin =
253 DataOrigin.Builder().setPackageName(packageName).build()
254
255 fun getSleepSessionRecords(inputDates: List<Pair<Instant, Instant>>): List<SleepSessionRecord> {
256 val result = arrayListOf<SleepSessionRecord>()
257 inputDates.forEach { (startTime, endTime) ->
258 result.add(SleepSessionRecord.Builder(getMetaData(), startTime, endTime).build())
259 }
260
261 return result
262 }
263
verifySleepSessionListsEqualnull264 fun verifySleepSessionListsEqual(actual: List<Record>, expected: List<SleepSessionRecord>) {
265 assertThat(actual.size).isEqualTo(expected.size)
266 for ((index, element) in actual.withIndex()) {
267 assertThat(element is SleepSessionRecord).isTrue()
268 val expectedElement = expected[index]
269 val actualElement = element as SleepSessionRecord
270
271 assertThat(actualElement.startTime).isEqualTo(expectedElement.startTime)
272 assertThat(actualElement.endTime).isEqualTo(expectedElement.endTime)
273 assertThat(actualElement.notes).isEqualTo(expectedElement.notes)
274 assertThat(actualElement.title).isEqualTo(expectedElement.title)
275 assertThat(actualElement.stages).isEqualTo(expectedElement.stages)
276 }
277 }
278
verifyOxygenSaturationListsEqualnull279 fun verifyOxygenSaturationListsEqual(actual: List<Record>, expected: List<OxygenSaturationRecord>) {
280 assertThat(actual.size).isEqualTo(expected.size)
281 for ((index, element) in actual.withIndex()) {
282 assertThat(element is OxygenSaturationRecord).isTrue()
283 val expectedElement = expected[index]
284 val actualElement = element as OxygenSaturationRecord
285
286 assertThat(actualElement.time).isEqualTo(expectedElement.time)
287 assertThat(actualElement.percentage).isEqualTo(expectedElement.percentage)
288 }
289 }
290
verifyHydrationListsEqualnull291 fun verifyHydrationListsEqual(actual: List<Record>, expected: List<HydrationRecord>) {
292 assertThat(actual.size).isEqualTo(expected.size)
293 for ((index, element) in actual.withIndex()) {
294 assertThat(element is HydrationRecord).isTrue()
295 val expectedElement = expected[index]
296 val actualElement = element as HydrationRecord
297
298 assertThat(actualElement.startTime).isEqualTo(expectedElement.startTime)
299 assertThat(actualElement.endTime).isEqualTo(expectedElement.endTime)
300 assertThat(actualElement.volume).isEqualTo(expectedElement.volume)
301 }
302 }
303
verifyBodyWaterMassListsEqualnull304 fun verifyBodyWaterMassListsEqual(actual: List<Record>, expected: List<Record>) {
305 assertThat(actual.size).isEqualTo(expected.size)
306 for ((index, element) in actual.withIndex()) {
307 assertThat(element is BodyWaterMassRecord).isTrue()
308 val expectedElement = expected[index] as BodyWaterMassRecord
309 val actualElement = element as BodyWaterMassRecord
310
311 assertThat(actualElement.time).isEqualTo(expectedElement.time)
312 assertThat(actualElement.bodyWaterMass).isEqualTo(expectedElement.bodyWaterMass)
313 }
314 }
315
316 // test data constants - start
317
318 val START_TIME = Instant.parse("2023-06-12T22:30:00Z")
319
320 // pre-defined Instants within a day, week, and month of the START_TIME Instant
321 val INSTANT_DAY: Instant = Instant.parse("2023-06-11T23:30:00Z")
322 val INSTANT_DAY2: Instant = Instant.parse("2023-06-12T02:00:00Z")
323 val INSTANT_WEEK: Instant = Instant.parse("2023-06-14T11:15:00Z")
324 val INSTANT_MONTH1: Instant = Instant.parse("2023-06-26T23:10:00Z")
325 val INSTANT_MONTH2: Instant = Instant.parse("2023-06-30T11:30:00Z")
326 val INSTANT_MONTH3: Instant = Instant.parse("2023-07-01T07:45:00Z")
327 val INSTANT_MONTH4: Instant = Instant.parse("2023-07-01T19:15:00Z")
328 val INSTANT_MONTH5: Instant = Instant.parse("2023-07-05T03:45:00Z")
329 val INSTANT_MONTH6: Instant = Instant.parse("2023-07-07T07:05:00Z")
330
331 val SLEEP_DAY_0H20 =
332 getSleepSessionRecord(
333 Instant.parse("2023-06-12T21:00:00Z"), Instant.parse("2023-06-12T21:20:00Z"))
334 val SLEEP_DAY_1H45 =
335 getSleepSessionRecord(
336 Instant.parse("2023-06-12T16:00:00Z"), Instant.parse("2023-06-12T17:45:00Z"))
337 val SLEEP_DAY_9H15 =
338 getSleepSessionRecord(
339 Instant.parse("2023-06-12T22:30:00Z"), Instant.parse("2023-06-13T07:45:00Z"))
340 val SLEEP_WEEK_9H15 =
341 getSleepSessionRecord(
342 Instant.parse("2023-06-14T22:30:00Z"), Instant.parse("2023-06-15T07:45:00Z"))
343 val SLEEP_WEEK_33H15 =
344 getSleepSessionRecord(
345 Instant.parse("2023-06-11T22:30:00Z"), Instant.parse("2023-06-13T07:45:00Z"))
346 val SLEEP_MONTH_81H15 =
347 getSleepSessionRecord(
348 Instant.parse("2023-07-09T22:30:00Z"), Instant.parse("2023-07-13T07:45:00Z"))
349
350 val HYDRATION_MONTH: HydrationRecord =
351 getHydrationRecord(INSTANT_MONTH1, INSTANT_MONTH2, Volume.fromLiters(2.0))
352 val HYDRATION_MONTH2: HydrationRecord =
353 getHydrationRecord(INSTANT_MONTH3, INSTANT_MONTH4, Volume.fromLiters(0.3))
354 val HYDRATION_MONTH3: HydrationRecord =
355 getHydrationRecord(INSTANT_MONTH5, INSTANT_MONTH6, Volume.fromLiters(1.5))
356
357 val OXYGENSATURATION_DAY: OxygenSaturationRecord =
358 getOxygenSaturationRecord(INSTANT_DAY, Percentage.fromValue(98.0))
359 val OXYGENSATURATION_DAY2: OxygenSaturationRecord =
360 getOxygenSaturationRecord(INSTANT_DAY2, Percentage.fromValue(95.0))
361
362 val DISTANCE_STARTDATE_1500: DistanceRecord =
363 getDistanceRecord(Length.fromMeters(1500.0), START_TIME)
364
365 val WEIGHT_DAY_100: WeightRecord = getWeightRecord(INSTANT_DAY, Mass.fromGrams(100000.0))
366 val WEIGHT_WEEK_100: WeightRecord = getWeightRecord(INSTANT_WEEK, Mass.fromGrams(100000.0))
367 val WEIGHT_MONTH_100: WeightRecord = getWeightRecord(INSTANT_MONTH3, Mass.fromGrams(100000.0))
368 val WEIGHT_STARTDATE_100: WeightRecord = getWeightRecord(START_TIME, Mass.fromGrams(100000.0))
369
370 val INTERMENSTRUAL_BLEEDING_DAY: IntermenstrualBleedingRecord =
371 getIntermenstrualBleedingRecord(INSTANT_DAY)
372
373 val BODYTEMPERATURE_MONTH: BodyTemperatureRecord =
374 getBodyTemperatureRecord(
375 INSTANT_MONTH3,
376 BodyTemperatureMeasurementLocation.MEASUREMENT_LOCATION_MOUTH,
377 Temperature.fromCelsius(100.0))
378
379 val BODYWATERMASS_WEEK: BodyWaterMassRecord =
380 getBodyWaterMassRecord(INSTANT_WEEK, Mass.fromGrams(1000.0))
381
382 // records using today's date, yesterday's date, and the date two days ago - for header testing
getMixedRecordsAcrossTwoDaysnull383 fun getMixedRecordsAcrossTwoDays(timeSource: TimeSource): List<Record> {
384 val instantToday: Instant = timeSource.currentLocalDateTime().toInstant()
385 val instantYesterday: Instant = timeSource.currentLocalDateTime().minusDays(1).toInstant()
386 return listOf(
387 getHydrationRecord(instantToday, instantToday.plusSeconds(900), Volume.fromLiters(2.0)),
388 getSleepSessionRecord(instantToday, instantToday.plusSeconds(1800)),
389 getDistanceRecord(Length.fromMeters(2500.0), instantYesterday),
390 getOxygenSaturationRecord(instantYesterday, Percentage.fromValue(99.0)))
391 }
392
getMixedRecordsAcrossThreeDaysnull393 fun getMixedRecordsAcrossThreeDays(timeSource: TimeSource): List<Record> {
394 val instantTwoDaysAgo: Instant = timeSource.currentLocalDateTime().minusDays(2).toInstant()
395 return getMixedRecordsAcrossTwoDays(timeSource)
396 .plus(
397 listOf(
398 getWeightRecord(instantTwoDaysAgo, Mass.fromGrams(95000.0)),
399 getDistanceRecord(Length.fromMeters(2000.0), instantTwoDaysAgo)))
400 }
401
402 // test data constants - end
403
404 // Enables or disables animations in a test
toggleAnimationnull405 fun toggleAnimation(isEnabled: Boolean) {
406 with(UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())) {
407 executeShellCommand(
408 "settings put global transition_animation_scale ${if (isEnabled) 1 else 0}")
409 executeShellCommand("settings put global window_animation_scale ${if (isEnabled) 1 else 0}")
410 executeShellCommand(
411 "settings put global animator_duration_scale ${if (isEnabled) 1 else 0}")
412 }
413 }
414
415 // Used for matching arguments for [RequestPermissionViewModel]
anynull416 fun <T> any(type: Class<T>): T = Mockito.any<T>(type)
417
418 /** Utility function to turn an array of permission strings to a list of [HealthPermission]s */
419 fun Array<String>.toPermissionsList(): List<HealthPermission> {
420 return this.map { HealthPermission.fromPermissionString(it) }.toList()
421 }
422
423 // region apps
424
425 const val TEST_APP_PACKAGE_NAME = "android.healthconnect.controller.test.app"
426 const val TEST_APP_PACKAGE_NAME_2 = "android.healthconnect.controller.test.app2"
427 const val TEST_APP_PACKAGE_NAME_3 = "package.name.3"
428 const val UNSUPPORTED_TEST_APP_PACKAGE_NAME = "android.healthconnect.controller.test.app3"
429 const val OLD_PERMISSIONS_TEST_APP_PACKAGE_NAME = "android.healthconnect.controller.test.app4"
430 const val TEST_APP_NAME = "Health Connect test app"
431 const val TEST_APP_NAME_2 = "Health Connect test app 2"
432 const val TEST_APP_NAME_3 = "Health Connect test app 3"
433 const val OLD_APP_NAME = "Old permissions test app"
434
435 val TEST_APP =
436 AppMetadata(packageName = TEST_APP_PACKAGE_NAME, appName = TEST_APP_NAME, icon = null)
437 val TEST_APP_2 =
438 AppMetadata(packageName = TEST_APP_PACKAGE_NAME_2, appName = TEST_APP_NAME_2, icon = null)
439 val TEST_APP_3 =
440 AppMetadata(packageName = TEST_APP_PACKAGE_NAME_3, appName = TEST_APP_NAME_3, icon = null)
441 val OLD_TEST_APP =
442 AppMetadata(
443 packageName = OLD_PERMISSIONS_TEST_APP_PACKAGE_NAME, appName = OLD_APP_NAME, icon = null)
444 // endregion
445