• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2023 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.android.healthconnect.controller.data.entries.api
17 
18 import android.content.Context
19 import android.health.connect.HealthConnectManager
20 import android.health.connect.ReadMedicalResourcesInitialRequest
21 import android.health.connect.ReadMedicalResourcesResponse
22 import android.health.connect.ReadRecordsRequestUsingFilters
23 import android.health.connect.ReadRecordsResponse
24 import android.health.connect.TimeInstantRangeFilter
25 import android.health.connect.datatypes.DataOrigin
26 import android.health.connect.datatypes.InstantRecord
27 import android.health.connect.datatypes.IntervalRecord
28 import android.health.connect.datatypes.MedicalDataSource
29 import android.health.connect.datatypes.MedicalResource
30 import android.health.connect.datatypes.MenstruationFlowRecord
31 import android.health.connect.datatypes.MenstruationPeriodRecord
32 import android.health.connect.datatypes.Record
33 import android.util.Log
34 import androidx.core.os.asOutcomeReceiver
35 import com.android.healthconnect.controller.R
36 import com.android.healthconnect.controller.data.entries.FormattedEntry
37 import com.android.healthconnect.controller.data.entries.datenavigation.DateNavigationPeriod
38 import com.android.healthconnect.controller.data.entries.datenavigation.toPeriod
39 import com.android.healthconnect.controller.dataentries.formatters.MenstruationPeriodFormatter
40 import com.android.healthconnect.controller.dataentries.formatters.shared.HealthDataEntryFormatter
41 import com.android.healthconnect.controller.permissions.data.toMedicalResourceType
42 import com.android.healthconnect.controller.shared.HealthPermissionToDatatypeMapper
43 import com.android.healthconnect.controller.shared.app.MedicalDataSourceReader
44 import com.android.healthconnect.controller.utils.LocalDateTimeFormatter
45 import com.android.healthconnect.controller.utils.SystemTimeSource
46 import com.android.healthconnect.controller.utils.TimeSource
47 import com.android.healthconnect.controller.utils.toLocalDate
48 import com.google.common.annotations.VisibleForTesting
49 import dagger.hilt.android.qualifiers.ApplicationContext
50 import java.time.Duration
51 import java.time.Instant
52 import java.time.Period
53 import java.time.ZoneId
54 import javax.inject.Inject
55 import javax.inject.Singleton
56 import kotlinx.coroutines.suspendCancellableCoroutine
57 
58 /**
59  * Helper methods for loading normal data entries ([LoadDataEntriesUseCase], menstruation entries
60  * ([LoadMenstruationDataUseCase]) and aggregations ([LoadDataAggregationsUseCase]).).
61  */
62 @Singleton
63 class LoadEntriesHelper
64 @Inject
65 constructor(
66     @ApplicationContext private val context: Context,
67     private val healthDataEntryFormatter: HealthDataEntryFormatter,
68     private val menstruationPeriodFormatter: MenstruationPeriodFormatter,
69     private val healthConnectManager: HealthConnectManager,
70     private val dataSourceReader: MedicalDataSourceReader,
71     private val timeSource: TimeSource = SystemTimeSource,
72 ) {
73     private val dateFormatter = LocalDateTimeFormatter(context)
74 
75     companion object {
76         private const val TAG = "LoadDataUseCaseHelper"
77     }
78 
79     /**
80      * Returns a list of records from a data type sorted in descending order of their start time.
81      */
82     suspend fun readDataType(
83         data: Class<out Record>,
84         timeFilterRange: TimeInstantRangeFilter,
85         packageName: String?,
86         ascending: Boolean = false,
87         pageSize: Int = 1000,
88     ): List<Record> {
89         val filter =
90             buildReadRecordsRequestUsingFilters(
91                 data,
92                 timeFilterRange,
93                 packageName,
94                 ascending,
95                 pageSize,
96             )
97         val records =
98             suspendCancellableCoroutine<ReadRecordsResponse<*>> { continuation ->
99                     healthConnectManager.readRecords(
100                         filter,
101                         Runnable::run,
102                         continuation.asOutcomeReceiver(),
103                     )
104                 }
105                 .records
106                 .sortedByDescending { record -> getStartTime(record) }
107         return records
108     }
109 
110     /** Returns a list of records from an input sorted in descending order of their start time. */
111     suspend fun readRecords(input: LoadDataEntriesInput): List<Record> {
112         val timeFilterRange =
113             getTimeFilter(input.displayedStartTime, input.period, endTimeExclusive = true)
114         val dataTypes = HealthPermissionToDatatypeMapper.getDataTypes(input.permissionType)
115 
116         return dataTypes
117             .map { dataType -> readDataType(dataType, timeFilterRange, input.packageName) }
118             .flatten()
119             .sortedByDescending { record ->
120                 when (record) {
121                     is InstantRecord -> record.time
122                     is IntervalRecord -> record.startTime
123                     else -> Instant.EPOCH
124                 }
125             }
126     }
127 
128     /** Returns a list containing the most recent record from the specified input. */
129     suspend fun readLastRecord(input: LoadDataEntriesInput): List<Record> {
130         val timeFilterRange =
131             getTimeFilter(input.displayedStartTime, input.period, endTimeExclusive = true)
132         val dataTypes = HealthPermissionToDatatypeMapper.getDataTypes(input.permissionType)
133 
134         return dataTypes
135             .map { dataType ->
136                 readDataType(
137                     dataType,
138                     timeFilterRange,
139                     input.packageName,
140                     ascending = false,
141                     pageSize = 1,
142                 )
143             }
144             .flatten()
145     }
146 
147     /** Returns a list of records from a MedicalPermissionType. */
148     suspend fun readMedicalRecords(input: LoadMedicalEntriesInput): List<MedicalResource> {
149         val medicalResourceType: Int
150         try {
151             medicalResourceType = toMedicalResourceType(input.medicalPermissionType)
152         } catch (ex: IllegalArgumentException) {
153             Log.i(TAG, "Failed to convert permission type to medical resource type.")
154             return emptyList()
155         }
156         val filter =
157             input.packageName?.let {
158                 buildMedicalResourceRequest(
159                     medicalResourceType,
160                     dataSourceReader.fromPackageName(it),
161                 )
162             } ?: buildMedicalResourceRequest(medicalResourceType)
163         val medicalResources =
164             suspendCancellableCoroutine<ReadMedicalResourcesResponse> { continuation ->
165                     healthConnectManager.readMedicalResources(
166                         filter,
167                         Runnable::run,
168                         continuation.asOutcomeReceiver(),
169                     )
170                 }
171                 .medicalResources
172         // TODO(b/362672526): Sort by descending time.
173         return medicalResources
174     }
175 
176     /**
177      * If more than one day's data is displayed, inserts a section header for each day: 'Today',
178      * 'Yesterday', then date format, and group Menstruation Period and Flow entries together under
179      * the same header.
180      */
181     suspend fun maybeAddDateSectionHeadersForMenstruation(
182         startTime: Instant,
183         entries: List<Record>,
184         period: DateNavigationPeriod,
185         showDataOrigin: Boolean,
186     ): List<FormattedEntry> {
187         if (entries.isEmpty()) {
188             return listOf()
189         }
190         if (period == DateNavigationPeriod.PERIOD_DAY) {
191             return entries
192                 .map { record ->
193                     if (record is MenstruationPeriodRecord) {
194                         menstruationPeriodFormatter.format(
195                             startTime,
196                             record,
197                             period,
198                             showDataOrigin,
199                         )
200                     } else {
201                         getFormatterRecord(record, showDataOrigin)
202                     }
203                 }
204                 .filterNotNull()
205         }
206 
207         val entriesWithSectionHeaders: MutableList<FormattedEntry> = mutableListOf()
208         var lastHeaderDate = Instant.EPOCH
209 
210         entries.forEach {
211             val possibleNextHeaderDate = getStartTime(it)
212             if (!areOnSameDay(lastHeaderDate, possibleNextHeaderDate)) {
213                 lastHeaderDate = possibleNextHeaderDate
214                 val sectionTitle = getSectionTitle(lastHeaderDate)
215                 entriesWithSectionHeaders.add(FormattedEntry.EntryDateSectionHeader(sectionTitle))
216             }
217             if (it is MenstruationPeriodRecord) {
218                 menstruationPeriodFormatter.format(startTime, it, period, showDataOrigin).let {
219                     formattedRecord ->
220                     entriesWithSectionHeaders.add(formattedRecord)
221                 }
222             } else if (it is MenstruationFlowRecord) {
223                 getFormatterRecord(it, showDataOrigin)?.let { formattedRecord ->
224                     entriesWithSectionHeaders.add(formattedRecord)
225                 }
226             }
227         }
228         return entriesWithSectionHeaders.toList()
229     }
230 
231     /**
232      * If more than one day's data is displayed, inserts a section header for each day: 'Today',
233      * 'Yesterday', then date format.
234      */
235     suspend fun maybeAddDateSectionHeaders(
236         entries: List<Record>,
237         period: DateNavigationPeriod,
238         showDataOrigin: Boolean,
239     ): List<FormattedEntry> {
240         if (entries.isEmpty()) {
241             return listOf()
242         }
243         if (period == DateNavigationPeriod.PERIOD_DAY) {
244             return entries.mapNotNull { record -> getFormatterRecord(record, showDataOrigin) }
245         }
246 
247         val entriesWithSectionHeaders: MutableList<FormattedEntry> = mutableListOf()
248         var lastHeaderDate = Instant.EPOCH
249 
250         entries.forEach {
251             val possibleNextHeaderDate = getStartTime(it)
252             if (!areOnSameDay(lastHeaderDate, possibleNextHeaderDate)) {
253                 lastHeaderDate = possibleNextHeaderDate
254                 val sectionTitle = getSectionTitle(lastHeaderDate)
255                 entriesWithSectionHeaders.add(FormattedEntry.EntryDateSectionHeader(sectionTitle))
256             }
257             getFormatterRecord(it, showDataOrigin)?.let { formattedRecord ->
258                 entriesWithSectionHeaders.add(formattedRecord)
259             }
260         }
261         return entriesWithSectionHeaders.toList()
262     }
263 
264     private fun getSectionTitle(date: Instant): String {
265         val today =
266             Instant.ofEpochMilli(timeSource.currentTimeMillis())
267                 .toLocalDate()
268                 .atStartOfDay(timeSource.deviceZoneOffset())
269                 .toInstant()
270         val yesterday =
271             today
272                 .toLocalDate()
273                 .minus(Period.ofDays(1))
274                 .atStartOfDay(timeSource.deviceZoneOffset())
275                 .toInstant()
276 
277         return if (areOnSameDay(date, today)) {
278             context.getString(R.string.today_header)
279         } else if (areOnSameDay(date, yesterday)) {
280             context.getString(R.string.yesterday_header)
281         } else {
282             dateFormatter.formatLongDate(date)
283         }
284     }
285 
286     private fun areOnSameDay(instant1: Instant, instant2: Instant): Boolean {
287         val localDate1 = instant1.atZone(timeSource.deviceZoneOffset()).toLocalDate()
288         val localDate2 = instant2.atZone(timeSource.deviceZoneOffset()).toLocalDate()
289         return localDate1 == localDate2
290     }
291 
292     fun getStartTime(record: Record): Instant {
293         return when (record) {
294             is InstantRecord -> {
295                 record.time
296             }
297             is IntervalRecord -> {
298                 record.startTime
299             }
300             else -> {
301                 throw IllegalArgumentException("unsupported record type!")
302             }
303         }
304     }
305 
306     private suspend fun getFormatterRecord(
307         record: Record,
308         showDataOrigin: Boolean,
309     ): FormattedEntry? {
310         return try {
311             healthDataEntryFormatter.format(record, showDataOrigin)
312         } catch (ex: Exception) {
313             Log.i(TAG, "Failed to format record!")
314             null
315         }
316     }
317 
318     fun getTimeFilter(
319         startTime: Instant,
320         period: DateNavigationPeriod,
321         endTimeExclusive: Boolean,
322     ): TimeInstantRangeFilter {
323 
324         val start =
325             startTime
326                 .atZone(ZoneId.systemDefault())
327                 .toLocalDate()
328                 .atStartOfDay(ZoneId.systemDefault())
329                 .toInstant()
330         var end = start.atZone(ZoneId.systemDefault()).plus(toPeriod(period)).toInstant()
331         if (endTimeExclusive) {
332             end = end.minus(Duration.ofMillis(1))
333         }
334 
335         return TimeInstantRangeFilter.Builder().setStartTime(start).setEndTime(end).build()
336     }
337 
338     fun getTimeFilter(startTime: Instant, endTime: Instant): TimeInstantRangeFilter {
339         return TimeInstantRangeFilter.Builder().setStartTime(startTime).setEndTime(endTime).build()
340     }
341 
342     @VisibleForTesting
343     fun buildReadRecordsRequestUsingFilters(
344         data: Class<out Record>,
345         timeFilterRange: TimeInstantRangeFilter,
346         packageName: String?,
347         ascending: Boolean = true,
348         pageSize: Int = 1000,
349     ): ReadRecordsRequestUsingFilters<out Record> {
350         val filter =
351             ReadRecordsRequestUsingFilters.Builder(data)
352                 .setAscending(ascending)
353                 .setPageSize(pageSize)
354                 .setTimeRangeFilter(timeFilterRange)
355         if (packageName != null) {
356             filter.addDataOrigins(DataOrigin.Builder().setPackageName(packageName).build()).build()
357         }
358         return filter.build()
359     }
360 
361     @VisibleForTesting
362     fun buildMedicalResourceRequest(
363         medicalResourceType: Int,
364         dataSources: List<MedicalDataSource>,
365     ): ReadMedicalResourcesInitialRequest {
366         val filter = ReadMedicalResourcesInitialRequest.Builder(medicalResourceType)
367         dataSources.map { it.id }.forEach { filter.addDataSourceId(it) }
368         return filter.build()
369     }
370 
371     @VisibleForTesting
372     fun buildMedicalResourceRequest(medicalResourceType: Int): ReadMedicalResourcesInitialRequest {
373         val filter = ReadMedicalResourcesInitialRequest.Builder(medicalResourceType)
374         return filter.build()
375     }
376 }
377