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