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 * ``` 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * ``` 10 * 11 * Unless required by applicable law or agreed to in writing, software distributed under the License 12 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 13 * or implied. See the License for the specific language governing permissions and limitations under 14 * the License. 15 */ 16 package com.android.healthconnect.controller.permissions.connectedapps 17 18 import android.content.Context 19 import android.content.Intent 20 import android.content.Intent.EXTRA_PACKAGE_NAME 21 import android.os.Bundle 22 import android.view.MenuItem 23 import android.view.View 24 import android.view.View.GONE 25 import android.widget.Toast 26 import androidx.annotation.StringRes 27 import androidx.appcompat.app.AlertDialog 28 import androidx.core.os.bundleOf 29 import androidx.fragment.app.commitNow 30 import androidx.fragment.app.viewModels 31 import androidx.navigation.fragment.findNavController 32 import androidx.preference.Preference 33 import androidx.preference.PreferenceGroup 34 import com.android.healthconnect.controller.R 35 import com.android.healthconnect.controller.deletion.DeletionConstants 36 import com.android.healthconnect.controller.deletion.DeletionConstants.DELETION_TYPE 37 import com.android.healthconnect.controller.deletion.DeletionConstants.FRAGMENT_TAG_DELETION 38 import com.android.healthconnect.controller.deletion.DeletionFragment 39 import com.android.healthconnect.controller.deletion.DeletionType 40 import com.android.healthconnect.controller.permissions.connectedapps.ConnectedAppsViewModel.DisconnectAllState 41 import com.android.healthconnect.controller.permissions.shared.HelpAndFeedbackFragment.Companion.APP_INTEGRATION_REQUEST_BUCKET_ID 42 import com.android.healthconnect.controller.permissions.shared.HelpAndFeedbackFragment.Companion.FEEDBACK_INTENT_RESULT_CODE 43 import com.android.healthconnect.controller.shared.Constants.APP_UPDATE_NEEDED_BANNER_SEEN 44 import com.android.healthconnect.controller.shared.Constants.EXTRA_APP_NAME 45 import com.android.healthconnect.controller.shared.Constants.USER_ACTIVITY_TRACKER 46 import com.android.healthconnect.controller.shared.app.ConnectedAppMetadata 47 import com.android.healthconnect.controller.shared.app.ConnectedAppStatus.ALLOWED 48 import com.android.healthconnect.controller.shared.app.ConnectedAppStatus.DENIED 49 import com.android.healthconnect.controller.shared.app.ConnectedAppStatus.INACTIVE 50 import com.android.healthconnect.controller.shared.app.ConnectedAppStatus.NEEDS_UPDATE 51 import com.android.healthconnect.controller.shared.dialog.AlertDialogBuilder 52 import com.android.healthconnect.controller.shared.inactiveapp.InactiveAppPreference 53 import com.android.healthconnect.controller.shared.preference.BannerPreference 54 import com.android.healthconnect.controller.shared.preference.HealthPreference 55 import com.android.healthconnect.controller.shared.preference.HealthPreferenceFragment 56 import com.android.healthconnect.controller.utils.AppStoreUtils 57 import com.android.healthconnect.controller.utils.AttributeResolver 58 import com.android.healthconnect.controller.utils.DeviceInfoUtils 59 import com.android.healthconnect.controller.utils.NavigationUtils 60 import com.android.healthconnect.controller.utils.dismissLoadingDialog 61 import com.android.healthconnect.controller.utils.logging.AppPermissionsElement 62 import com.android.healthconnect.controller.utils.logging.DisconnectAllAppsDialogElement 63 import com.android.healthconnect.controller.utils.logging.HealthConnectLogger 64 import com.android.healthconnect.controller.utils.logging.MigrationElement 65 import com.android.healthconnect.controller.utils.logging.PageName 66 import com.android.healthconnect.controller.utils.setupMenu 67 import com.android.healthconnect.controller.utils.setupSharedMenu 68 import com.android.healthconnect.controller.utils.showLoadingDialog 69 import com.android.settingslib.widget.AppPreference 70 import com.android.settingslib.widget.TopIntroPreference 71 import dagger.hilt.android.AndroidEntryPoint 72 import javax.inject.Inject 73 74 /** Fragment for connected apps screen. */ 75 @AndroidEntryPoint(HealthPreferenceFragment::class) 76 class ConnectedAppsFragment : Hilt_ConnectedAppsFragment() { 77 78 companion object { 79 private const val TOP_INTRO = "connected_apps_top_intro" 80 const val ALLOWED_APPS_CATEGORY = "allowed_apps" 81 private const val NOT_ALLOWED_APPS = "not_allowed_apps" 82 private const val INACTIVE_APPS = "inactive_apps" 83 private const val NEED_UPDATE_APPS = "need_update_apps" 84 private const val THINGS_TO_TRY = "things_to_try_app_permissions_screen" 85 private const val SETTINGS_AND_HELP = "settings_and_help" 86 private const val BANNER_PREFERENCE_KEY = "banner_preference" 87 } 88 89 init { 90 this.setPageName(PageName.APP_PERMISSIONS_PAGE) 91 } 92 93 @Inject lateinit var logger: HealthConnectLogger 94 @Inject lateinit var appStoreUtils: AppStoreUtils 95 @Inject lateinit var deviceInfoUtils: DeviceInfoUtils 96 @Inject lateinit var navigationUtils: NavigationUtils 97 98 private val viewModel: ConnectedAppsViewModel by viewModels() 99 private lateinit var searchMenuItem: MenuItem 100 private lateinit var removeAllAppsDialog: AlertDialog 101 102 private val mTopIntro: TopIntroPreference by lazy { 103 preferenceScreen.findPreference(TOP_INTRO)!! 104 } 105 106 private val mAllowedAppsCategory: PreferenceGroup by lazy { 107 preferenceScreen.findPreference(ALLOWED_APPS_CATEGORY)!! 108 } 109 110 private val mNotAllowedAppsCategory: PreferenceGroup by lazy { 111 preferenceScreen.findPreference(NOT_ALLOWED_APPS)!! 112 } 113 114 private val mInactiveAppsCategory: PreferenceGroup by lazy { 115 preferenceScreen.findPreference(INACTIVE_APPS)!! 116 } 117 118 private val mNeedUpdateAppsCategory: PreferenceGroup? by lazy { 119 preferenceScreen.findPreference(NEED_UPDATE_APPS)!! 120 } 121 122 private val mThingsToTryCategory: PreferenceGroup by lazy { 123 preferenceScreen.findPreference(THINGS_TO_TRY)!! 124 } 125 126 private val mSettingsAndHelpCategory: PreferenceGroup by lazy { 127 preferenceScreen.findPreference(SETTINGS_AND_HELP)!! 128 } 129 130 override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { 131 super.onCreatePreferences(savedInstanceState, rootKey) 132 setPreferencesFromResource(R.xml.connected_apps_screen, rootKey) 133 134 if (childFragmentManager.findFragmentByTag(FRAGMENT_TAG_DELETION) == null) { 135 childFragmentManager.commitNow { add(DeletionFragment(), FRAGMENT_TAG_DELETION) } 136 } 137 } 138 139 private fun createRemoveAllAppsAccessDialog(apps: List<ConnectedAppMetadata>) { 140 removeAllAppsDialog = 141 AlertDialogBuilder( 142 this, DisconnectAllAppsDialogElement.DISCONNECT_ALL_APPS_DIALOG_CONTAINER) 143 .setIcon(R.attr.disconnectAllIcon) 144 .setTitle(R.string.permissions_disconnect_all_dialog_title) 145 .setMessage(R.string.permissions_disconnect_all_dialog_message) 146 .setCancelable(false) 147 .setNeutralButton( 148 android.R.string.cancel, 149 DisconnectAllAppsDialogElement.DISCONNECT_ALL_APPS_DIALOG_CANCEL_BUTTON) { _, _ 150 -> 151 viewModel.setAlertDialogStatus(false) 152 } 153 .setPositiveButton( 154 R.string.permissions_disconnect_all_dialog_disconnect, 155 DisconnectAllAppsDialogElement.DISCONNECT_ALL_APPS_DIALOG_REMOVE_ALL_BUTTON) { 156 _, 157 _ -> 158 if (!viewModel.disconnectAllApps(apps)) { 159 Toast.makeText( 160 requireContext(), R.string.default_error, Toast.LENGTH_SHORT) 161 .show() 162 } 163 } 164 .create() 165 } 166 167 override fun onResume() { 168 super.onResume() 169 viewModel.loadConnectedApps() 170 } 171 172 override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 173 super.onViewCreated(view, savedInstanceState) 174 observeConnectedApps() 175 observeRevokeAllAppsPermissions() 176 } 177 178 private fun observeRevokeAllAppsPermissions() { 179 viewModel.disconnectAllState.observe(viewLifecycleOwner) { state -> 180 when (state) { 181 is DisconnectAllState.Loading -> { 182 showLoadingDialog() 183 } 184 else -> { 185 dismissLoadingDialog() 186 } 187 } 188 } 189 } 190 191 private fun observeConnectedApps() { 192 viewModel.connectedApps.observe(viewLifecycleOwner) { connectedApps -> 193 clearAllCategories() 194 if (connectedApps.isEmpty()) { 195 setupSharedMenu(viewLifecycleOwner, logger) 196 setUpEmptyState() 197 } else { 198 setupMenu(R.menu.connected_apps, viewLifecycleOwner, logger) { menuItem -> 199 when (menuItem.itemId) { 200 R.id.menu_search -> { 201 searchMenuItem = menuItem 202 logger.logInteraction(AppPermissionsElement.SEARCH_BUTTON) 203 findNavController().navigate(R.id.action_connectedApps_to_searchApps) 204 true 205 } 206 else -> false 207 } 208 } 209 logger.logImpression(AppPermissionsElement.SEARCH_BUTTON) 210 211 mTopIntro?.title = getString(R.string.connected_apps_text) 212 mThingsToTryCategory?.isVisible = false 213 setAppAndSettingsCategoriesVisibility(true) 214 215 val connectedAppsGroup = connectedApps.groupBy { it.status } 216 val allowedApps = connectedAppsGroup[ALLOWED].orEmpty() 217 val notAllowedApps = connectedAppsGroup[DENIED].orEmpty() 218 val needUpdateApps = connectedAppsGroup[NEEDS_UPDATE].orEmpty() 219 val activeApps: MutableList<ConnectedAppMetadata> = allowedApps.toMutableList() 220 activeApps.addAll(notAllowedApps) 221 createRemoveAllAppsAccessDialog(activeApps) 222 223 mSettingsAndHelpCategory?.addPreference( 224 getRemoveAccessForAllAppsPreference().apply { 225 isEnabled = allowedApps.isNotEmpty() 226 setOnPreferenceClickListener { 227 viewModel.setAlertDialogStatus(true) 228 true 229 } 230 }) 231 232 if (deviceInfoUtils.isPlayStoreAvailable(requireContext()) || 233 deviceInfoUtils.isSendFeedbackAvailable(requireContext())) { 234 mSettingsAndHelpCategory?.addPreference(getHelpAndFeedbackPreference()) 235 } 236 237 updateAllowedApps(allowedApps) 238 updateDeniedApps(notAllowedApps) 239 updateInactiveApps(connectedAppsGroup[INACTIVE].orEmpty()) 240 updateNeedUpdateApps(needUpdateApps) 241 242 viewModel.alertDialogActive.observe(viewLifecycleOwner) { state -> 243 if (state) { 244 removeAllAppsDialog.show() 245 } else { 246 if (removeAllAppsDialog.isShowing) { 247 removeAllAppsDialog.dismiss() 248 } 249 } 250 } 251 } 252 } 253 } 254 255 private fun updateInactiveApps(appsList: List<ConnectedAppMetadata>) { 256 if (appsList.isEmpty()) { 257 preferenceScreen.removePreference(mInactiveAppsCategory) 258 } else { 259 appsList.forEach { app -> 260 val inactiveAppPreference = 261 InactiveAppPreference(requireContext()).also { 262 it.title = app.appMetadata.appName 263 it.icon = app.appMetadata.icon 264 it.logName = AppPermissionsElement.INACTIVE_APP_BUTTON 265 it.setOnDeleteButtonClickListener { 266 val appDeletionType = 267 DeletionType.DeletionTypeAppData( 268 app.appMetadata.packageName, app.appMetadata.appName) 269 childFragmentManager.setFragmentResult( 270 DeletionConstants.START_INACTIVE_APP_DELETION_EVENT, 271 bundleOf(DELETION_TYPE to appDeletionType)) 272 } 273 } 274 mInactiveAppsCategory?.addPreference(inactiveAppPreference) 275 } 276 } 277 } 278 279 private fun updateNeedUpdateApps(appsList: List<ConnectedAppMetadata>) { 280 if (appsList.isEmpty()) { 281 mNeedUpdateAppsCategory?.isVisible = false 282 } else { 283 mNeedUpdateAppsCategory?.isVisible = true 284 appsList.forEach { app -> 285 val intent = appStoreUtils.getAppStoreLink(app.appMetadata.packageName) 286 if (intent == null) { 287 mNeedUpdateAppsCategory?.addPreference( 288 getAppPreference(app).also { it.isSelectable = false }) 289 } else { 290 mNeedUpdateAppsCategory?.addPreference( 291 getAppPreference(app) { navigationUtils.startActivity(this, intent) }) 292 } 293 } 294 295 val sharedPreference = 296 requireActivity().getSharedPreferences(USER_ACTIVITY_TRACKER, Context.MODE_PRIVATE) 297 val bannerSeen = sharedPreference.getBoolean(APP_UPDATE_NEEDED_BANNER_SEEN, false) 298 299 if (!bannerSeen) { 300 val banner = getAppUpdateNeededBanner(appsList) 301 preferenceScreen.removePreferenceRecursively(BANNER_PREFERENCE_KEY) 302 preferenceScreen.addPreference(banner) 303 } 304 } 305 } 306 307 private fun updateAllowedApps(appsList: List<ConnectedAppMetadata>) { 308 if (appsList.isEmpty()) { 309 mAllowedAppsCategory?.addPreference(getNoAppsPreference(R.string.no_apps_allowed)) 310 } else { 311 appsList.forEach { app -> 312 mAllowedAppsCategory?.addPreference( 313 getAppPreference(app) { navigateToAppInfoScreen(app) }) 314 } 315 } 316 } 317 318 private fun updateDeniedApps(appsList: List<ConnectedAppMetadata>) { 319 if (appsList.isEmpty()) { 320 mNotAllowedAppsCategory?.addPreference(getNoAppsPreference(R.string.no_apps_denied)) 321 } else { 322 appsList.forEach { app -> 323 mNotAllowedAppsCategory?.addPreference( 324 getAppPreference(app) { navigateToAppInfoScreen(app) }) 325 } 326 } 327 } 328 329 private fun navigateToAppInfoScreen(app: ConnectedAppMetadata) { 330 findNavController() 331 .navigate( 332 R.id.action_connectedApps_to_connectedApp, 333 bundleOf( 334 EXTRA_PACKAGE_NAME to app.appMetadata.packageName, 335 EXTRA_APP_NAME to app.appMetadata.appName)) 336 } 337 338 private fun getNoAppsPreference(@StringRes res: Int): Preference { 339 return Preference(requireContext()).also { 340 it.setTitle(res) 341 it.isSelectable = false 342 } 343 } 344 345 private fun getAppPreference( 346 app: ConnectedAppMetadata, 347 onClick: (() -> Unit)? = null 348 ): AppPreference { 349 return HealthAppPreference(requireContext(), app.appMetadata).also { 350 if (app.status == ALLOWED) { 351 it.logName = AppPermissionsElement.CONNECTED_APP_BUTTON 352 } else if (app.status == DENIED) { 353 it.logName = AppPermissionsElement.NOT_CONNECTED_APP_BUTTON 354 } else if (app.status == NEEDS_UPDATE) { 355 it.logName = AppPermissionsElement.NEEDS_UPDATE_APP_BUTTON 356 } 357 it.setOnPreferenceClickListener { 358 onClick?.invoke() 359 true 360 } 361 } 362 } 363 364 private fun getRemoveAccessForAllAppsPreference(): HealthPreference { 365 return HealthPreference(requireContext()).also { 366 it.title = resources.getString(R.string.disconnect_all_apps) 367 it.icon = 368 AttributeResolver.getDrawable(requireContext(), R.attr.removeAccessForAllAppsIcon) 369 it.logName = AppPermissionsElement.REMOVE_ALL_APPS_PERMISSIONS_BUTTON 370 } 371 } 372 373 private fun getHelpAndFeedbackPreference(): HealthPreference { 374 return HealthPreference(requireContext()).also { 375 it.title = resources.getString(R.string.help_and_feedback) 376 it.icon = AttributeResolver.getDrawable(requireContext(), R.attr.helpAndFeedbackIcon) 377 it.logName = AppPermissionsElement.HELP_AND_FEEDBACK_BUTTON 378 it.setOnPreferenceClickListener { 379 findNavController().navigate(R.id.action_connectedApps_to_helpAndFeedback) 380 true 381 } 382 } 383 } 384 385 private fun getCheckForUpdatesPreference(): HealthPreference { 386 return HealthPreference(requireContext()).also { 387 it.title = resources.getString(R.string.check_for_updates) 388 it.icon = AttributeResolver.getDrawable(requireContext(), R.attr.checkForUpdatesIcon) 389 it.summary = resources.getString(R.string.check_for_updates_description) 390 it.logName = AppPermissionsElement.CHECK_FOR_UPDATES_BUTTON 391 it.setOnPreferenceClickListener { 392 findNavController().navigate(R.id.action_connected_apps_to_updated_apps) 393 true 394 } 395 } 396 } 397 398 private fun getSeeAllCompatibleAppsPreference(): HealthPreference { 399 return HealthPreference(requireContext()).also { 400 it.title = resources.getString(R.string.see_all_compatible_apps) 401 it.icon = 402 AttributeResolver.getDrawable(requireContext(), R.attr.seeAllCompatibleAppsIcon) 403 it.summary = resources.getString(R.string.see_all_compatible_apps_description) 404 it.logName = AppPermissionsElement.SEE_ALL_COMPATIBLE_APPS_BUTTON 405 it.setOnPreferenceClickListener { 406 findNavController().navigate(R.id.action_connected_apps_to_play_store) 407 true 408 } 409 } 410 } 411 412 private fun getSendFeedbackPreference(): Preference { 413 return HealthPreference(requireContext()).also { 414 it.title = resources.getString(R.string.send_feedback) 415 it.icon = AttributeResolver.getDrawable(requireContext(), R.attr.sendFeedbackIcon) 416 it.summary = resources.getString(R.string.send_feedback_description) 417 it.logName = AppPermissionsElement.SEND_FEEDBACK_BUTTON 418 it.setOnPreferenceClickListener { 419 val intent = Intent(Intent.ACTION_BUG_REPORT) 420 intent.putExtra("category_tag", APP_INTEGRATION_REQUEST_BUCKET_ID) 421 activity?.startActivityForResult(intent, FEEDBACK_INTENT_RESULT_CODE) 422 true 423 } 424 } 425 } 426 427 private fun getAppUpdateNeededBanner(appsList: List<ConnectedAppMetadata>): BannerPreference { 428 return BannerPreference(requireContext(), MigrationElement.MIGRATION_APP_UPDATE_BANNER) 429 .also { banner -> 430 banner.setPrimaryButton( 431 resources.getString(R.string.app_update_needed_banner_button), 432 MigrationElement.MIGRATION_APP_UPDATE_BUTTON) 433 banner.setSecondaryButton( 434 resources.getString(R.string.app_update_needed_banner_learn_more_button), 435 MigrationElement.MIGRATION_APP_UPDATE_LEARN_MORE_BUTTON) 436 banner.title = resources.getString(R.string.app_update_needed_banner_title) 437 438 if (appsList.size > 1) { 439 banner.summary = 440 resources.getString(R.string.app_update_needed_banner_description_multiple) 441 } else { 442 banner.summary = 443 resources.getString( 444 R.string.app_update_needed_banner_description_single, 445 appsList[0].appMetadata.appName) 446 } 447 448 banner.key = BANNER_PREFERENCE_KEY 449 banner.setIcon(R.drawable.ic_apps_outage) 450 banner.order = 1 451 if (deviceInfoUtils.isPlayStoreAvailable(requireContext())) { 452 banner.setPrimaryButtonOnClickListener { 453 findNavController().navigate(R.id.action_connected_apps_to_updated_apps) 454 true 455 } 456 } else { 457 banner.setPrimaryButtonVisibility(GONE) 458 } 459 460 banner.setSecondaryButtonOnClickListener { 461 deviceInfoUtils.openHCGetStartedLink(requireActivity()) 462 } 463 banner.setIsDismissable(true) 464 banner.setDismissAction( 465 MigrationElement.MIGRATION_APP_UPDATE_BANNER_DISMISS_BUTTON) { 466 val sharedPreference = 467 requireActivity() 468 .getSharedPreferences(USER_ACTIVITY_TRACKER, Context.MODE_PRIVATE) 469 sharedPreference.edit().apply { 470 putBoolean(APP_UPDATE_NEEDED_BANNER_SEEN, true) 471 apply() 472 } 473 preferenceScreen.removePreference(banner) 474 } 475 } 476 } 477 478 private fun setUpEmptyState() { 479 mTopIntro?.title = getString(R.string.connected_apps_empty_list_section_title) 480 if (deviceInfoUtils.isPlayStoreAvailable(requireContext()) || 481 deviceInfoUtils.isSendFeedbackAvailable(requireContext())) { 482 mThingsToTryCategory?.isVisible = true 483 } 484 if (deviceInfoUtils.isPlayStoreAvailable(requireContext())) { 485 mThingsToTryCategory?.addPreference(getCheckForUpdatesPreference()) 486 mThingsToTryCategory?.addPreference(getSeeAllCompatibleAppsPreference()) 487 } 488 if (deviceInfoUtils.isSendFeedbackAvailable(requireContext())) { 489 mThingsToTryCategory?.addPreference(getSendFeedbackPreference()) 490 } 491 setAppAndSettingsCategoriesVisibility(false) 492 } 493 494 private fun setAppAndSettingsCategoriesVisibility(isVisible: Boolean) { 495 mInactiveAppsCategory?.isVisible = isVisible 496 mAllowedAppsCategory?.isVisible = isVisible 497 mNeedUpdateAppsCategory?.isVisible = isVisible 498 mNotAllowedAppsCategory?.isVisible = isVisible 499 mSettingsAndHelpCategory?.isVisible = isVisible 500 } 501 502 private fun clearAllCategories() { 503 mThingsToTryCategory?.removeAll() 504 mAllowedAppsCategory?.removeAll() 505 mNotAllowedAppsCategory?.removeAll() 506 mNeedUpdateAppsCategory?.removeAll() 507 mInactiveAppsCategory?.removeAll() 508 mSettingsAndHelpCategory?.removeAll() 509 } 510 } 511