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