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