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 * http://www.apache.org/licenses/LICENSE-2.0 26 * 27 * Unless required by applicable law or agreed to in writing, software distributed under the License 28 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 29 * or implied. See the License for the specific language governing permissions and limitations under 30 * the License. 31 */ 32 package com.android.healthconnect.controller.permissions.app 33 34 import android.content.Intent.EXTRA_PACKAGE_NAME 35 import android.os.Bundle 36 import android.view.View 37 import android.widget.CompoundButton.OnCheckedChangeListener 38 import android.widget.Toast 39 import androidx.core.os.bundleOf 40 import androidx.fragment.app.activityViewModels 41 import androidx.fragment.app.commitNow 42 import androidx.navigation.fragment.findNavController 43 import androidx.preference.PreferenceGroup 44 import com.android.healthconnect.controller.R 45 import com.android.healthconnect.controller.deletion.DeletionConstants.DELETION_TYPE 46 import com.android.healthconnect.controller.deletion.DeletionConstants.FRAGMENT_TAG_DELETION 47 import com.android.healthconnect.controller.deletion.DeletionConstants.START_DELETION_EVENT 48 import com.android.healthconnect.controller.deletion.DeletionFragment 49 import com.android.healthconnect.controller.deletion.DeletionType 50 import com.android.healthconnect.controller.deletion.DeletionViewModel 51 import com.android.healthconnect.controller.permissions.additionalaccess.AdditionalAccessViewModel 52 import com.android.healthconnect.controller.permissions.additionalaccess.DisableExerciseRoutePermissionDialog 53 import com.android.healthconnect.controller.permissions.app.AppPermissionViewModel.RevokeAllState 54 import com.android.healthconnect.controller.permissions.data.FitnessPermissionStrings.Companion.fromPermissionType 55 import com.android.healthconnect.controller.permissions.data.HealthPermission.FitnessPermission 56 import com.android.healthconnect.controller.permissions.data.PermissionsAccessType 57 import com.android.healthconnect.controller.permissions.shared.DisconnectDialogFragment 58 import com.android.healthconnect.controller.permissions.shared.DisconnectDialogFragment.Companion.DISCONNECT_ALL_EVENT 59 import com.android.healthconnect.controller.permissions.shared.DisconnectDialogFragment.Companion.DISCONNECT_CANCELED_EVENT 60 import com.android.healthconnect.controller.permissions.shared.DisconnectDialogFragment.Companion.KEY_DELETE_DATA 61 import com.android.healthconnect.controller.shared.Constants.EXTRA_APP_NAME 62 import com.android.healthconnect.controller.shared.HealthDataCategoryExtensions.fromHealthPermissionType 63 import com.android.healthconnect.controller.shared.HealthDataCategoryExtensions.icon 64 import com.android.healthconnect.controller.shared.HealthPermissionReader 65 import com.android.healthconnect.controller.shared.children 66 import com.android.healthconnect.controller.shared.preference.HealthMainSwitchPreference 67 import com.android.healthconnect.controller.shared.preference.HealthPreference 68 import com.android.healthconnect.controller.shared.preference.HealthPreferenceFragment 69 import com.android.healthconnect.controller.shared.preference.HealthSwitchPreference 70 import com.android.healthconnect.controller.utils.FeatureUtils 71 import com.android.healthconnect.controller.utils.LocalDateTimeFormatter 72 import com.android.healthconnect.controller.utils.dismissLoadingDialog 73 import com.android.healthconnect.controller.utils.logging.AppAccessElement 74 import com.android.healthconnect.controller.utils.logging.HealthConnectLogger 75 import com.android.healthconnect.controller.utils.logging.PageName 76 import com.android.healthconnect.controller.utils.pref 77 import com.android.healthconnect.controller.utils.showLoadingDialog 78 import com.android.settingslib.widget.AppHeaderPreference 79 import com.android.settingslib.widget.FooterPreference 80 import dagger.hilt.android.AndroidEntryPoint 81 import javax.inject.Inject 82 83 /** Fragment for connected app screen. */ 84 @AndroidEntryPoint(HealthPreferenceFragment::class) 85 class ConnectedAppFragment : Hilt_ConnectedAppFragment() { 86 87 companion object { 88 private const val PERMISSION_HEADER = "manage_app_permission_header" 89 private const val ALLOW_ALL_PREFERENCE = "allow_all_preference" 90 private const val READ_CATEGORY = "read_permission_category" 91 private const val WRITE_CATEGORY = "write_permission_category" 92 private const val MANAGE_DATA_PREFERENCE_KEY = "manage_app" 93 private const val FOOTER_KEY = "connected_app_footer" 94 private const val KEY_ADDITIONAL_ACCESS = "additional_access" 95 private const val DISABLE_EXERCISE_ROUTE_DIALOG_TAG = "disable_exercise_route" 96 private const val PARAGRAPH_SEPARATOR = "\n\n" 97 } 98 99 init { 100 this.setPageName(PageName.APP_ACCESS_PAGE) 101 } 102 103 @Inject lateinit var featureUtils: FeatureUtils 104 @Inject lateinit var logger: HealthConnectLogger 105 @Inject lateinit var healthPermissionReader: HealthPermissionReader 106 107 private var packageName: String = "" 108 private var appName: String = "" 109 private val appPermissionViewModel: AppPermissionViewModel by activityViewModels() 110 private val deletionViewModel: DeletionViewModel by activityViewModels() 111 private val additionalAccessViewModel: AdditionalAccessViewModel by activityViewModels() 112 private val permissionMap: MutableMap<FitnessPermission, HealthSwitchPreference> = 113 mutableMapOf() 114 115 private val header: AppHeaderPreference by pref(PERMISSION_HEADER) 116 private val allowAllPreference: HealthMainSwitchPreference by pref(ALLOW_ALL_PREFERENCE) 117 private val readPermissionCategory: PreferenceGroup by pref(READ_CATEGORY) 118 private val writePermissionCategory: PreferenceGroup by pref(WRITE_CATEGORY) 119 private val manageDataCategory: PreferenceGroup by pref(MANAGE_DATA_PREFERENCE_KEY) 120 private val connectedAppFooter: FooterPreference by pref(FOOTER_KEY) 121 private val dateFormatter by lazy { LocalDateTimeFormatter(requireContext()) } 122 123 override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { 124 super.onCreatePreferences(savedInstanceState, rootKey) 125 setPreferencesFromResource(R.xml.connected_app_screen, rootKey) 126 127 allowAllPreference.logNameActive = AppAccessElement.ALLOW_ALL_PERMISSIONS_SWITCH_ACTIVE 128 allowAllPreference.logNameInactive = AppAccessElement.ALLOW_ALL_PERMISSIONS_SWITCH_INACTIVE 129 130 if (childFragmentManager.findFragmentByTag(FRAGMENT_TAG_DELETION) == null) { 131 childFragmentManager.commitNow { add(DeletionFragment(), FRAGMENT_TAG_DELETION) } 132 } 133 } 134 135 override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 136 super.onViewCreated(view, savedInstanceState) 137 138 if (requireArguments().containsKey(EXTRA_PACKAGE_NAME) && 139 requireArguments().getString(EXTRA_PACKAGE_NAME) != null) { 140 packageName = requireArguments().getString(EXTRA_PACKAGE_NAME)!! 141 } 142 if (requireArguments().containsKey(EXTRA_APP_NAME) && 143 requireArguments().getString(EXTRA_APP_NAME) != null) { 144 appName = requireArguments().getString(EXTRA_APP_NAME)!! 145 } 146 147 appPermissionViewModel.loadPermissionsForPackage(packageName) 148 149 appPermissionViewModel.appPermissions.observe(viewLifecycleOwner) { permissions -> 150 updatePermissions(permissions) 151 } 152 appPermissionViewModel.grantedPermissions.observe(viewLifecycleOwner) { granted -> 153 permissionMap.forEach { (healthPermission, switchPreference) -> 154 switchPreference.isChecked = healthPermission in granted 155 } 156 } 157 appPermissionViewModel.lastReadPermissionDisconnected.observe(viewLifecycleOwner) { lastRead 158 -> 159 if (lastRead) { 160 Toast.makeText( 161 requireContext(), 162 R.string.removed_additional_permissions_toast, 163 Toast.LENGTH_LONG) 164 .show() 165 appPermissionViewModel.markLastReadShown() 166 } 167 } 168 169 deletionViewModel.appPermissionReloadNeeded.observe(viewLifecycleOwner) { isReloadNeeded -> 170 if (isReloadNeeded) appPermissionViewModel.loadPermissionsForPackage(packageName) 171 } 172 173 appPermissionViewModel.revokeAllPermissionsState.observe(viewLifecycleOwner) { state -> 174 when (state) { 175 is RevokeAllState.Loading -> { 176 showLoadingDialog() 177 } 178 else -> { 179 dismissLoadingDialog() 180 } 181 } 182 } 183 184 appPermissionViewModel.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(DISCONNECT_CANCELED_EVENT, this) { _, _ -> 192 allowAllPreference.isChecked = true 193 } 194 195 childFragmentManager.setFragmentResultListener(DISCONNECT_ALL_EVENT, this) { _, bundle -> 196 val permissionsUpdated = appPermissionViewModel.revokeAllPermissions(packageName) 197 if (!permissionsUpdated) { 198 Toast.makeText(requireContext(), R.string.default_error, Toast.LENGTH_SHORT).show() 199 } 200 if (bundle.containsKey(KEY_DELETE_DATA) && bundle.getBoolean(KEY_DELETE_DATA)) { 201 appPermissionViewModel.deleteAppData(packageName, appName) 202 } 203 } 204 205 setupAllowAllPreference() 206 setupManageDataPreferenceCategory() 207 setupHeader() 208 setupFooter() 209 } 210 211 private fun setupHeader() { 212 appPermissionViewModel.appInfo.observe(viewLifecycleOwner) { appMetadata -> 213 header.apply { 214 icon = appMetadata.icon 215 title = appMetadata.appName 216 } 217 } 218 } 219 220 private fun setupManageDataPreferenceCategory() { 221 manageDataCategory.removeAll() 222 if (featureUtils.isNewInformationArchitectureEnabled()) { 223 manageDataCategory.addPreference( 224 HealthPreference(requireContext()).also { 225 it.title = getString(R.string.see_app_data) 226 it.setOnPreferenceClickListener { 227 findNavController() 228 .navigate( 229 R.id.action_connectedApp_to_appData, 230 bundleOf( 231 EXTRA_PACKAGE_NAME to packageName, EXTRA_APP_NAME to appName)) 232 true 233 } 234 }) 235 } else { 236 manageDataCategory.addPreference( 237 HealthPreference(requireContext()).also { 238 it.logName = AppAccessElement.DELETE_APP_DATA_BUTTON 239 it.title = getString(R.string.delete_app_data) 240 it.setOnPreferenceClickListener { 241 val deletionType = DeletionType.DeletionTypeAppData(packageName, appName) 242 childFragmentManager.setFragmentResult( 243 START_DELETION_EVENT, bundleOf(DELETION_TYPE to deletionType)) 244 true 245 } 246 }) 247 } 248 additionalAccessViewModel.loadAdditionalAccessPreferences(packageName) 249 additionalAccessViewModel.additionalAccessState.observe(viewLifecycleOwner) { state -> 250 if (state.isValid() && shouldAddAdditionalAccessPref()) { 251 val additionalAccessPref = 252 HealthPreference(requireContext()).also { 253 it.key = KEY_ADDITIONAL_ACCESS 254 it.logName = AppAccessElement.ADDITIONAL_ACCESS_BUTTON 255 it.setTitle(R.string.additional_access_label) 256 it.setOnPreferenceClickListener { _ -> 257 val extras = bundleOf(EXTRA_PACKAGE_NAME to packageName) 258 findNavController() 259 .navigate( 260 R.id.action_connectedAppFragment_to_additionalAccessFragment, 261 extras) 262 true 263 } 264 } 265 manageDataCategory.addPreference(additionalAccessPref) 266 } 267 manageDataCategory.children.find { it.key == KEY_ADDITIONAL_ACCESS }?.isVisible = 268 state.isValid() 269 } 270 } 271 272 private fun shouldAddAdditionalAccessPref(): Boolean { 273 return manageDataCategory.children.none { it.key == KEY_ADDITIONAL_ACCESS } 274 } 275 276 private val onSwitchChangeListener = OnCheckedChangeListener { buttonView, isChecked -> 277 if (isChecked) { 278 val permissionsUpdated = appPermissionViewModel.grantAllPermissions(packageName) 279 if (!permissionsUpdated) { 280 buttonView.isChecked = false 281 Toast.makeText(requireContext(), R.string.default_error, Toast.LENGTH_SHORT).show() 282 } 283 } else { 284 showRevokeAllPermissions() 285 } 286 } 287 288 private fun setupAllowAllPreference() { 289 allowAllPreference.addOnSwitchChangeListener(onSwitchChangeListener) 290 appPermissionViewModel.allAppPermissionsGranted.observe(viewLifecycleOwner) { isAllGranted 291 -> 292 allowAllPreference.removeOnSwitchChangeListener(onSwitchChangeListener) 293 allowAllPreference.isChecked = isAllGranted 294 allowAllPreference.addOnSwitchChangeListener(onSwitchChangeListener) 295 } 296 } 297 298 private fun showRevokeAllPermissions() { 299 DisconnectDialogFragment(appName).show(childFragmentManager, DisconnectDialogFragment.TAG) 300 } 301 302 private fun updatePermissions(permissions: List<FitnessPermission>) { 303 readPermissionCategory.removeAll() 304 writePermissionCategory.removeAll() 305 permissionMap.clear() 306 307 permissions 308 .sortedBy { 309 requireContext() 310 .getString(fromPermissionType(it.healthPermissionType).uppercaseLabel) 311 } 312 .forEach { permission -> 313 val category = 314 if (permission.permissionsAccessType == PermissionsAccessType.READ) { 315 readPermissionCategory 316 } else { 317 writePermissionCategory 318 } 319 320 val preference = 321 HealthSwitchPreference(requireContext()).also { it -> 322 val healthCategory = 323 fromHealthPermissionType(permission.healthPermissionType) 324 it.icon = healthCategory.icon(requireContext()) 325 it.setTitle( 326 fromPermissionType(permission.healthPermissionType).uppercaseLabel) 327 it.logNameActive = AppAccessElement.PERMISSION_SWITCH_ACTIVE 328 it.logNameInactive = AppAccessElement.PERMISSION_SWITCH_INACTIVE 329 it.setOnPreferenceChangeListener { _, newValue -> 330 allowAllPreference.removeOnSwitchChangeListener(onSwitchChangeListener) 331 val checked = newValue as Boolean 332 val permissionUpdated = 333 appPermissionViewModel.updatePermission( 334 packageName, permission, checked) 335 if (!permissionUpdated) { 336 Toast.makeText( 337 requireContext(), 338 R.string.default_error, 339 Toast.LENGTH_SHORT) 340 .show() 341 } 342 allowAllPreference.addOnSwitchChangeListener(onSwitchChangeListener) 343 permissionUpdated 344 } 345 } 346 permissionMap[permission] = preference 347 category.addPreference(preference) 348 } 349 350 readPermissionCategory.apply { isVisible = (preferenceCount != 0) } 351 writePermissionCategory.apply { isVisible = (preferenceCount != 0) } 352 } 353 354 private fun setupFooter() { 355 appPermissionViewModel.atLeastOnePermissionGranted.observe(viewLifecycleOwner) { 356 isAtLeastOneGranted -> 357 updateFooter(isAtLeastOneGranted) 358 } 359 } 360 361 private fun updateFooter(isAtLeastOneGranted: Boolean) { 362 var title = 363 getString(R.string.other_android_permissions) + 364 PARAGRAPH_SEPARATOR + 365 getString(R.string.manage_permissions_rationale, appName) 366 var contentDescription = 367 getString(R.string.other_android_permissions_content_description) + 368 PARAGRAPH_SEPARATOR + 369 getString(R.string.manage_permissions_rationale, appName) 370 371 val isHistoryReadAvailable = 372 additionalAccessViewModel.additionalAccessState.value?.historyReadUIState?.isDeclared 373 ?: false 374 // Do not show the access date here if history read is available 375 if (isAtLeastOneGranted && !isHistoryReadAvailable) { 376 val dataAccessDate = appPermissionViewModel.loadAccessDate(packageName) 377 dataAccessDate?.let { 378 val formattedDate = dateFormatter.formatLongDate(dataAccessDate) 379 val paragraph = 380 getString(R.string.manage_permissions_time_frame, appName, formattedDate) 381 title = paragraph + PARAGRAPH_SEPARATOR + title 382 contentDescription = paragraph + PARAGRAPH_SEPARATOR + contentDescription 383 } 384 } 385 386 connectedAppFooter.title = title 387 connectedAppFooter.setContentDescription(contentDescription) 388 if (healthPermissionReader.isRationaleIntentDeclared(packageName)) { 389 connectedAppFooter.setLearnMoreText(getString(R.string.manage_permissions_learn_more)) 390 logger.logImpression(AppAccessElement.PRIVACY_POLICY_LINK) 391 connectedAppFooter.setLearnMoreAction { 392 logger.logInteraction(AppAccessElement.PRIVACY_POLICY_LINK) 393 val startRationaleIntent = 394 healthPermissionReader.getApplicationRationaleIntent(packageName) 395 startActivity(startRationaleIntent) 396 } 397 } 398 } 399 } 400