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