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.ReadRecordsRequestUsingFilters
21 import android.health.connect.ReadRecordsResponse
22 import android.health.connect.TimeInstantRangeFilter
23 import android.health.connect.datatypes.DataOrigin
24 import android.health.connect.datatypes.InstantRecord
25 import android.health.connect.datatypes.IntervalRecord
26 import android.health.connect.datatypes.Record
27 import android.util.Log
28 import androidx.core.os.asOutcomeReceiver
29 import com.android.healthconnect.controller.R
30 import com.android.healthconnect.controller.data.entries.FormattedEntry
31 import com.android.healthconnect.controller.data.entries.datenavigation.DateNavigationPeriod
32 import com.android.healthconnect.controller.data.entries.datenavigation.toPeriod
33 import com.android.healthconnect.controller.dataentries.formatters.shared.HealthDataEntryFormatter
34 import com.android.healthconnect.controller.shared.HealthPermissionToDatatypeMapper
35 import com.android.healthconnect.controller.utils.LocalDateTimeFormatter
36 import com.android.healthconnect.controller.utils.SystemTimeSource
37 import com.android.healthconnect.controller.utils.TimeSource
38 import com.android.healthconnect.controller.utils.toLocalDate
39 import com.google.common.annotations.VisibleForTesting
40 import dagger.hilt.android.qualifiers.ApplicationContext
41 import java.time.Duration
42 import java.time.Instant
43 import java.time.Period
44 import java.time.ZoneId
45 import javax.inject.Inject
46 import javax.inject.Singleton
47 import kotlinx.coroutines.suspendCancellableCoroutine
48 
49 /**
50  * Helper methods for loading normal data entries ([LoadDataEntriesUseCase], menstruation entries
51  * ([LoadMenstruationDataUseCase]) and aggregations ([LoadDataAggregationsUseCase]).).
52  */
53 @Singleton
54 class LoadEntriesHelper
55 @Inject
56 constructor(
57     @ApplicationContext private val context: Context,
58     private val healthDataEntryFormatter: HealthDataEntryFormatter,
59     private val healthConnectManager: HealthConnectManager,
60     private val timeSource: TimeSource = SystemTimeSource
61 ) {
62     private val dateFormatter = LocalDateTimeFormatter(context)
63 
64     companion object {
65         private const val TAG = "LoadDataUseCaseHelper"
66     }
67 
68     /**
69      * Returns a list of records from a data type sorted in descending order of their start time.
70      */
71     suspend fun readDataType(
72         data: Class<out Record>,
73         timeFilterRange: TimeInstantRangeFilter,
74         packageName: String?,
75         ascending: Boolean = true,
76         pageSize: Int = 1000
77     ): List<Record> {
78         val filter =
79             buildReadRecordsRequestUsingFilters(
80                 data, timeFilterRange, packageName, ascending, pageSize)
81         val records =
82             suspendCancellableCoroutine<ReadRecordsResponse<*>> { continuation ->
83                     healthConnectManager.readRecords(
84                         filter, Runnable::run, continuation.asOutcomeReceiver())
85                 }
86                 .records
87                 .sortedByDescending { record -> getStartTime(record) }
88         return records
89     }
90 
91     /** Returns a list of records from an input sorted in descending order of their start time. */
92     suspend fun readRecords(input: LoadDataEntriesInput): List<Record> {
93         val timeFilterRange =
94             getTimeFilter(input.displayedStartTime, input.period, endTimeExclusive = true)
95         val dataTypes = HealthPermissionToDatatypeMapper.getDataTypes(input.permissionType)
96 
97         return dataTypes
98             .map { dataType -> readDataType(dataType, timeFilterRange, input.packageName) }
99             .flatten()
100     }
101 
102     /** Returns a list containing the most recent record from the specified input. */
103     suspend fun readLastRecord(input: LoadDataEntriesInput): List<Record> {
104         val timeFilterRange =
105             getTimeFilter(input.displayedStartTime, input.period, endTimeExclusive = true)
106         val dataTypes = HealthPermissionToDatatypeMapper.getDataTypes(input.permissionType)
107 
108         return dataTypes
109             .map { dataType ->
110                 readDataType(
111                     dataType, timeFilterRange, input.packageName, ascending = false, pageSize = 1)
112             }
113             .flatten()
114     }
115 
116     /**
117      * If more than one day's data is displayed, inserts a section header for each day: 'Today',
118      * 'Yesterday', then date format.
119      */
120     suspend fun maybeAddDateSectionHeaders(
121         entries: List<Record>,
122         period: DateNavigationPeriod,
123         showDataOrigin: Boolean
124     ): List<FormattedEntry> {
125         if (entries.isEmpty()) {
126             return listOf()
127         }
128         if (period == DateNavigationPeriod.PERIOD_DAY) {
129             return entries.mapNotNull { record -> getFormatterRecord(record, showDataOrigin) }
130         }
131 
132         val entriesWithSectionHeaders: MutableList<FormattedEntry> = mutableListOf()
133         var lastHeaderDate = Instant.EPOCH
134 
135         entries.forEach {
136             val possibleNextHeaderDate = getStartTime(it)
137             if (!areOnSameDay(lastHeaderDate, possibleNextHeaderDate)) {
138                 lastHeaderDate = possibleNextHeaderDate
139                 val sectionTitle = getSectionTitle(lastHeaderDate)
140                 entriesWithSectionHeaders.add(FormattedEntry.EntryDateSectionHeader(sectionTitle))
141             }
142             getFormatterRecord(it, showDataOrigin)?.let { formattedRecord ->
143                 entriesWithSectionHeaders.add(formattedRecord)
144             }
145         }
146         return entriesWithSectionHeaders.toList()
147     }
148 
149     private fun getSectionTitle(date: Instant): String {
150         val today =
151             Instant.ofEpochMilli(timeSource.currentTimeMillis())
152                 .toLocalDate()
153                 .atStartOfDay(timeSource.deviceZoneOffset())
154                 .toInstant()
155         val yesterday =
156             today
157                 .toLocalDate()
158                 .minus(Period.ofDays(1))
159                 .atStartOfDay(timeSource.deviceZoneOffset())
160                 .toInstant()
161 
162         return if (areOnSameDay(date, today)) {
163             context.getString(R.string.today_header)
164         } else if (areOnSameDay(date, yesterday)) {
165             context.getString(R.string.yesterday_header)
166         } else {
167             dateFormatter.formatLongDate(date)
168         }
169     }
170 
171     private fun areOnSameDay(instant1: Instant, instant2: Instant): Boolean {
172         val localDate1 = instant1.atZone(timeSource.deviceZoneOffset()).toLocalDate()
173         val localDate2 = instant2.atZone(timeSource.deviceZoneOffset()).toLocalDate()
174         return localDate1 == localDate2
175     }
176 
177     fun getStartTime(record: Record): Instant {
178         return when (record) {
179             is InstantRecord -> {
180                 record.time
181             }
182             is IntervalRecord -> {
183                 record.startTime
184             }
185             else -> {
186                 throw IllegalArgumentException("unsupported record type!")
187             }
188         }
189     }
190 
191     private suspend fun getFormatterRecord(
192         record: Record,
193         showDataOrigin: Boolean
194     ): FormattedEntry? {
195         return try {
196             healthDataEntryFormatter.format(record, showDataOrigin)
197         } catch (ex: Exception) {
198             Log.i(TAG, "Failed to format record!")
199             null
200         }
201     }
202 
203     fun getTimeFilter(
204         startTime: Instant,
205         period: DateNavigationPeriod,
206         endTimeExclusive: Boolean
207     ): TimeInstantRangeFilter {
208 
209         val start =
210             startTime
211                 .atZone(ZoneId.systemDefault())
212                 .toLocalDate()
213                 .atStartOfDay(ZoneId.systemDefault())
214                 .toInstant()
215         var end = start.atZone(ZoneId.systemDefault()).plus(toPeriod(period)).toInstant()
216         if (endTimeExclusive) {
217             end = end.minus(Duration.ofMillis(1))
218         }
219 
220         return TimeInstantRangeFilter.Builder().setStartTime(start).setEndTime(end).build()
221     }
222 
223     fun getTimeFilter(startTime: Instant, endTime: Instant): TimeInstantRangeFilter {
224         return TimeInstantRangeFilter.Builder().setStartTime(startTime).setEndTime(endTime).build()
225     }
226 
227     @VisibleForTesting
228     fun buildReadRecordsRequestUsingFilters(
229         data: Class<out Record>,
230         timeFilterRange: TimeInstantRangeFilter,
231         packageName: String?,
232         ascending: Boolean = true,
233         pageSize: Int = 1000
234     ): ReadRecordsRequestUsingFilters<out Record> {
235         val filter =
236             ReadRecordsRequestUsingFilters.Builder(data)
237                 .setAscending(ascending)
238                 .setPageSize(pageSize)
239                 .setTimeRangeFilter(timeFilterRange)
240         if (packageName != null) {
241             filter.addDataOrigins(DataOrigin.Builder().setPackageName(packageName).build()).build()
242         }
243         return filter.build()
244     }
245 }
246