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