1 /**
<lambda>null2  * Copyright (C) 2022 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
5  * in compliance with the License. You may obtain a copy of the License at
6  *
7  * http://www.apache.org/licenses/LICENSE-2.0
8  *
9  * Unless required by applicable law or agreed to in writing, software distributed under the License
10  * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
11  * or implied. See the License for the specific language governing permissions and limitations under
12  * the License.
13  */
14 package com.android.healthconnect.controller.permissions.app
15 
16 import android.health.connect.HealthPermissions.READ_EXERCISE
17 import android.health.connect.HealthPermissions.READ_EXERCISE_ROUTES
18 import android.health.connect.TimeInstantRangeFilter
19 import android.util.Log
20 import androidx.lifecycle.LiveData
21 import androidx.lifecycle.MediatorLiveData
22 import androidx.lifecycle.MutableLiveData
23 import androidx.lifecycle.ViewModel
24 import androidx.lifecycle.viewModelScope
25 import com.android.healthconnect.controller.deletion.DeletionType
26 import com.android.healthconnect.controller.deletion.api.DeleteAppDataUseCase
27 import com.android.healthconnect.controller.permissions.additionalaccess.ILoadExerciseRoutePermissionUseCase
28 import com.android.healthconnect.controller.permissions.additionalaccess.PermissionUiState.ALWAYS_ALLOW
29 import com.android.healthconnect.controller.permissions.api.GrantHealthPermissionUseCase
30 import com.android.healthconnect.controller.permissions.api.IGetGrantedHealthPermissionsUseCase
31 import com.android.healthconnect.controller.permissions.api.LoadAccessDateUseCase
32 import com.android.healthconnect.controller.permissions.api.RevokeAllHealthPermissionsUseCase
33 import com.android.healthconnect.controller.permissions.api.RevokeHealthPermissionUseCase
34 import com.android.healthconnect.controller.permissions.data.HealthPermission
35 import com.android.healthconnect.controller.permissions.data.HealthPermission.FitnessPermission
36 import com.android.healthconnect.controller.permissions.data.HealthPermission.FitnessPermission.Companion.fromPermissionString
37 import com.android.healthconnect.controller.permissions.data.PermissionsAccessType
38 import com.android.healthconnect.controller.service.IoDispatcher
39 import com.android.healthconnect.controller.shared.HealthPermissionReader
40 import com.android.healthconnect.controller.shared.app.AppInfoReader
41 import com.android.healthconnect.controller.shared.app.AppMetadata
42 import com.android.healthconnect.controller.shared.usecase.UseCaseResults
43 import com.android.healthconnect.controller.utils.FeatureUtils
44 import dagger.hilt.android.lifecycle.HiltViewModel
45 import java.time.Instant
46 import javax.inject.Inject
47 import kotlinx.coroutines.CoroutineDispatcher
48 import kotlinx.coroutines.launch
49 import kotlinx.coroutines.runBlocking
50 
51 /** View model for {@link ConnectedAppFragment} and {SettingsManageAppPermissionsFragment} . */
52 @HiltViewModel
53 class AppPermissionViewModel
54 @Inject
55 constructor(
56     private val appInfoReader: AppInfoReader,
57     private val loadAppPermissionsStatusUseCase: LoadAppPermissionsStatusUseCase,
58     private val grantPermissionsStatusUseCase: GrantHealthPermissionUseCase,
59     private val revokePermissionsStatusUseCase: RevokeHealthPermissionUseCase,
60     private val revokeAllHealthPermissionsUseCase: RevokeAllHealthPermissionsUseCase,
61     private val deleteAppDataUseCase: DeleteAppDataUseCase,
62     private val loadAccessDateUseCase: LoadAccessDateUseCase,
63     private val loadGrantedHealthPermissionsUseCase: IGetGrantedHealthPermissionsUseCase,
64     private val loadExerciseRoutePermissionUseCase: ILoadExerciseRoutePermissionUseCase,
65     private val healthPermissionReader: HealthPermissionReader,
66     private val featureUtils: FeatureUtils,
67     @IoDispatcher private val ioDispatcher: CoroutineDispatcher
68 ) : ViewModel() {
69 
70     companion object {
71         private const val TAG = "AppPermissionViewModel"
72     }
73 
74     private val _appPermissions = MutableLiveData<List<FitnessPermission>>(emptyList())
75     val appPermissions: LiveData<List<FitnessPermission>>
76         get() = _appPermissions
77 
78     private val _grantedPermissions = MutableLiveData<Set<FitnessPermission>>(emptySet())
79     val grantedPermissions: LiveData<Set<FitnessPermission>>
80         get() = _grantedPermissions
81 
82     val allAppPermissionsGranted =
83         MediatorLiveData(false).apply {
84             addSource(_appPermissions) {
85                 postValue(isAllPermissionsGranted(appPermissions, grantedPermissions))
86             }
87             addSource(_grantedPermissions) {
88                 postValue(isAllPermissionsGranted(appPermissions, grantedPermissions))
89             }
90         }
91 
92     val atLeastOnePermissionGranted =
93         MediatorLiveData(false).apply {
94             addSource(_grantedPermissions) { grantedPermissions ->
95                 postValue(grantedPermissions.isNotEmpty())
96             }
97         }
98 
99     private val _appInfo = MutableLiveData<AppMetadata>()
100     val appInfo: LiveData<AppMetadata>
101         get() = _appInfo
102 
103     private val _revokeAllPermissionsState =
104         MutableLiveData<RevokeAllState>(RevokeAllState.NotStarted)
105     val revokeAllPermissionsState: LiveData<RevokeAllState>
106         get() = _revokeAllPermissionsState
107 
108     private var permissionsList: List<HealthPermissionStatus> = listOf()
109 
110     /**
111      * Flag to prevent {@link SettingManageAppPermissionsFragment} from reloading the granted
112      * permissions on orientation change
113      */
114     private var shouldLoadGrantedPermissions = true
115 
116     private val _showDisableExerciseRouteEvent = MutableLiveData(false)
117     val showDisableExerciseRouteEvent =
118         MediatorLiveData(DisableExerciseRouteDialogEvent()).apply {
119             addSource(_showDisableExerciseRouteEvent) {
120                 postValue(
121                     DisableExerciseRouteDialogEvent(
122                         shouldShowDialog = _showDisableExerciseRouteEvent.value ?: false,
123                         appName = _appInfo.value?.appName ?: ""))
124             }
125             addSource(_appInfo) {
126                 postValue(
127                     DisableExerciseRouteDialogEvent(
128                         shouldShowDialog = _showDisableExerciseRouteEvent.value ?: false,
129                         appName = _appInfo.value?.appName ?: ""))
130             }
131         }
132 
133     private val _lastReadPermissionDisconnected = MutableLiveData(false)
134     val lastReadPermissionDisconnected: LiveData<Boolean>
135         get() = _lastReadPermissionDisconnected
136 
137     private var grantedAdditionalPermissions: List<String> = emptyList()
138 
139     fun loadPermissionsForPackage(packageName: String) {
140         // clear app permissions
141         _appPermissions.postValue(emptyList())
142         _grantedPermissions.postValue(emptySet())
143 
144         viewModelScope.launch { _appInfo.postValue(appInfoReader.getAppMetadata(packageName)) }
145         if (isPackageSupported(packageName)) {
146             loadAllPermissions(packageName)
147         } else {
148             // we only load granted permissions for not supported apps to allow users to revoke
149             // these permissions.
150             loadGrantedPermissionsForPackage(packageName)
151         }
152     }
153 
154     private fun loadAllPermissions(packageName: String) {
155         viewModelScope.launch {
156             permissionsList = loadAppPermissionsStatusUseCase.invoke(packageName)
157             _appPermissions.postValue(
158                 permissionsList.map { it.healthPermission }.filterIsInstance<FitnessPermission>())
159             _grantedPermissions.postValue(
160                 permissionsList
161                     .filter { it.isGranted }
162                     .map { it.healthPermission }
163                     .filterIsInstance<FitnessPermission>()
164                     .toSet())
165             grantedAdditionalPermissions =
166                 permissionsList
167                     .filter { it.isGranted }
168                     .map { it.healthPermission }
169                     .filterIsInstance<HealthPermission.AdditionalPermission>()
170                     .map { it.additionalPermission }
171         }
172     }
173 
174     private fun loadGrantedPermissionsForPackage(packageName: String) {
175         // Only reload the status the first time this method is called
176         if (shouldLoadGrantedPermissions) {
177             viewModelScope.launch {
178                 val grantedPermissions =
179                     loadAppPermissionsStatusUseCase.invoke(packageName).filter { it.isGranted }
180                 permissionsList = grantedPermissions
181 
182                 // Only show app permissions that are granted
183                 _appPermissions.postValue(
184                     grantedPermissions
185                         .map { it.healthPermission }
186                         .filterIsInstance<FitnessPermission>())
187                 _grantedPermissions.postValue(
188                     grantedPermissions
189                         .map { it.healthPermission }
190                         .filterIsInstance<FitnessPermission>()
191                         .toSet())
192             }
193             shouldLoadGrantedPermissions = false
194         }
195     }
196 
197     fun loadAccessDate(packageName: String): Instant? {
198         return loadAccessDateUseCase.invoke(packageName)
199     }
200 
201     fun updatePermission(
202         packageName: String,
203         dataTypePermission: FitnessPermission,
204         grant: Boolean
205     ): Boolean {
206         try {
207             if (grant) {
208                 grantPermission(packageName, dataTypePermission)
209             } else {
210                 if (shouldDisplayExerciseRouteDialog(packageName, dataTypePermission)) {
211                     _showDisableExerciseRouteEvent.postValue(true)
212                 } else {
213                     revokePermission(dataTypePermission, packageName)
214                 }
215             }
216 
217             return true
218         } catch (ex: Exception) {
219             Log.e(TAG, "Failed to update permissions!", ex)
220         }
221         return false
222     }
223 
224     private fun grantPermission(packageName: String, fitnessPermission: FitnessPermission) {
225         val grantedPermissions = _grantedPermissions.value.orEmpty().toMutableSet()
226         grantPermissionsStatusUseCase.invoke(packageName, fitnessPermission.toString())
227         grantedPermissions.add(fitnessPermission)
228         _grantedPermissions.postValue(grantedPermissions)
229     }
230 
231     private fun revokePermission(fitnessPermission: FitnessPermission, packageName: String) {
232         val grantedPermissions = _grantedPermissions.value.orEmpty().toMutableSet()
233         val readPermissionsBeforeDisconnect =
234             grantedPermissions.count { permission ->
235                 permission.permissionsAccessType == PermissionsAccessType.READ
236             }
237         grantedPermissions.remove(fitnessPermission)
238         val readPermissionsAfterDisconnect =
239             grantedPermissions.count { permission ->
240                 permission.permissionsAccessType == PermissionsAccessType.READ
241             }
242         _grantedPermissions.postValue(grantedPermissions)
243 
244         val lastReadPermissionRevoked =
245             grantedAdditionalPermissions.isNotEmpty() &&
246                 (readPermissionsBeforeDisconnect > readPermissionsAfterDisconnect) &&
247                 readPermissionsAfterDisconnect == 0
248 
249         if (lastReadPermissionRevoked) {
250             grantedAdditionalPermissions.forEach { permission ->
251                 revokePermissionsStatusUseCase.invoke(packageName, permission)
252             }
253         }
254 
255         _lastReadPermissionDisconnected.postValue(lastReadPermissionRevoked)
256         revokePermissionsStatusUseCase.invoke(packageName, fitnessPermission.toString())
257     }
258 
259     fun markLastReadShown() {
260         _lastReadPermissionDisconnected.postValue(false)
261     }
262 
263     private fun shouldDisplayExerciseRouteDialog(
264         packageName: String,
265         fitnessPermission: FitnessPermission
266     ): Boolean {
267         if (!featureUtils.isExerciseRouteReadAllEnabled() ||
268             fitnessPermission.toString() != READ_EXERCISE) {
269             return false
270         }
271 
272         return isExerciseRoutePermissionAlwaysAllow(packageName)
273     }
274 
275     fun grantAllPermissions(packageName: String): Boolean {
276         try {
277             _appPermissions.value?.forEach {
278                 grantPermissionsStatusUseCase.invoke(packageName, it.toString())
279             }
280             val grantedPermissions = _grantedPermissions.value.orEmpty().toMutableSet()
281             grantedPermissions.addAll(_appPermissions.value.orEmpty())
282             _grantedPermissions.postValue(grantedPermissions)
283             return true
284         } catch (ex: Exception) {
285             Log.e(TAG, "Failed to update permissions!", ex)
286         }
287         return false
288     }
289 
290     fun disableExerciseRoutePermission(packageName: String) {
291         revokePermission(fromPermissionString(READ_EXERCISE), packageName)
292         // the revokePermission call will automatically revoke all additional permissions
293         // including Exercise Routes if the READ_EXERCISE permission is the last READ permission
294         if (isExerciseRoutePermissionAlwaysAllow(packageName)) {
295             revokePermissionsStatusUseCase(packageName, READ_EXERCISE_ROUTES)
296         }
297     }
298 
299     private fun isExerciseRoutePermissionAlwaysAllow(packageName: String): Boolean = runBlocking {
300         when (val exerciseRouteState = loadExerciseRoutePermissionUseCase(packageName)) {
301             is UseCaseResults.Success -> {
302                 exerciseRouteState.data.exerciseRoutePermissionState == ALWAYS_ALLOW
303             }
304             else -> false
305         }
306     }
307 
308     fun revokeAllPermissions(packageName: String): Boolean {
309         // TODO (b/325729045) if there is an error within the coroutine scope
310         // it will not be caught by this statement in tests. Consider using LiveData instead
311         try {
312             viewModelScope.launch(ioDispatcher) {
313                 _revokeAllPermissionsState.postValue(RevokeAllState.Loading)
314                 revokeAllHealthPermissionsUseCase.invoke(packageName)
315                 if (isPackageSupported(packageName)) {
316                     loadPermissionsForPackage(packageName)
317                 }
318                 _revokeAllPermissionsState.postValue(RevokeAllState.Updated)
319                 _grantedPermissions.postValue(emptySet())
320             }
321             return true
322         } catch (ex: Exception) {
323             Log.e(TAG, "Failed to update permissions!", ex)
324         }
325         return false
326     }
327 
328     fun deleteAppData(packageName: String, appName: String) {
329         viewModelScope.launch {
330             val appData = DeletionType.DeletionTypeAppData(packageName, appName)
331             val timeRangeFilter =
332                 TimeInstantRangeFilter.Builder()
333                     .setStartTime(Instant.EPOCH)
334                     .setEndTime(Instant.ofEpochMilli(Long.MAX_VALUE))
335                     .build()
336             deleteAppDataUseCase.invoke(appData, timeRangeFilter)
337         }
338     }
339 
340     fun shouldNavigateToAppPermissionsFragment(packageName: String): Boolean {
341         return isPackageSupported(packageName) || hasGrantedPermissions(packageName)
342     }
343 
344     private fun hasGrantedPermissions(packageName: String): Boolean {
345         return loadGrantedHealthPermissionsUseCase(packageName)
346             .map { permission -> fromPermissionString(permission) }
347             .isNotEmpty()
348     }
349 
350     private fun isAllPermissionsGranted(
351         permissionsListLiveData: LiveData<List<FitnessPermission>>,
352         grantedPermissionsLiveData: LiveData<Set<FitnessPermission>>
353     ): Boolean {
354         val permissionsList = permissionsListLiveData.value.orEmpty()
355         val grantedPermissions = grantedPermissionsLiveData.value.orEmpty()
356         return if (permissionsList.isEmpty() || grantedPermissions.isEmpty()) {
357             false
358         } else {
359             permissionsList.size == grantedPermissions.size
360         }
361     }
362 
363     /** Returns True if the packageName declares the Rationale intent, False otherwise */
364     fun isPackageSupported(packageName: String): Boolean {
365         return healthPermissionReader.isRationaleIntentDeclared(packageName)
366     }
367 
368     fun hideExerciseRoutePermissionDialog() {
369         _showDisableExerciseRouteEvent.postValue(false)
370     }
371 
372     sealed class RevokeAllState {
373         object NotStarted : RevokeAllState()
374 
375         object Loading : RevokeAllState()
376 
377         object Updated : RevokeAllState()
378     }
379 
380     data class DisableExerciseRouteDialogEvent(
381         val shouldShowDialog: Boolean = false,
382         val appName: String = ""
383     )
384 }
385