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