1 /* <lambda>null2 * Copyright (C) 2022 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 17 package com.android.healthconnect.controller.recentaccess 18 19 import android.health.connect.accesslog.AccessLog 20 import androidx.lifecycle.LiveData 21 import androidx.lifecycle.MutableLiveData 22 import androidx.lifecycle.ViewModel 23 import androidx.lifecycle.viewModelScope 24 import com.android.healthconnect.controller.permissions.connectedapps.ILoadHealthPermissionApps 25 import com.android.healthconnect.controller.recentaccess.RecentAccessViewModel.RecentAccessState.Loading 26 import com.android.healthconnect.controller.shared.HealthDataCategoryExtensions.uppercaseTitle 27 import com.android.healthconnect.controller.shared.app.AppInfoReader 28 import com.android.healthconnect.controller.shared.app.ConnectedAppStatus 29 import com.android.healthconnect.controller.shared.dataTypeToCategory 30 import com.android.healthconnect.controller.utils.TimeSource 31 import com.android.healthconnect.controller.utils.postValueIfUpdated 32 import dagger.hilt.android.lifecycle.HiltViewModel 33 import kotlinx.coroutines.launch 34 import java.time.Duration 35 import java.time.Instant 36 import javax.inject.Inject 37 38 @HiltViewModel 39 class RecentAccessViewModel 40 @Inject 41 constructor( 42 private val appInfoReader: AppInfoReader, 43 private val loadHealthPermissionApps: ILoadHealthPermissionApps, 44 private val loadRecentAccessUseCase: ILoadRecentAccessUseCase, 45 private val timeSource: TimeSource 46 ) : ViewModel() { 47 48 companion object { 49 private val MAX_CLUSTER_DURATION = Duration.ofMinutes(10) 50 private val MAX_GAP_BETWEEN_LOGS_IN_CLUSTER_DURATION = Duration.ofMinutes(1) 51 } 52 53 private val _recentAccessApps = MutableLiveData<RecentAccessState>() 54 val recentAccessApps: LiveData<RecentAccessState> 55 get() = _recentAccessApps 56 57 fun loadRecentAccessApps(maxNumEntries: Int = -1) { 58 // Don't show loading if data was loaded before just refresh. 59 if (_recentAccessApps.value !is RecentAccessState.WithData) { 60 _recentAccessApps.postValue(Loading) 61 } 62 viewModelScope.launch { 63 try { 64 val clusters = getRecentAccessAppsClusters(maxNumEntries) 65 _recentAccessApps.postValueIfUpdated(RecentAccessState.WithData(clusters)) 66 } catch (ex: Exception) { 67 _recentAccessApps.postValueIfUpdated(RecentAccessState.Error) 68 } 69 } 70 } 71 72 private suspend fun getRecentAccessAppsClusters( 73 maxNumEntries: Int 74 ): List<RecentAccessEntry> { 75 val accessLogs = loadRecentAccessUseCase.invoke() 76 val connectedApps = loadHealthPermissionApps.invoke() 77 val inactiveApps = 78 connectedApps 79 .groupBy { it.status }[ConnectedAppStatus.INACTIVE] 80 .orEmpty() 81 .map { connectedAppMetadata -> connectedAppMetadata.appMetadata.packageName } 82 83 val clusters = clusterEntries(accessLogs, maxNumEntries) 84 val filteredClusters = mutableListOf<RecentAccessEntry>() 85 clusters.forEach { 86 if (inactiveApps.contains(it.metadata.packageName)) { 87 it.isInactive = true 88 } 89 if (inactiveApps.contains(it.metadata.packageName) || 90 appInfoReader.isAppInstalled(it.metadata.packageName)) { 91 filteredClusters.add(it) 92 } 93 } 94 return filteredClusters 95 } 96 97 private data class DataAccessEntryCluster( 98 val latestTime: Instant, 99 var earliestTime: Instant, 100 val recentDataAccessEntry: RecentAccessEntry 101 ) 102 103 private suspend fun clusterEntries( 104 accessLogs: List<AccessLog>, 105 maxNumEntries: Int 106 ): List<RecentAccessEntry> { 107 if (accessLogs.isEmpty()) { 108 return listOf() 109 } 110 111 val dataAccessEntries = mutableListOf<RecentAccessEntry>() 112 val currentDataAccessEntryClusters = hashMapOf<String, DataAccessEntryCluster>() 113 114 // Logs are sorted by time, descending 115 for (currentLog in accessLogs) { 116 val currentPackageName = currentLog.packageName 117 val currentCluster = currentDataAccessEntryClusters.get(currentPackageName) 118 119 if (currentCluster == null) { 120 // If no cluster started for this app yet, init one with the current log 121 currentDataAccessEntryClusters.put( 122 currentPackageName, initDataAccessEntryCluster(currentLog)) 123 } else if (logBelongsToCluster(currentLog, currentCluster)) { 124 updateDataAccessEntryCluster(currentCluster, currentLog) 125 } else { 126 // Log doesn't belong to current cluster. Convert current cluster to UI entry and 127 // remove 128 // it from currently accumulating clusters, start a new cluster with currentLog 129 130 dataAccessEntries.add(currentCluster.recentDataAccessEntry) 131 132 currentDataAccessEntryClusters.remove(currentPackageName) 133 134 currentDataAccessEntryClusters.put( 135 currentPackageName, initDataAccessEntryCluster(currentLog)) 136 137 // If we have enough entries already and all clusters that are still being 138 // accumulated are 139 // already earlier than the ones we completed, we can finish and return what we have 140 if (maxNumEntries != -1 && dataAccessEntries.size >= maxNumEntries) { 141 val earliestDataAccessEntryTime = dataAccessEntries.minOf { it.instantTime } 142 if (currentDataAccessEntryClusters.values.none { 143 it.earliestTime.isAfter(earliestDataAccessEntryTime) 144 }) { 145 break 146 } 147 } 148 } 149 } 150 151 // complete all remaining clusters and add them to the list of entries. If we already had 152 // enough 153 // entries and we don't need these remaining clusters (we broke the loop above early), they 154 // will be 155 // filtered out anyway by final sorting and limiting. 156 currentDataAccessEntryClusters.values.map { cluster -> 157 dataAccessEntries.add(cluster.recentDataAccessEntry) 158 } 159 160 return dataAccessEntries 161 .sortedByDescending { it.instantTime } 162 .take(if (maxNumEntries != -1) maxNumEntries else dataAccessEntries.size) 163 } 164 165 private suspend fun initDataAccessEntryCluster( 166 accessLog: AccessLog 167 ): DataAccessEntryCluster { 168 val newCluster = 169 DataAccessEntryCluster( 170 latestTime = accessLog.accessTime, 171 earliestTime = Instant.MIN, 172 recentDataAccessEntry = 173 RecentAccessEntry( 174 metadata = 175 appInfoReader.getAppMetadata(packageName = accessLog.packageName))) 176 177 updateDataAccessEntryCluster(newCluster, accessLog) 178 return newCluster 179 } 180 181 private fun logBelongsToCluster( 182 accessLog: AccessLog, 183 cluster: DataAccessEntryCluster 184 ): Boolean = 185 Duration.between(accessLog.accessTime, cluster.latestTime) 186 .compareTo(MAX_CLUSTER_DURATION) <= 0 && 187 Duration.between(accessLog.accessTime, cluster.earliestTime) 188 .compareTo(MAX_GAP_BETWEEN_LOGS_IN_CLUSTER_DURATION) <= 0 189 190 private fun updateDataAccessEntryCluster( 191 cluster: DataAccessEntryCluster, 192 accessLog: AccessLog 193 ) { 194 val midnight = 195 timeSource 196 .currentLocalDateTime() 197 .toLocalDate() 198 .atStartOfDay(timeSource.deviceZoneOffset()) 199 .toInstant() 200 201 cluster.earliestTime = accessLog.accessTime 202 cluster.recentDataAccessEntry.instantTime = accessLog.accessTime 203 cluster.recentDataAccessEntry.isToday = (!accessLog.accessTime.isBefore(midnight)) 204 205 if (accessLog.operationType == AccessLog.OperationType.OPERATION_TYPE_READ) { 206 cluster.recentDataAccessEntry.dataTypesRead.addAll( 207 accessLog.recordTypes.map { dataTypeToCategory(it).uppercaseTitle() }) 208 } else { 209 cluster.recentDataAccessEntry.dataTypesWritten.addAll( 210 accessLog.recordTypes.map { dataTypeToCategory(it).uppercaseTitle() }) 211 } 212 } 213 214 sealed class RecentAccessState { 215 object Loading : RecentAccessState() 216 object Error : RecentAccessState() 217 data class WithData(val recentAccessEntries: List<RecentAccessEntry>) : RecentAccessState() 218 } 219 } 220