1 /* <lambda>null2 * Copyright (C) 2022 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 package com.android.healthconnect.controller.home 17 18 import android.content.Context 19 import android.content.Intent 20 import android.icu.text.MessageFormat 21 import android.os.Bundle 22 import android.view.View 23 import androidx.core.os.bundleOf 24 import androidx.fragment.app.activityViewModels 25 import androidx.fragment.app.viewModels 26 import androidx.navigation.fragment.findNavController 27 import androidx.preference.Preference 28 import androidx.preference.PreferenceGroup 29 import com.android.healthconnect.controller.HealthFitnessUiStatsLog.* 30 import com.android.healthconnect.controller.R 31 import com.android.healthconnect.controller.exportimport.api.ExportStatusViewModel 32 import com.android.healthconnect.controller.exportimport.api.ScheduledExportUiState 33 import com.android.healthconnect.controller.exportimport.api.ScheduledExportUiStatus 34 import com.android.healthconnect.controller.migration.MigrationActivity.Companion.maybeShowWhatsNewDialog 35 import com.android.healthconnect.controller.migration.MigrationViewModel 36 import com.android.healthconnect.controller.migration.api.MigrationRestoreState 37 import com.android.healthconnect.controller.migration.api.MigrationRestoreState.DataRestoreUiState 38 import com.android.healthconnect.controller.migration.api.MigrationRestoreState.MigrationUiState 39 import com.android.healthconnect.controller.recentaccess.RecentAccessEntry 40 import com.android.healthconnect.controller.recentaccess.RecentAccessPreference 41 import com.android.healthconnect.controller.recentaccess.RecentAccessViewModel 42 import com.android.healthconnect.controller.recentaccess.RecentAccessViewModel.RecentAccessState 43 import com.android.healthconnect.controller.shared.Constants 44 import com.android.healthconnect.controller.shared.Constants.MIGRATION_NOT_COMPLETE_DIALOG_SEEN 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 48 import com.android.healthconnect.controller.shared.dialog.AlertDialogBuilder 49 import com.android.healthconnect.controller.shared.preference.BannerPreference 50 import com.android.healthconnect.controller.shared.preference.HealthPreference 51 import com.android.healthconnect.controller.shared.preference.HealthPreferenceFragment 52 import com.android.healthconnect.controller.utils.AttributeResolver 53 import com.android.healthconnect.controller.utils.FeatureUtils 54 import com.android.healthconnect.controller.utils.LocalDateTimeFormatter 55 import com.android.healthconnect.controller.utils.NavigationUtils 56 import com.android.healthconnect.controller.utils.TimeSource 57 import com.android.healthconnect.controller.utils.logging.DataRestoreElement 58 import com.android.healthconnect.controller.utils.logging.ErrorPageElement 59 import com.android.healthconnect.controller.utils.logging.HomePageElement 60 import com.android.healthconnect.controller.utils.logging.MigrationElement 61 import com.android.healthconnect.controller.utils.logging.PageName 62 import com.android.healthfitness.flags.Flags 63 import dagger.hilt.android.AndroidEntryPoint 64 import java.time.Instant 65 import java.time.temporal.ChronoUnit 66 import javax.inject.Inject 67 68 /** Home fragment for Health Connect. */ 69 @AndroidEntryPoint(HealthPreferenceFragment::class) 70 class HomeFragment : Hilt_HomeFragment() { 71 72 companion object { 73 private const val DATA_AND_ACCESS_PREFERENCE_KEY = "data_and_access" 74 private const val RECENT_ACCESS_PREFERENCE_KEY = "recent_access" 75 private const val CONNECTED_APPS_PREFERENCE_KEY = "connected_apps" 76 private const val MIGRATION_BANNER_PREFERENCE_KEY = "migration_banner" 77 private const val DATA_RESTORE_BANNER_PREFERENCE_KEY = "data_restore_banner" 78 private const val MANAGE_DATA_PREFERENCE_KEY = "manage_data" 79 private const val EXPORT_FILE_ACCESS_ERROR_BANNER_PREFERENCE_KEY = 80 "export_file_access_error_banner" 81 private const val HOME_FRAGMENT_BANNER_ORDER = 1 82 83 @JvmStatic fun newInstance() = HomeFragment() 84 } 85 86 init { 87 this.setPageName(PageName.HOME_PAGE) 88 } 89 90 @Inject lateinit var featureUtils: FeatureUtils 91 @Inject lateinit var timeSource: TimeSource 92 @Inject lateinit var navigationUtils: NavigationUtils 93 94 private val recentAccessViewModel: RecentAccessViewModel by viewModels() 95 private val homeFragmentViewModel: HomeFragmentViewModel by viewModels() 96 private val migrationViewModel: MigrationViewModel by activityViewModels() 97 private val exportStatusViewModel: ExportStatusViewModel by activityViewModels() 98 99 private val mDataAndAccessPreference: HealthPreference? by lazy { 100 preferenceScreen.findPreference(DATA_AND_ACCESS_PREFERENCE_KEY) 101 } 102 103 private val mRecentAccessPreference: PreferenceGroup? by lazy { 104 preferenceScreen.findPreference(RECENT_ACCESS_PREFERENCE_KEY) 105 } 106 107 private val mConnectedAppsPreference: HealthPreference? by lazy { 108 preferenceScreen.findPreference(CONNECTED_APPS_PREFERENCE_KEY) 109 } 110 111 private val mManageDataPreference: HealthPreference? by lazy { 112 preferenceScreen.findPreference(MANAGE_DATA_PREFERENCE_KEY) 113 } 114 115 private val dateFormatter: LocalDateTimeFormatter by lazy { 116 LocalDateTimeFormatter(requireContext()) 117 } 118 119 private lateinit var migrationBannerSummary: String 120 private var migrationBanner: BannerPreference? = null 121 122 override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { 123 super.onCreatePreferences(savedInstanceState, rootKey) 124 setPreferencesFromResource(R.xml.home_preference_screen, rootKey) 125 mDataAndAccessPreference?.logName = HomePageElement.DATA_AND_ACCESS_BUTTON 126 mDataAndAccessPreference?.setOnPreferenceClickListener { 127 findNavController().navigate(R.id.action_homeFragment_to_healthDataCategoriesFragment) 128 true 129 } 130 mConnectedAppsPreference?.logName = HomePageElement.APP_PERMISSIONS_BUTTON 131 mConnectedAppsPreference?.setOnPreferenceClickListener { 132 findNavController().navigate(R.id.action_homeFragment_to_connectedAppsFragment) 133 true 134 } 135 136 if (featureUtils.isNewAppPriorityEnabled() || 137 featureUtils.isNewInformationArchitectureEnabled()) { 138 mManageDataPreference?.logName = HomePageElement.MANAGE_DATA_BUTTON 139 mManageDataPreference?.setOnPreferenceClickListener { 140 findNavController().navigate(R.id.action_homeFragment_to_manageDataFragment) 141 true 142 } 143 } else { 144 preferenceScreen.removePreferenceRecursively(MANAGE_DATA_PREFERENCE_KEY) 145 } 146 147 migrationBannerSummary = getString(R.string.resume_migration_banner_description_fallback) 148 migrationBanner = getMigrationBanner() 149 } 150 151 override fun onResume() { 152 super.onResume() 153 recentAccessViewModel.loadRecentAccessApps(maxNumEntries = 3) 154 homeFragmentViewModel.loadConnectedApps() 155 } 156 157 override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 158 super.onViewCreated(view, savedInstanceState) 159 160 recentAccessViewModel.loadRecentAccessApps(maxNumEntries = 3) 161 recentAccessViewModel.recentAccessApps.observe(viewLifecycleOwner) { recentAppsState -> 162 when (recentAppsState) { 163 is RecentAccessState.WithData -> { 164 updateRecentApps(recentAppsState.recentAccessEntries) 165 } 166 else -> { 167 updateRecentApps(emptyList()) 168 } 169 } 170 } 171 homeFragmentViewModel.connectedApps.observe(viewLifecycleOwner) { connectedApps -> 172 updateConnectedApps(connectedApps) 173 } 174 migrationViewModel.migrationState.observe(viewLifecycleOwner) { migrationState -> 175 when (migrationState) { 176 is MigrationViewModel.MigrationFragmentState.WithData -> { 177 showMigrationState(migrationState.migrationRestoreState) 178 } 179 else -> { 180 // do nothing 181 } 182 } 183 } 184 185 if (Flags.exportImport()) { 186 exportStatusViewModel.storedScheduledExportStatus.observe(viewLifecycleOwner) { 187 scheduledExportUiStatus -> 188 when (scheduledExportUiStatus) { 189 is ScheduledExportUiStatus.WithData -> { 190 maybeShowExportErrorBanner(scheduledExportUiStatus.scheduledExportUiState) 191 } 192 else -> { 193 // do nothing 194 } 195 } 196 } 197 } 198 } 199 200 private fun showMigrationState(migrationRestoreState: MigrationRestoreState) { 201 preferenceScreen.removePreferenceRecursively(MIGRATION_BANNER_PREFERENCE_KEY) 202 preferenceScreen.removePreferenceRecursively(DATA_RESTORE_BANNER_PREFERENCE_KEY) 203 204 val (migrationUiState, dataRestoreUiState, dataErrorState) = migrationRestoreState 205 206 if (dataRestoreUiState == DataRestoreUiState.PENDING) { 207 // TODO (b/327170886) uncomment when states are correct 208 // preferenceScreen.addPreference(getDataRestorePendingBanner()) 209 } else if (migrationUiState in 210 listOf( 211 MigrationUiState.ALLOWED_PAUSED, 212 MigrationUiState.ALLOWED_NOT_STARTED, 213 MigrationUiState.MODULE_UPGRADE_REQUIRED, 214 MigrationUiState.APP_UPGRADE_REQUIRED)) { 215 migrationBanner = getMigrationBanner() 216 preferenceScreen.addPreference(migrationBanner as BannerPreference) 217 } else if (migrationUiState == MigrationUiState.COMPLETE) { 218 maybeShowWhatsNewDialog(requireContext()) 219 } else if (migrationUiState == MigrationUiState.ALLOWED_ERROR) { 220 maybeShowMigrationNotCompleteDialog() 221 } 222 } 223 224 private fun maybeShowMigrationNotCompleteDialog() { 225 val sharedPreference = 226 requireActivity().getSharedPreferences(USER_ACTIVITY_TRACKER, Context.MODE_PRIVATE) 227 val dialogSeen = sharedPreference.getBoolean(MIGRATION_NOT_COMPLETE_DIALOG_SEEN, false) 228 229 if (!dialogSeen) { 230 AlertDialogBuilder(this, MigrationElement.MIGRATION_NOT_COMPLETE_DIALOG_CONTAINER) 231 .setTitle(R.string.migration_not_complete_dialog_title) 232 .setMessage(R.string.migration_not_complete_dialog_content) 233 .setCancelable(false) 234 .setNegativeButton( 235 R.string.migration_whats_new_dialog_button, 236 MigrationElement.MIGRATION_NOT_COMPLETE_DIALOG_BUTTON) { _, _ -> 237 sharedPreference.edit().apply { 238 putBoolean(MIGRATION_NOT_COMPLETE_DIALOG_SEEN, true) 239 apply() 240 } 241 } 242 .create() 243 .show() 244 } 245 } 246 247 private fun maybeShowExportErrorBanner(scheduledExportUiState: ScheduledExportUiState) { 248 when (scheduledExportUiState.dataExportError) { 249 ScheduledExportUiState.DataExportError.DATA_EXPORT_LOST_FILE_ACCESS -> { 250 scheduledExportUiState.lastSuccessfulExportTime?.let { 251 preferenceScreen.addPreference( 252 getExportFileAccessErrorBanner(it, scheduledExportUiState.periodInDays)) 253 } 254 } 255 else -> { 256 // Do nothing yet. 257 } 258 } 259 } 260 261 private fun getExportFileAccessErrorBanner( 262 lastSuccessfulDate: Instant, 263 periodInDays: Int 264 ): BannerPreference { 265 // TODO: b/325917283 - Add proper logging for the export file access error banner. 266 return BannerPreference(requireContext(), ErrorPageElement.UNKNOWN_ELEMENT).also { 267 it.setPrimaryButton( 268 getString(R.string.export_file_access_error_banner_button), 269 ErrorPageElement.UNKNOWN_ELEMENT) 270 it.title = getString(R.string.export_file_access_error_banner_title) 271 it.key = EXPORT_FILE_ACCESS_ERROR_BANNER_PREFERENCE_KEY 272 it.summary = 273 getString( 274 R.string.export_file_access_error_banner_summary, 275 dateFormatter.formatLongDate( 276 lastSuccessfulDate.plus(periodInDays.toLong(), ChronoUnit.DAYS))) 277 it.icon = AttributeResolver.getNullableDrawable(requireContext(), R.attr.warningIcon) 278 it.setPrimaryButtonOnClickListener { 279 findNavController().navigate(R.id.action_homeFragment_to_exportSetupActivity) 280 } 281 it.order = HOME_FRAGMENT_BANNER_ORDER 282 } 283 } 284 285 private fun getMigrationBanner(): BannerPreference { 286 return BannerPreference(requireContext(), MigrationElement.MIGRATION_RESUME_BANNER).also { 287 it.setPrimaryButton( 288 resources.getString(R.string.resume_migration_banner_button), 289 MigrationElement.MIGRATION_RESUME_BANNER_BUTTON) 290 it.title = resources.getString(R.string.resume_migration_banner_title) 291 it.key = MIGRATION_BANNER_PREFERENCE_KEY 292 it.summary = migrationBannerSummary 293 it.icon = 294 AttributeResolver.getNullableDrawable(requireContext(), R.attr.settingsAlertIcon) 295 it.setPrimaryButtonOnClickListener { 296 findNavController().navigate(R.id.action_homeFragment_to_migrationActivity) 297 } 298 it.order = HOME_FRAGMENT_BANNER_ORDER 299 } 300 } 301 302 private fun getDataRestorePendingBanner(): BannerPreference { 303 return BannerPreference(requireContext(), DataRestoreElement.RESTORE_PENDING_BANNER).also { 304 it.setPrimaryButton( 305 resources.getString(R.string.data_restore_pending_banner_button), 306 DataRestoreElement.RESTORE_PENDING_BANNER_UPDATE_BUTTON) 307 it.title = resources.getString(R.string.data_restore_pending_banner_title) 308 it.key = DATA_RESTORE_BANNER_PREFERENCE_KEY 309 it.summary = resources.getString(R.string.data_restore_pending_banner_content) 310 it.icon = 311 AttributeResolver.getNullableDrawable(requireContext(), R.attr.updateNeededIcon) 312 it.setPrimaryButtonOnClickListener { 313 findNavController().navigate(R.id.action_homeFragment_to_systemUpdateActivity) 314 } 315 it.order = 1 316 } 317 } 318 319 private fun updateConnectedApps(connectedApps: List<ConnectedAppMetadata>) { 320 val connectedAppsGroup = connectedApps.groupBy { it.status } 321 val numAllowedApps = connectedAppsGroup[ConnectedAppStatus.ALLOWED].orEmpty().size 322 val numNotAllowedApps = connectedAppsGroup[ConnectedAppStatus.DENIED].orEmpty().size 323 val numTotalApps = numAllowedApps + numNotAllowedApps 324 325 if (numTotalApps == 0) { 326 mConnectedAppsPreference?.summary = 327 getString(R.string.connected_apps_button_no_permissions_subtitle) 328 } else if (numAllowedApps == numTotalApps) { 329 mConnectedAppsPreference?.summary = 330 MessageFormat.format( 331 getString(R.string.connected_apps_connected_subtitle), 332 mapOf("count" to numAllowedApps)) 333 } else { 334 mConnectedAppsPreference?.summary = 335 getString( 336 if (numAllowedApps == 1) R.string.only_one_connected_app_button_subtitle 337 else R.string.connected_apps_button_subtitle, 338 numAllowedApps.toString(), 339 numTotalApps.toString()) 340 } 341 } 342 343 private fun updateRecentApps(recentAppsList: List<RecentAccessEntry>) { 344 mRecentAccessPreference?.removeAll() 345 346 if (recentAppsList.isEmpty()) { 347 mRecentAccessPreference?.addPreference( 348 Preference(requireContext()) 349 .also { it.setSummary(R.string.no_recent_access) } 350 .also { it.isSelectable = false }) 351 } else { 352 recentAppsList.forEach { recentApp -> 353 val newRecentAccessPreference = 354 RecentAccessPreference(requireContext(), recentApp, timeSource, false).also { 355 newPreference -> 356 if (!recentApp.isInactive) { 357 newPreference.setOnPreferenceClickListener { 358 findNavController() 359 .navigate( 360 R.id.action_homeFragment_to_connectedAppFragment, 361 bundleOf( 362 Intent.EXTRA_PACKAGE_NAME to 363 recentApp.metadata.packageName, 364 Constants.EXTRA_APP_NAME to recentApp.metadata.appName)) 365 true 366 } 367 } 368 } 369 mRecentAccessPreference?.addPreference(newRecentAccessPreference) 370 } 371 val seeAllPreference = 372 HealthPreference(requireContext()).also { 373 it.setTitle(R.string.show_recent_access_entries_button_title) 374 it.setIcon(AttributeResolver.getResource(requireContext(), R.attr.seeAllIcon)) 375 it.logName = HomePageElement.SEE_ALL_RECENT_ACCESS_BUTTON 376 } 377 seeAllPreference.setOnPreferenceClickListener { 378 findNavController().navigate(R.id.action_homeFragment_to_recentAccessFragment) 379 true 380 } 381 mRecentAccessPreference?.addPreference(seeAllPreference) 382 } 383 } 384 } 385