• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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