blob: 4ddcf1c3de2609015ffb65e5a3ed9556be7250b0 [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.permissioncontroller.safetycenter.ui.model
import android.app.Application
import android.content.Context
import android.content.Intent
import android.content.Intent.ACTION_SAFETY_CENTER
import android.os.Build
import android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE
import android.safetycenter.SafetyCenterData
import android.safetycenter.SafetyCenterErrorDetails
import android.safetycenter.SafetyCenterIssue
import android.safetycenter.SafetyCenterManager
import android.safetycenter.SafetyCenterStatus
import android.util.Log
import androidx.annotation.MainThread
import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat.getMainExecutor
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.map
import com.android.modules.utils.build.SdkLevel
import com.android.permissioncontroller.safetycenter.ui.InteractionLogger
import com.android.permissioncontroller.safetycenter.ui.NavigationSource
import com.android.safetycenter.internaldata.SafetyCenterIds
/* A SafetyCenterViewModel that talks to the real backing service for Safety Center. */
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
class LiveSafetyCenterViewModel(app: Application) : SafetyCenterViewModel(app) {
private val TAG: String = LiveSafetyCenterViewModel::class.java.simpleName
override val statusUiLiveData: LiveData<StatusUiData>
get() = safetyCenterUiLiveData.map { StatusUiData(it.safetyCenterData) }
override val safetyCenterUiLiveData: LiveData<SafetyCenterUiData> by this::_safetyCenterLiveData
override val errorLiveData: LiveData<SafetyCenterErrorDetails> by this::_errorLiveData
private val _safetyCenterLiveData = SafetyCenterLiveData()
private val _errorLiveData = MutableLiveData<SafetyCenterErrorDetails>()
override val interactionLogger: InteractionLogger by lazy {
// Fetching the config to build this set of source IDs requires IPC, so we do this
// initialization lazily.
InteractionLogger(safetyCenterManager.safetyCenterConfig)
}
private var changingConfigurations = false
private val safetyCenterManager = app.getSystemService(SafetyCenterManager::class.java)!!
override fun getCurrentSafetyCenterDataAsUiData(): SafetyCenterUiData =
SafetyCenterUiData(safetyCenterManager.safetyCenterData)
override fun dismissIssue(issue: SafetyCenterIssue) {
safetyCenterManager.dismissSafetyCenterIssue(issue.id)
}
override fun executeIssueAction(
issue: SafetyCenterIssue,
action: SafetyCenterIssue.Action,
launchTaskId: Int?
) {
val issueId =
if (launchTaskId != null) {
SafetyCenterIds.encodeToString(
SafetyCenterIds.issueIdFromString(issue.id)
.toBuilder()
.setTaskId(launchTaskId)
.build()
)
} else {
issue.id
}
safetyCenterManager.executeSafetyCenterIssueAction(issueId, action.id)
}
override fun markIssueResolvedUiCompleted(issueId: IssueId) {
_safetyCenterLiveData.markIssueResolvedUiCompleted(issueId)
}
override fun rescan() {
safetyCenterManager.refreshSafetySources(
SafetyCenterManager.REFRESH_REASON_RESCAN_BUTTON_CLICK
)
}
override fun clearError() {
_errorLiveData.value = null
}
override fun navigateToSafetyCenter(context: Context, navigationSource: NavigationSource?) {
val intent = Intent(ACTION_SAFETY_CENTER)
if (navigationSource != null) {
navigationSource.addToIntent(intent)
}
context.startActivity(intent)
}
override fun pageOpen() {
executeIfNotChangingConfigurations {
safetyCenterManager.refreshSafetySources(SafetyCenterManager.REFRESH_REASON_PAGE_OPEN)
}
}
@RequiresApi(UPSIDE_DOWN_CAKE)
override fun pageOpen(sourceGroupId: String) {
executeIfNotChangingConfigurations {
val safetySourceIds = getSafetySourceIdsToRefresh(sourceGroupId)
if (safetySourceIds == null) {
Log.w(TAG, "$sourceGroupId has no matching source IDs, so refreshing all sources")
safetyCenterManager.refreshSafetySources(
SafetyCenterManager.REFRESH_REASON_PAGE_OPEN
)
} else {
safetyCenterManager.refreshSafetySources(
SafetyCenterManager.REFRESH_REASON_PAGE_OPEN,
safetySourceIds
)
}
}
}
override fun changingConfigurations() {
changingConfigurations = true
}
private fun executeIfNotChangingConfigurations(block: () -> Unit) {
if (changingConfigurations) {
// Don't refresh when changing configurations, but reset for the next pageOpen call
changingConfigurations = false
return
}
block()
}
private fun getSafetySourceIdsToRefresh(sourceGroupId: String): List<String>? {
val safetySourcesGroup =
safetyCenterManager.safetyCenterConfig?.safetySourcesGroups?.find {
it.id == sourceGroupId
}
return safetySourcesGroup?.safetySources?.map { it.id }
}
private inner class SafetyCenterLiveData :
MutableLiveData<SafetyCenterUiData>(),
SafetyCenterManager.OnSafetyCenterDataChangedListener {
// Managing the data queue isn't designed to support multithreading. Any methods that
// manipulate it, or the inFlight or resolved issues lists should only be called on the
// main thread, and are marked accordingly.
private val safetyCenterDataQueue = ArrayDeque<SafetyCenterData>()
private var issuesPendingResolution = mapOf<IssueId, ActionId>()
private val currentResolvedIssues = mutableMapOf<IssueId, ActionId>()
override fun onActive() {
safetyCenterManager.addOnSafetyCenterDataChangedListener(
getMainExecutor(app.applicationContext),
this
)
super.onActive()
}
override fun onInactive() {
safetyCenterManager.removeOnSafetyCenterDataChangedListener(this)
if (!changingConfigurations) {
// Remove all the tracked state and start from scratch when active again.
issuesPendingResolution = mapOf()
currentResolvedIssues.clear()
safetyCenterDataQueue.clear()
}
super.onInactive()
}
@MainThread
override fun onSafetyCenterDataChanged(data: SafetyCenterData) {
safetyCenterDataQueue.addLast(data)
maybeProcessDataToNextResolvedIssues()
}
override fun onError(errorDetails: SafetyCenterErrorDetails) {
_errorLiveData.value = errorDetails
}
@MainThread
private fun maybeProcessDataToNextResolvedIssues() {
// Only process data updates while we aren't waiting for issue resolution animations
// to complete.
if (currentResolvedIssues.isNotEmpty()) {
Log.d(
TAG,
"Received SafetyCenterData while issue resolution animations" +
" occurring. Will update UI with new data soon."
)
return
}
while (safetyCenterDataQueue.isNotEmpty() && currentResolvedIssues.isEmpty()) {
val nextData = safetyCenterDataQueue.first()
// Calculate newly resolved issues by diffing the tracked in-flight issues and the
// current update. Resolved issues are formerly in-flight issues that no longer
// appear in a subsequent SafetyCenterData update.
val nextResolvedIssues: Map<IssueId, ActionId> =
determineResolvedIssues(nextData.buildIssueIdSet())
// Save the set of in-flight issues to diff against the next data update, removing
// the now-resolved, formerly in-flight issues. If these are not tracked separately
// the queue will not progress once the issue resolution animations complete.
issuesPendingResolution = nextData.getInFlightIssues()
if (nextResolvedIssues.isNotEmpty()) {
currentResolvedIssues.putAll(nextResolvedIssues)
sendResolvedIssuesAndCurrentData()
} else if (shouldEndScan(nextData) || shouldSendLastDataInQueue()) {
sendNextData()
} else {
skipNextData()
}
}
}
private fun determineResolvedIssues(nextIssueIds: Set<IssueId>): Map<IssueId, ActionId> {
// Any previously in-flight issue that does not appear in the incoming SafetyCenterData
// is considered resolved.
return issuesPendingResolution.filterNot { issue -> nextIssueIds.contains(issue.key) }
}
private fun shouldEndScan(nextData: SafetyCenterData): Boolean =
isCurrentlyScanning() && !nextData.isScanning()
private fun shouldSendLastDataInQueue(): Boolean =
!isCurrentlyScanning() && safetyCenterDataQueue.size == 1
private fun isCurrentlyScanning(): Boolean = value?.safetyCenterData?.isScanning() ?: false
private fun sendNextData() {
value = SafetyCenterUiData(safetyCenterDataQueue.removeFirst())
}
private fun skipNextData() = safetyCenterDataQueue.removeFirst()
private fun sendResolvedIssuesAndCurrentData() {
val currentData = value?.safetyCenterData
if (currentData == null || currentResolvedIssues.isEmpty()) {
// There can only be resolved issues after receiving data with in-flight issues,
// so we should always have already sent data here.
throw IllegalArgumentException("No current data or no resolved issues")
}
// The current SafetyCenterData still contains the resolved SafetyCenterIssue objects.
// Send it with the resolved IDs so the UI can generate the correct preferences and
// trigger the right animations for issue resolution.
value = SafetyCenterUiData(currentData, currentResolvedIssues)
}
@MainThread
fun markIssueResolvedUiCompleted(issueId: IssueId) {
currentResolvedIssues.remove(issueId)
maybeProcessDataToNextResolvedIssues()
}
}
}
/** Returns inflight issues pending resolution */
private fun SafetyCenterData.getInFlightIssues(): Map<IssueId, ActionId> =
allResolvableIssues
.map { issue ->
issue.actions
// UX requirements require skipping resolution UI for issues that do not have a
// valid successMessage
.filter { it.isInFlight && !it.successMessage.isNullOrEmpty() }
.map { issue.id to it.id }
}
.flatten()
.toMap()
private fun SafetyCenterData.isScanning() =
status.refreshStatus == SafetyCenterStatus.REFRESH_STATUS_FULL_RESCAN_IN_PROGRESS
private fun SafetyCenterData.buildIssueIdSet(): Set<IssueId> =
allResolvableIssues.map { it.id }.toSet()
private val SafetyCenterData.allResolvableIssues: Sequence<SafetyCenterIssue>
get() =
if (SdkLevel.isAtLeastU()) {
issues.asSequence() + dismissedIssues.asSequence()
} else {
issues.asSequence()
}
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
class LiveSafetyCenterViewModelFactory(private val app: Application) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST") return LiveSafetyCenterViewModel(app) as T
}
}