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