blob: 9ea4add82826986b9c6afe83cf931cce3e5550a6 [file] [log] [blame]
/*
* Copyright (C) 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.healthconnect.controller.recentaccess
import android.health.connect.accesslog.AccessLog
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.healthconnect.controller.permissions.connectedapps.ILoadHealthPermissionApps
import com.android.healthconnect.controller.recentaccess.RecentAccessViewModel.RecentAccessState.Loading
import com.android.healthconnect.controller.shared.HealthDataCategoryExtensions.uppercaseTitle
import com.android.healthconnect.controller.shared.app.AppInfoReader
import com.android.healthconnect.controller.shared.app.ConnectedAppStatus
import com.android.healthconnect.controller.shared.dataTypeToCategory
import com.android.healthconnect.controller.utils.SystemTimeSource
import com.android.healthconnect.controller.utils.TimeSource
import com.android.healthconnect.controller.utils.postValueIfUpdated
import dagger.hilt.android.lifecycle.HiltViewModel
import java.time.Duration
import java.time.Instant
import javax.inject.Inject
import kotlinx.coroutines.launch
@HiltViewModel
class RecentAccessViewModel
@Inject
constructor(
private val appInfoReader: AppInfoReader,
private val loadHealthPermissionApps: ILoadHealthPermissionApps,
private val loadRecentAccessUseCase: ILoadRecentAccessUseCase,
) : ViewModel() {
companion object {
private val MAX_CLUSTER_DURATION = Duration.ofMinutes(10)
private val MAX_GAP_BETWEEN_LOGS_IN_CLUSTER_DURATION = Duration.ofMinutes(1)
}
private val _recentAccessApps = MutableLiveData<RecentAccessState>()
val recentAccessApps: LiveData<RecentAccessState>
get() = _recentAccessApps
fun loadRecentAccessApps(maxNumEntries: Int = -1, timeSource: TimeSource = SystemTimeSource) {
// Don't show loading if data was loaded before just refresh.
if (_recentAccessApps.value !is RecentAccessState.WithData) {
_recentAccessApps.postValue(Loading)
}
viewModelScope.launch {
try {
val clusters = getRecentAccessAppsClusters(maxNumEntries, timeSource)
_recentAccessApps.postValueIfUpdated(RecentAccessState.WithData(clusters))
} catch (ex: Exception) {
_recentAccessApps.postValueIfUpdated(RecentAccessState.Error)
}
}
}
private suspend fun getRecentAccessAppsClusters(
maxNumEntries: Int,
timeSource: TimeSource
): List<RecentAccessEntry> {
val accessLogs = loadRecentAccessUseCase.invoke()
val connectedApps = loadHealthPermissionApps.invoke()
val inactiveApps =
connectedApps
.groupBy { it.status }[ConnectedAppStatus.INACTIVE]
.orEmpty()
.map { connectedAppMetadata -> connectedAppMetadata.appMetadata.packageName }
val clusters = clusterEntries(accessLogs, maxNumEntries, timeSource)
val filteredClusters = mutableListOf<RecentAccessEntry>()
clusters.forEach {
if (inactiveApps.contains(it.metadata.packageName)) {
it.isInactive = true
}
if (inactiveApps.contains(it.metadata.packageName) ||
appInfoReader.isAppInstalled(it.metadata.packageName)) {
filteredClusters.add(it)
}
}
return filteredClusters
}
private data class DataAccessEntryCluster(
val latestTime: Instant,
var earliestTime: Instant,
val recentDataAccessEntry: RecentAccessEntry
)
private suspend fun clusterEntries(
accessLogs: List<AccessLog>,
maxNumEntries: Int,
timeSource: TimeSource = SystemTimeSource
): List<RecentAccessEntry> {
if (accessLogs.isEmpty()) {
return listOf()
}
val dataAccessEntries = mutableListOf<RecentAccessEntry>()
val currentDataAccessEntryClusters = hashMapOf<String, DataAccessEntryCluster>()
// Logs are sorted by time, descending
for (currentLog in accessLogs) {
val currentPackageName = currentLog.packageName
val currentCluster = currentDataAccessEntryClusters.get(currentPackageName)
if (currentCluster == null) {
// If no cluster started for this app yet, init one with the current log
currentDataAccessEntryClusters.put(
currentPackageName, initDataAccessEntryCluster(currentLog, timeSource))
} else if (logBelongsToCluster(currentLog, currentCluster)) {
updateDataAccessEntryCluster(currentCluster, currentLog, timeSource)
} else {
// Log doesn't belong to current cluster. Convert current cluster to UI entry and
// remove
// it from currently accumulating clusters, start a new cluster with currentLog
dataAccessEntries.add(currentCluster.recentDataAccessEntry)
currentDataAccessEntryClusters.remove(currentPackageName)
currentDataAccessEntryClusters.put(
currentPackageName, initDataAccessEntryCluster(currentLog, timeSource))
// If we have enough entries already and all clusters that are still being
// accumulated are
// already earlier than the ones we completed, we can finish and return what we have
if (maxNumEntries != -1 && dataAccessEntries.size >= maxNumEntries) {
val earliestDataAccessEntryTime = dataAccessEntries.minOf { it.instantTime }
if (currentDataAccessEntryClusters.values.none {
it.earliestTime.isAfter(earliestDataAccessEntryTime)
}) {
break
}
}
}
}
// complete all remaining clusters and add them to the list of entries. If we already had
// enough
// entries and we don't need these remaining clusters (we broke the loop above early), they
// will be
// filtered out anyway by final sorting and limiting.
currentDataAccessEntryClusters.values.map { cluster ->
dataAccessEntries.add(cluster.recentDataAccessEntry)
}
return dataAccessEntries
.sortedByDescending { it.instantTime }
.take(if (maxNumEntries != -1) maxNumEntries else dataAccessEntries.size)
}
private suspend fun initDataAccessEntryCluster(
accessLog: AccessLog,
timeSource: TimeSource = SystemTimeSource
): DataAccessEntryCluster {
val newCluster =
DataAccessEntryCluster(
latestTime = accessLog.accessTime,
earliestTime = Instant.MIN,
recentDataAccessEntry =
RecentAccessEntry(
metadata =
appInfoReader.getAppMetadata(packageName = accessLog.packageName)))
updateDataAccessEntryCluster(newCluster, accessLog, timeSource)
return newCluster
}
private fun logBelongsToCluster(
accessLog: AccessLog,
cluster: DataAccessEntryCluster
): Boolean =
Duration.between(accessLog.accessTime, cluster.latestTime)
.compareTo(MAX_CLUSTER_DURATION) <= 0 &&
Duration.between(accessLog.accessTime, cluster.earliestTime)
.compareTo(MAX_GAP_BETWEEN_LOGS_IN_CLUSTER_DURATION) <= 0
private fun updateDataAccessEntryCluster(
cluster: DataAccessEntryCluster,
accessLog: AccessLog,
timeSource: TimeSource = SystemTimeSource
) {
val midnight =
timeSource
.currentLocalDateTime()
.toLocalDate()
.atStartOfDay(timeSource.deviceZoneOffset())
.toInstant()
cluster.earliestTime = accessLog.accessTime
cluster.recentDataAccessEntry.instantTime = accessLog.accessTime
cluster.recentDataAccessEntry.isToday = (!accessLog.accessTime.isBefore(midnight))
if (accessLog.operationType == AccessLog.OperationType.OPERATION_TYPE_READ) {
cluster.recentDataAccessEntry.dataTypesRead.addAll(
accessLog.recordTypes.map { dataTypeToCategory(it).uppercaseTitle() })
} else {
cluster.recentDataAccessEntry.dataTypesWritten.addAll(
accessLog.recordTypes.map { dataTypeToCategory(it).uppercaseTitle() })
}
}
sealed class RecentAccessState {
object Loading : RecentAccessState()
object Error : RecentAccessState()
data class WithData(val recentAccessEntries: List<RecentAccessEntry>) : RecentAccessState()
}
}