• Home
  • History
  • Annotate
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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