1 /** <lambda>null2 * Copyright (C) 2023 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 * http://www.apache.org/licenses/LICENSE-2.0 8 * 9 * Unless required by applicable law or agreed to in writing, software distributed under the License 10 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 11 * or implied. See the License for the specific language governing permissions and limitations under 12 * the License. 13 */ 14 package com.android.healthconnect.controller.datasources 15 16 import android.health.connect.HealthDataCategory 17 import android.os.Bundle 18 import android.view.MenuItem 19 import android.view.View 20 import android.widget.AdapterView 21 import androidx.annotation.VisibleForTesting 22 import androidx.core.os.bundleOf 23 import androidx.fragment.app.activityViewModels 24 import androidx.navigation.fragment.findNavController 25 import androidx.preference.PreferenceGroup 26 import com.android.healthconnect.controller.R 27 import com.android.healthconnect.controller.categories.HealthDataCategoriesFragment.Companion.CATEGORY_KEY 28 import com.android.healthconnect.controller.datasources.DataSourcesViewModel.AggregationCardsState 29 import com.android.healthconnect.controller.datasources.DataSourcesViewModel.PotentialAppSourcesState 30 import com.android.healthconnect.controller.datasources.DataSourcesViewModel.PriorityListState 31 import com.android.healthconnect.controller.datasources.appsources.AppSourcesAdapter 32 import com.android.healthconnect.controller.datasources.appsources.AppSourcesPreference 33 import com.android.healthconnect.controller.shared.HealthDataCategoryExtensions.lowercaseTitle 34 import com.android.healthconnect.controller.shared.HealthDataCategoryExtensions.uppercaseTitle 35 import com.android.healthconnect.controller.shared.HealthDataCategoryInt 36 import com.android.healthconnect.controller.shared.app.AppMetadata 37 import com.android.healthconnect.controller.shared.app.AppUtils 38 import com.android.healthconnect.controller.shared.preference.CardContainerPreference 39 import com.android.healthconnect.controller.shared.preference.HeaderPreference 40 import com.android.healthconnect.controller.shared.preference.HealthPreference 41 import com.android.healthconnect.controller.shared.preference.HealthPreferenceFragment 42 import com.android.healthconnect.controller.utils.AttributeResolver 43 import com.android.healthconnect.controller.utils.DeviceInfoUtilsImpl 44 import com.android.healthconnect.controller.utils.TimeSource 45 import com.android.healthconnect.controller.utils.logging.DataSourcesElement 46 import com.android.healthconnect.controller.utils.logging.HealthConnectLogger 47 import com.android.healthconnect.controller.utils.logging.PageName 48 import com.android.healthconnect.controller.utils.setupMenu 49 import com.android.healthconnect.controller.utils.setupSharedMenu 50 import com.android.settingslib.widget.FooterPreference 51 import com.android.settingslib.widget.SettingsSpinnerAdapter 52 import com.android.settingslib.widget.SettingsSpinnerPreference 53 import dagger.hilt.android.AndroidEntryPoint 54 import javax.inject.Inject 55 56 @AndroidEntryPoint(HealthPreferenceFragment::class) 57 class DataSourcesFragment : 58 Hilt_DataSourcesFragment(), AppSourcesAdapter.OnAppRemovedFromPriorityListListener { 59 60 companion object { 61 private const val DATA_TYPE_SPINNER_PREFERENCE_GROUP = "data_type_spinner_group" 62 private const val DATA_TOTALS_PREFERENCE_GROUP = "data_totals_group" 63 private const val DATA_TOTALS_PREFERENCE_KEY = "data_totals_preference" 64 private const val APP_SOURCES_PREFERENCE_GROUP = "app_sources_group" 65 private const val APP_SOURCES_PREFERENCE_KEY = "app_sources" 66 private const val ADD_AN_APP_PREFERENCE_KEY = "add_an_app" 67 private const val NON_EMPTY_FOOTER_PREFERENCE_KEY = "data_sources_footer" 68 private const val EMPTY_STATE_HEADER_PREFERENCE_KEY = "empty_state_header" 69 private const val EMPTY_STATE_FOOTER_PREFERENCE_KEY = "empty_state_footer" 70 private const val IS_EDIT_MODE = "is_edit_mode" 71 72 private val dataSourcesCategories = 73 arrayListOf(HealthDataCategory.ACTIVITY, HealthDataCategory.SLEEP) 74 } 75 76 init { 77 this.setPageName(PageName.DATA_SOURCES_PAGE) 78 } 79 80 @Inject lateinit var logger: HealthConnectLogger 81 @Inject lateinit var appUtils: AppUtils 82 private var isEditMode = false 83 84 private val dataSourcesViewModel: DataSourcesViewModel by activityViewModels() 85 private lateinit var spinnerPreference: SettingsSpinnerPreference 86 private lateinit var dataSourcesCategoriesStrings: List<String> 87 private var currentCategorySelection: @HealthDataCategoryInt Int = HealthDataCategory.ACTIVITY 88 @Inject lateinit var timeSource: TimeSource 89 90 private val dataTypeSpinnerPreferenceGroup: PreferenceGroup? by lazy { 91 preferenceScreen.findPreference(DATA_TYPE_SPINNER_PREFERENCE_GROUP) 92 } 93 94 private val dataTotalsPreferenceGroup: PreferenceGroup? by lazy { 95 preferenceScreen.findPreference(DATA_TOTALS_PREFERENCE_GROUP) 96 } 97 98 private val appSourcesPreferenceGroup: PreferenceGroup? by lazy { 99 preferenceScreen.findPreference(APP_SOURCES_PREFERENCE_GROUP) 100 } 101 102 private val nonEmptyFooterPreference: FooterPreference? by lazy { 103 preferenceScreen.findPreference(NON_EMPTY_FOOTER_PREFERENCE_KEY) 104 } 105 106 private val onEditMenuItemSelected: (MenuItem) -> Boolean = { menuItem -> 107 when (menuItem.itemId) { 108 R.id.menu_edit -> { 109 editPriorityList() 110 true 111 } 112 else -> false 113 } 114 } 115 116 private var cardContainerPreference: CardContainerPreference? = null 117 118 override fun onAppRemovedFromPriorityList() { 119 exitEditMode() 120 } 121 122 override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { 123 super.onCreatePreferences(savedInstanceState, rootKey) 124 setPreferencesFromResource(R.xml.data_sources_and_priority_screen, rootKey) 125 dataSourcesCategoriesStrings = 126 dataSourcesCategories.map { category -> getString(category.uppercaseTitle()) } 127 128 if (requireArguments().containsKey(CATEGORY_KEY) && savedInstanceState == null) { 129 130 // Only require this from the HealthPermissionTypes screen 131 // When navigating here from the Manage Data screen we pass Unknown 132 // so that going back and forth to this screen does not restrict users to just one 133 // category 134 val argCategory = requireArguments().getInt(CATEGORY_KEY) 135 if (argCategory != HealthDataCategory.UNKNOWN) { 136 currentCategorySelection = argCategory 137 dataSourcesViewModel.setCurrentSelection(currentCategorySelection) 138 } 139 } 140 141 setupSpinnerPreference() 142 } 143 144 override fun onSaveInstanceState(outState: Bundle) { 145 super.onSaveInstanceState(outState) 146 outState.putBoolean(IS_EDIT_MODE, isEditMode) 147 } 148 149 override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 150 super.onViewCreated(view, savedInstanceState) 151 152 savedInstanceState?.let { bundle -> 153 val savedIsEditMode = bundle.getBoolean(IS_EDIT_MODE, false) 154 isEditMode = savedIsEditMode 155 } 156 157 setLoading(true) 158 val currentStringSelection = spinnerPreference.selectedItem 159 currentCategorySelection = 160 dataSourcesCategories[dataSourcesCategoriesStrings.indexOf(currentStringSelection)] 161 dataSourcesViewModel.loadData(currentCategorySelection) 162 163 dataSourcesViewModel.dataSourcesAndAggregationsInfo.observe(viewLifecycleOwner) { 164 dataSourcesInfo -> 165 if (dataSourcesInfo.isLoading()) { 166 setLoading(true) 167 } else if (dataSourcesInfo.isLoadingFailed()) { 168 setLoading(false) 169 setError(true) 170 } else if (dataSourcesInfo.isWithData()) { 171 setLoading(false) 172 173 val priorityList = 174 (dataSourcesInfo.priorityListState as PriorityListState.WithData).priorityList 175 val potentialAppSources = 176 (dataSourcesInfo.potentialAppSourcesState as PotentialAppSourcesState.WithData) 177 .appSources 178 val cardInfos = 179 (dataSourcesInfo.aggregationCardsState as AggregationCardsState.WithData) 180 .dataTotals 181 182 if (priorityList.isEmpty() && potentialAppSources.isEmpty()) { 183 addEmptyState() 184 } else { 185 updateMenu(priorityList.size > 1 && !isEditMode) 186 updateAppSourcesSection(priorityList, potentialAppSources) 187 updateDataTotalsSection(cardInfos) 188 } 189 } 190 } 191 192 dataSourcesViewModel.updatedAggregationCardsData.observe(viewLifecycleOwner) { 193 aggregationCardsData -> 194 when (aggregationCardsData) { 195 is AggregationCardsState.Loading -> { 196 updateAggregations(listOf(), true) 197 } 198 is AggregationCardsState.LoadingFailed -> { 199 updateDataTotalsSection(listOf()) 200 } 201 is AggregationCardsState.WithData -> { 202 updateAggregations(aggregationCardsData.dataTotals, false) 203 } 204 } 205 } 206 } 207 208 override fun onResume() { 209 super.onResume() 210 dataSourcesViewModel.loadData(currentCategorySelection) 211 } 212 213 private fun updateMenu(shouldShowEditButton: Boolean) { 214 if (shouldShowEditButton) { 215 setupMenu(R.menu.data_sources, viewLifecycleOwner, logger, onEditMenuItemSelected) 216 } else { 217 setupSharedMenu(viewLifecycleOwner, logger) 218 } 219 } 220 221 @VisibleForTesting 222 fun editPriorityList() { 223 isEditMode = true 224 updateMenu(shouldShowEditButton = false) 225 appSourcesPreferenceGroup?.removePreferenceRecursively(ADD_AN_APP_PREFERENCE_KEY) 226 val appSourcesPreference = 227 preferenceScreen?.findPreference<AppSourcesPreference>(APP_SOURCES_PREFERENCE_KEY) 228 appSourcesPreference?.toggleEditMode(true) 229 } 230 231 private fun exitEditMode() { 232 appSourcesPreferenceGroup 233 ?.findPreference<AppSourcesPreference>(APP_SOURCES_PREFERENCE_KEY) 234 ?.toggleEditMode(false) 235 updateMenu(dataSourcesViewModel.getEditedPriorityList().size > 1) 236 updateAddApp(dataSourcesViewModel.getEditedPotentialAppSources().isNotEmpty()) 237 isEditMode = false 238 } 239 240 /** Updates the priority list preference. */ 241 private fun updateAppSourcesSection( 242 priorityList: List<AppMetadata>, 243 potentialAppSources: List<AppMetadata> 244 ) { 245 removeEmptyState() 246 appSourcesPreferenceGroup?.isVisible = true 247 appSourcesPreferenceGroup?.removePreferenceRecursively(APP_SOURCES_PREFERENCE_KEY) 248 249 dataSourcesViewModel.setEditedPriorityList(priorityList) 250 appSourcesPreferenceGroup?.addPreference( 251 AppSourcesPreference( 252 requireContext(), 253 appUtils, 254 dataSourcesViewModel, 255 currentCategorySelection, 256 this) 257 .also { 258 it.key = APP_SOURCES_PREFERENCE_KEY 259 it.setEditMode(isEditMode) 260 }) 261 262 updateAddApp(potentialAppSources.isNotEmpty() && !isEditMode) 263 nonEmptyFooterPreference?.isVisible = true 264 } 265 266 /** 267 * Shows the "Add an app" button when there is at least one potential app for the priority list. 268 * 269 * <p> Hides the button in edit mode and when there are no other potential apps for the priority 270 * list. 271 */ 272 private fun updateAddApp(shouldShow: Boolean) { 273 appSourcesPreferenceGroup?.removePreferenceRecursively(ADD_AN_APP_PREFERENCE_KEY) 274 275 if (!shouldShow) { 276 return 277 } 278 279 appSourcesPreferenceGroup?.addPreference( 280 HealthPreference(requireContext()).also { 281 it.icon = AttributeResolver.getDrawable(requireContext(), R.attr.addIcon) 282 it.title = getString(R.string.data_sources_add_app) 283 it.logName = DataSourcesElement.ADD_AN_APP_BUTTON 284 it.key = ADD_AN_APP_PREFERENCE_KEY 285 it.order = 100 // Arbitrary number to ensure the button is added at the end of the 286 // priority list 287 it.setOnPreferenceClickListener { 288 findNavController() 289 .navigate( 290 R.id.action_dataSourcesFragment_to_addAnAppFragment, 291 bundleOf(CATEGORY_KEY to currentCategorySelection)) 292 true 293 } 294 }) 295 } 296 297 /** Populates the data totals section with aggregation cards if needed. */ 298 private fun updateDataTotalsSection(cardInfos: List<AggregationCardInfo>) { 299 dataTotalsPreferenceGroup?.removePreferenceRecursively(DATA_TOTALS_PREFERENCE_KEY) 300 // Do not show data cards when there are no apps on the priority list 301 if (appSourcesPreferenceGroup?.isVisible == false) { 302 return 303 } 304 305 if (cardInfos.isEmpty()) { 306 dataTotalsPreferenceGroup?.isVisible = false 307 } else { 308 dataTotalsPreferenceGroup?.isVisible = true 309 cardContainerPreference = 310 CardContainerPreference(requireContext(), timeSource).also { 311 it.setAggregationCardInfo(cardInfos) 312 it.key = DATA_TOTALS_PREFERENCE_KEY 313 } 314 dataTotalsPreferenceGroup?.addPreference( 315 (cardContainerPreference as CardContainerPreference)) 316 } 317 } 318 319 /** Updates the aggregation cards after a priority list change. */ 320 private fun updateAggregations(cardInfos: List<AggregationCardInfo>, isLoading: Boolean) { 321 if (isLoading) { 322 cardContainerPreference?.setLoading(true) 323 } else { 324 if (cardInfos.isEmpty()) { 325 dataTotalsPreferenceGroup?.isVisible = false 326 } else { 327 dataTotalsPreferenceGroup?.isVisible = true 328 cardContainerPreference?.setAggregationCardInfo(cardInfos) 329 cardContainerPreference?.setLoading(false) 330 } 331 } 332 } 333 334 /** 335 * The empty state of this fragment is represented by: 336 * - no apps with write permissions for this category 337 * - no apps with data for this category 338 */ 339 private fun addEmptyState() { 340 removeNonEmptyState() 341 removeEmptyState() 342 343 preferenceScreen.addPreference(getEmptyStateHeaderPreference()) 344 preferenceScreen.addPreference(getEmptyStateFooterPreference()) 345 } 346 347 private fun removeEmptyState() { 348 preferenceScreen.removePreferenceRecursively(EMPTY_STATE_HEADER_PREFERENCE_KEY) 349 preferenceScreen.removePreferenceRecursively(EMPTY_STATE_FOOTER_PREFERENCE_KEY) 350 } 351 352 private fun removeNonEmptyState() { 353 preferenceScreen.removePreferenceRecursively(APP_SOURCES_PREFERENCE_KEY) 354 preferenceScreen.removePreferenceRecursively(ADD_AN_APP_PREFERENCE_KEY) 355 preferenceScreen.removePreferenceRecursively(DATA_TOTALS_PREFERENCE_KEY) 356 357 // We hide the preference group headers and footer instead of removing them 358 appSourcesPreferenceGroup?.isVisible = false 359 dataTotalsPreferenceGroup?.isVisible = false 360 nonEmptyFooterPreference?.isVisible = false 361 } 362 363 private fun getEmptyStateHeaderPreference(): HeaderPreference { 364 return HeaderPreference(requireContext()).also { 365 it.setHeaderText(getString(R.string.data_sources_empty_state)) 366 it.key = EMPTY_STATE_HEADER_PREFERENCE_KEY 367 } 368 } 369 370 private fun getEmptyStateFooterPreference(): FooterPreference { 371 return FooterPreference(context).also { 372 it.title = 373 getString( 374 R.string.data_sources_empty_state_footer, 375 getString(currentCategorySelection.lowercaseTitle())) 376 it.setLearnMoreText(getString(R.string.data_sources_help_link)) 377 it.setLearnMoreAction { DeviceInfoUtilsImpl().openHCGetStartedLink(requireActivity()) } 378 it.key = EMPTY_STATE_FOOTER_PREFERENCE_KEY 379 } 380 } 381 382 private fun setupSpinnerPreference() { 383 spinnerPreference = SettingsSpinnerPreference(context) 384 spinnerPreference.setAdapter( 385 SettingsSpinnerAdapter<String>(context).also { 386 it.addAll(dataSourcesCategoriesStrings) 387 }) 388 389 spinnerPreference.setOnItemSelectedListener( 390 object : AdapterView.OnItemSelectedListener { 391 override fun onItemSelected( 392 parent: AdapterView<*>?, 393 view: View?, 394 position: Int, 395 id: Long 396 ) { 397 logger.logInteraction(DataSourcesElement.DATA_TYPE_SPINNER) 398 399 val currentCategory = dataSourcesCategories[position] 400 currentCategorySelection = dataSourcesCategories[position] 401 exitEditMode() 402 403 // Reload the data sources information when a new category has been selected 404 dataSourcesViewModel.loadData(currentCategory) 405 dataSourcesViewModel.setCurrentSelection(currentCategory) 406 } 407 408 override fun onNothingSelected(p0: AdapterView<*>?) {} 409 }) 410 411 spinnerPreference.setSelection( 412 dataSourcesCategories.indexOf(dataSourcesViewModel.getCurrentSelection())) 413 414 dataTypeSpinnerPreferenceGroup?.isVisible = true 415 dataTypeSpinnerPreferenceGroup?.addPreference(spinnerPreference) 416 logger.logImpression(DataSourcesElement.DATA_TYPE_SPINNER) 417 } 418 } 419