1 /* <lambda>null2 * Copyright (C) 2023 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 */ 18 19 /** 20 * Copyright (C) 2022 The Android Open Source Project 21 * 22 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 23 * in compliance with the License. You may obtain a copy of the License at 24 * 25 * ``` 26 * http://www.apache.org/licenses/LICENSE-2.0 27 * ``` 28 * 29 * Unless required by applicable law or agreed to in writing, software distributed under the License 30 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 31 * or implied. See the License for the specific language governing permissions and limitations under 32 * the License. 33 */ 34 package com.android.healthconnect.controller.permissions.app 35 36 import android.content.Intent.EXTRA_PACKAGE_NAME 37 import android.os.Bundle 38 import android.view.View 39 import android.widget.CompoundButton.OnCheckedChangeListener 40 import android.widget.Toast 41 import androidx.core.os.bundleOf 42 import androidx.fragment.app.activityViewModels 43 import androidx.fragment.app.viewModels 44 import androidx.preference.PreferenceGroup 45 import androidx.preference.TwoStatePreference 46 import com.android.healthconnect.controller.R 47 import com.android.healthconnect.controller.migration.MigrationActivity.Companion.maybeShowMigrationDialog 48 import com.android.healthconnect.controller.migration.MigrationViewModel 49 import com.android.healthconnect.controller.migration.MigrationViewModel.MigrationFragmentState.* 50 import com.android.healthconnect.controller.permissions.additionalaccess.AdditionalAccessViewModel 51 import com.android.healthconnect.controller.permissions.additionalaccess.DisableExerciseRoutePermissionDialog 52 import com.android.healthconnect.controller.permissions.app.AppPermissionViewModel.RevokeAllState 53 import com.android.healthconnect.controller.permissions.data.FitnessPermissionStrings.Companion.fromPermissionType 54 import com.android.healthconnect.controller.permissions.data.HealthPermission.FitnessPermission 55 import com.android.healthconnect.controller.permissions.data.PermissionsAccessType 56 import com.android.healthconnect.controller.permissions.shared.DisconnectDialogFragment 57 import com.android.healthconnect.controller.shared.HealthDataCategoryExtensions.fromHealthPermissionType 58 import com.android.healthconnect.controller.shared.HealthDataCategoryExtensions.icon 59 import com.android.healthconnect.controller.shared.HealthPermissionReader 60 import com.android.healthconnect.controller.shared.preference.HealthMainSwitchPreference 61 import com.android.healthconnect.controller.shared.preference.HealthPreference 62 import com.android.healthconnect.controller.shared.preference.HealthPreferenceFragment 63 import com.android.healthconnect.controller.shared.preference.HealthSwitchPreference 64 import com.android.healthconnect.controller.utils.LocalDateTimeFormatter 65 import com.android.healthconnect.controller.utils.NavigationUtils 66 import com.android.healthconnect.controller.utils.dismissLoadingDialog 67 import com.android.healthconnect.controller.utils.logging.AppAccessElement.ADDITIONAL_ACCESS_BUTTON 68 import com.android.healthconnect.controller.utils.logging.PageName 69 import com.android.healthconnect.controller.utils.logging.PermissionsElement 70 import com.android.healthconnect.controller.utils.pref 71 import com.android.healthconnect.controller.utils.showLoadingDialog 72 import com.android.settingslib.widget.AppHeaderPreference 73 import com.android.settingslib.widget.FooterPreference 74 import dagger.hilt.android.AndroidEntryPoint 75 import javax.inject.Inject 76 77 /** 78 * Fragment to show granted/revoked health permissions for and app. It is used as an entry point 79 * from PermissionController. 80 * 81 * For apps that declares health connect permissions without the rational intent, we only show 82 * granted permissions to allow the user to revoke this app permissions. 83 */ 84 @AndroidEntryPoint(HealthPreferenceFragment::class) 85 class SettingsManageAppPermissionsFragment : Hilt_SettingsManageAppPermissionsFragment() { 86 87 init { 88 this.setPageName(PageName.MANAGE_PERMISSIONS_PAGE) 89 } 90 91 @Inject lateinit var healthPermissionReader: HealthPermissionReader 92 @Inject lateinit var navigationUtils: NavigationUtils 93 94 private lateinit var packageName: String 95 private var appName: String = "" 96 97 private val viewModel: AppPermissionViewModel by activityViewModels() 98 private val permissionMap: MutableMap<FitnessPermission, TwoStatePreference> = mutableMapOf() 99 private val additionalAccessViewModel: AdditionalAccessViewModel by viewModels() 100 private val migrationViewModel: MigrationViewModel by viewModels() 101 private val allowAllPreference: HealthMainSwitchPreference by pref(ALLOW_ALL_PREFERENCE) 102 private val readPermissionCategory: PreferenceGroup by pref(READ_CATEGORY) 103 private val writePermissionCategory: PreferenceGroup by pref(WRITE_CATEGORY) 104 private val manageAppCategory: PreferenceGroup by pref(MANAGE_APP_CATEGORY) 105 private val header: AppHeaderPreference by pref(PERMISSION_HEADER) 106 private val footer: FooterPreference by pref(FOOTER) 107 private val dateFormatter by lazy { LocalDateTimeFormatter(requireContext()) } 108 private val onSwitchChangeListener = OnCheckedChangeListener { switchView, isChecked -> 109 if (isChecked) { 110 val permissionsUpdated = viewModel.grantAllPermissions(packageName) 111 if (!permissionsUpdated) { 112 switchView.isChecked = false 113 Toast.makeText(requireContext(), R.string.default_error, Toast.LENGTH_SHORT).show() 114 } 115 } else { 116 showRevokeAllPermissions() 117 } 118 } 119 120 override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { 121 super.onCreatePreferences(savedInstanceState, rootKey) 122 setPreferencesFromResource(R.xml.settings_manage_app_permission_screen, rootKey) 123 124 allowAllPreference.apply { 125 logNameActive = PermissionsElement.ALLOW_ALL_SWITCH 126 logNameInactive = PermissionsElement.ALLOW_ALL_SWITCH 127 } 128 } 129 130 override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 131 super.onViewCreated(view, savedInstanceState) 132 if (requireArguments().containsKey(EXTRA_PACKAGE_NAME) && 133 requireArguments().getString(EXTRA_PACKAGE_NAME) != null) { 134 packageName = requireArguments().getString(EXTRA_PACKAGE_NAME)!! 135 } 136 137 viewModel.loadPermissionsForPackage(packageName) 138 additionalAccessViewModel.loadAdditionalAccessPreferences(packageName) 139 140 viewModel.appPermissions.observe(viewLifecycleOwner) { permissions -> 141 updatePermissions(permissions) 142 } 143 viewModel.grantedPermissions.observe(viewLifecycleOwner) { granted -> 144 permissionMap.forEach { (healthPermission, switchPreference) -> 145 switchPreference.isChecked = healthPermission in granted 146 } 147 } 148 viewModel.lastReadPermissionDisconnected.observe(viewLifecycleOwner) { lastRead -> 149 if (lastRead) { 150 Toast.makeText( 151 requireContext(), 152 R.string.removed_additional_permissions_toast, 153 Toast.LENGTH_LONG) 154 .show() 155 viewModel.markLastReadShown() 156 } 157 } 158 159 viewModel.revokeAllPermissionsState.observe(viewLifecycleOwner) { state -> 160 when (state) { 161 is RevokeAllState.Loading -> { 162 showLoadingDialog() 163 } 164 else -> { 165 dismissLoadingDialog() 166 } 167 } 168 } 169 170 migrationViewModel.migrationState.observe(viewLifecycleOwner) { migrationState -> 171 when (migrationState) { 172 is WithData -> { 173 maybeShowMigrationDialog( 174 migrationState.migrationRestoreState, 175 requireActivity(), 176 viewModel.appInfo.value?.appName!!) 177 } 178 else -> { 179 // do nothing 180 } 181 } 182 } 183 184 viewModel.showDisableExerciseRouteEvent.observe(viewLifecycleOwner) { event -> 185 if (event.shouldShowDialog) { 186 DisableExerciseRoutePermissionDialog.createDialog(packageName, event.appName) 187 .show(childFragmentManager, DISABLE_EXERCISE_ROUTE_DIALOG_TAG) 188 } 189 } 190 191 childFragmentManager.setFragmentResultListener( 192 DisconnectDialogFragment.DISCONNECT_CANCELED_EVENT, this) { _, _ -> 193 allowAllPreference.isChecked = true 194 } 195 196 childFragmentManager.setFragmentResultListener( 197 DisconnectDialogFragment.DISCONNECT_ALL_EVENT, this) { _, bundle -> 198 if (!viewModel.revokeAllPermissions(packageName)) { 199 Toast.makeText(requireContext(), R.string.default_error, Toast.LENGTH_SHORT) 200 .show() 201 } 202 203 if (bundle.containsKey(DisconnectDialogFragment.KEY_DELETE_DATA) && 204 bundle.getBoolean(DisconnectDialogFragment.KEY_DELETE_DATA)) { 205 viewModel.deleteAppData(packageName, appName) 206 } 207 } 208 209 setupHeader() 210 setupManageAppCategory() 211 } 212 213 private fun setupHeader() { 214 viewModel.appInfo.observe(viewLifecycleOwner) { appMetadata -> 215 packageName = appMetadata.packageName 216 appName = appMetadata.appName 217 setupAllowAllPreference() 218 setupFooter(appMetadata.appName) 219 header.apply { 220 icon = appMetadata.icon 221 title = appMetadata.appName 222 } 223 } 224 } 225 226 private fun setupFooter(appName: String) { 227 if (viewModel.isPackageSupported(packageName)) { 228 viewModel.atLeastOnePermissionGranted.observe(viewLifecycleOwner) { isAtLeastOneGranted 229 -> 230 updateFooter(isAtLeastOneGranted, appName) 231 } 232 } else { 233 preferenceScreen.removePreferenceRecursively(FOOTER) 234 } 235 } 236 237 private fun setupManageAppCategory() { 238 additionalAccessViewModel.additionalAccessState.observe(viewLifecycleOwner) { state -> 239 manageAppCategory.isVisible = state.isValid() 240 manageAppCategory.removeAll() 241 if (state.isValid()) { 242 val additionalAccessPref = 243 HealthPreference(requireContext()).also { 244 it.key = KEY_ADDITIONAL_ACCESS 245 it.logName = ADDITIONAL_ACCESS_BUTTON 246 it.setTitle(R.string.additional_access_label) 247 it.setOnPreferenceClickListener { _ -> 248 val extras = bundleOf(EXTRA_PACKAGE_NAME to packageName) 249 navigationUtils.navigate( 250 fragment = this, 251 action = R.id.action_manageAppFragment_to_additionalAccessFragment, 252 bundle = extras) 253 true 254 } 255 } 256 manageAppCategory.addPreference(additionalAccessPref) 257 } 258 } 259 } 260 261 private fun setupAllowAllPreference() { 262 allowAllPreference.addOnSwitchChangeListener(onSwitchChangeListener) 263 viewModel.allAppPermissionsGranted.observe(viewLifecycleOwner) { isAllGranted -> 264 allowAllPreference.removeOnSwitchChangeListener(onSwitchChangeListener) 265 allowAllPreference.isChecked = isAllGranted 266 allowAllPreference.addOnSwitchChangeListener(onSwitchChangeListener) 267 } 268 } 269 270 private fun showRevokeAllPermissions() { 271 DisconnectDialogFragment(appName = appName, enableDeleteData = false) 272 .show(childFragmentManager, DisconnectDialogFragment.TAG) 273 } 274 275 private fun updatePermissions(permissions: List<FitnessPermission>) { 276 readPermissionCategory.removeAll() 277 writePermissionCategory.removeAll() 278 279 permissionMap.clear() 280 281 permissions 282 .sortedBy { 283 requireContext() 284 .getString(fromPermissionType(it.healthPermissionType).uppercaseLabel) 285 } 286 .forEach { permission -> 287 val category = 288 if (permission.permissionsAccessType == PermissionsAccessType.READ) { 289 readPermissionCategory 290 } else { 291 writePermissionCategory 292 } 293 val switchPreference = 294 HealthSwitchPreference(requireContext()).also { 295 val healthCategory = 296 fromHealthPermissionType(permission.healthPermissionType) 297 it.icon = healthCategory.icon(requireContext()) 298 it.setTitle( 299 fromPermissionType(permission.healthPermissionType).uppercaseLabel) 300 it.logNameActive = PermissionsElement.PERMISSION_SWITCH 301 it.logNameInactive = PermissionsElement.PERMISSION_SWITCH 302 it.setOnPreferenceChangeListener { _, newValue -> 303 val checked = newValue as Boolean 304 val permissionUpdated = 305 viewModel.updatePermission(packageName, permission, checked) 306 if (!permissionUpdated) { 307 Toast.makeText( 308 requireContext(), 309 R.string.default_error, 310 Toast.LENGTH_SHORT) 311 .show() 312 } 313 permissionUpdated 314 } 315 } 316 permissionMap[permission] = switchPreference 317 category.addPreference(switchPreference) 318 } 319 320 // Hide category if it contains no permissions 321 readPermissionCategory.apply { isVisible = (preferenceCount != 0) } 322 writePermissionCategory.apply { isVisible = (preferenceCount != 0) } 323 } 324 325 private fun updateFooter(isAtLeastOneGranted: Boolean, appName: String) { 326 var title = getString(R.string.manage_permissions_rationale, appName) 327 328 val isHistoryReadAvailable = 329 additionalAccessViewModel.additionalAccessState.value?.historyReadUIState?.isDeclared 330 ?: false 331 // Do not show the access date here if history read is available 332 if (isAtLeastOneGranted && !isHistoryReadAvailable) { 333 val dataAccessDate = viewModel.loadAccessDate(packageName) 334 dataAccessDate?.let { 335 val formattedDate = dateFormatter.formatLongDate(dataAccessDate) 336 title = 337 getString(R.string.manage_permissions_time_frame, appName, formattedDate) + 338 PARAGRAPH_SEPARATOR + 339 title 340 } 341 } 342 343 footer.title = title 344 if (healthPermissionReader.isRationaleIntentDeclared(packageName)) { 345 footer.setLearnMoreText(getString(R.string.manage_permissions_learn_more)) 346 footer.setLearnMoreAction { 347 val startRationaleIntent = 348 healthPermissionReader.getApplicationRationaleIntent(packageName) 349 startActivity(startRationaleIntent) 350 } 351 } 352 } 353 354 companion object { 355 private const val ALLOW_ALL_PREFERENCE = "allow_all_preference" 356 private const val READ_CATEGORY = "read_permission_category" 357 private const val WRITE_CATEGORY = "write_permission_category" 358 private const val PERMISSION_HEADER = "manage_app_permission_header" 359 private const val MANAGE_APP_CATEGORY = "manage_app_category" 360 private const val KEY_ADDITIONAL_ACCESS = "additional_access" 361 private const val DISABLE_EXERCISE_ROUTE_DIALOG_TAG = "disable_exercise_route_dialog" 362 private const val FOOTER = "manage_app_permission_footer" 363 private const val PARAGRAPH_SEPARATOR = "\n\n" 364 } 365 } 366