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