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 package com.android.healthconnect.controller.route 17 18 import android.app.Activity 19 import android.content.Intent 20 import android.health.connect.HealthConnectManager.EXTRA_EXERCISE_ROUTE 21 import android.health.connect.HealthConnectManager.EXTRA_SESSION_ID 22 import android.health.connect.datatypes.ExerciseRoute 23 import android.os.Bundle 24 import android.util.Log 25 import android.view.WindowManager 26 import android.widget.Button 27 import android.widget.LinearLayout 28 import android.widget.TextView 29 import androidx.activity.viewModels 30 import androidx.annotation.VisibleForTesting 31 import androidx.appcompat.app.AlertDialog 32 import androidx.fragment.app.FragmentActivity 33 import com.android.healthconnect.controller.R 34 import com.android.healthconnect.controller.dataentries.formatters.ExerciseSessionFormatter 35 import com.android.healthconnect.controller.migration.MigrationActivity.Companion.maybeShowWhatsNewDialog 36 import com.android.healthconnect.controller.migration.MigrationActivity.Companion.showDataRestoreInProgressDialog 37 import com.android.healthconnect.controller.migration.MigrationActivity.Companion.showMigrationInProgressDialog 38 import com.android.healthconnect.controller.migration.MigrationActivity.Companion.showMigrationPendingDialog 39 import com.android.healthconnect.controller.migration.MigrationViewModel 40 import com.android.healthconnect.controller.migration.api.MigrationRestoreState 41 import com.android.healthconnect.controller.migration.api.MigrationRestoreState.DataRestoreUiState 42 import com.android.healthconnect.controller.migration.api.MigrationRestoreState.MigrationUiState 43 import com.android.healthconnect.controller.route.ExerciseRouteViewModel.SessionWithAttribution 44 import com.android.healthconnect.controller.shared.app.AppInfoReader 45 import com.android.healthconnect.controller.shared.dialog.AlertDialogBuilder 46 import com.android.healthconnect.controller.shared.map.MapView 47 import com.android.healthconnect.controller.utils.LocalDateTimeFormatter 48 import com.android.healthconnect.controller.utils.boldAppName 49 import com.android.healthconnect.controller.utils.logging.HealthConnectLogger 50 import com.android.healthconnect.controller.utils.logging.RouteRequestElement 51 import com.android.settingslib.widget.SettingsThemeHelper 52 import dagger.hilt.android.AndroidEntryPoint 53 import javax.inject.Inject 54 import kotlinx.coroutines.runBlocking 55 56 /** Request route activity for Health Connect. */ 57 @AndroidEntryPoint(FragmentActivity::class) 58 class RouteRequestActivity : Hilt_RouteRequestActivity() { 59 60 companion object { 61 private const val TAG = "RouteRequestActivity" 62 } 63 64 @Inject lateinit var appInfoReader: AppInfoReader 65 66 @VisibleForTesting var dialog: AlertDialog? = null 67 68 @VisibleForTesting lateinit var infoDialog: AlertDialog 69 70 @Inject lateinit var healthConnectLogger: HealthConnectLogger 71 72 private val viewModel: ExerciseRouteViewModel by viewModels() 73 private val migrationViewModel: MigrationViewModel by viewModels() 74 75 private val sessionIdExtra: String? 76 get() = intent.getStringExtra(EXTRA_SESSION_ID) 77 78 private var requester: String? = null 79 private var migrationRestoreState = MigrationUiState.UNKNOWN 80 private var sessionWithAttribution: SessionWithAttribution? = null 81 82 override fun onCreate(savedInstanceState: Bundle?) { 83 super.onCreate(savedInstanceState) 84 // This flag ensures a non system app cannot show an overlay on Health Connect. b/313425281 85 window.addSystemFlags( 86 WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS 87 ) 88 if (sessionIdExtra == null || callingPackage == null) { 89 Log.e(TAG, "Invalid Intent Extras, finishing.") 90 finishCancelled() 91 return 92 } 93 94 val callingPackageName = callingPackage!! 95 96 if (!viewModel.isReadRoutesPermissionDeclared(callingPackageName)) { 97 Log.e(TAG, "Read permission not declared") 98 finishCancelled() 99 return 100 } 101 102 viewModel.getExerciseWithRoute(sessionIdExtra!!) 103 runBlocking { requester = appInfoReader.getAppMetadata(callingPackageName).appName } 104 viewModel.exerciseSession.observe(this) { session -> 105 this.sessionWithAttribution = session 106 setupRequestDialog(session, callingPackageName) 107 } 108 109 migrationViewModel.migrationState.observe(this) { migrationState -> 110 when (migrationState) { 111 is MigrationViewModel.MigrationFragmentState.WithData -> { 112 maybeShowMigrationDialog(migrationState.migrationRestoreState) 113 this.migrationRestoreState = 114 migrationState.migrationRestoreState.migrationUiState 115 } 116 else -> { 117 // do nothing 118 } 119 } 120 } 121 } 122 123 private fun setupRequestDialog(data: SessionWithAttribution?, callingPackage: String) { 124 if ( 125 data == null || 126 data.session.route == null || 127 data.session.route?.routeLocations.isNullOrEmpty() 128 ) { 129 Log.e(TAG, "No route or empty route, finishing.") 130 finishCancelled() 131 return 132 } 133 134 val session = data.session 135 val route = session.route!! 136 137 if ( 138 session.metadata.dataOrigin.packageName == callingPackage && 139 viewModel.isRouteReadOrWritePermissionGranted(callingPackage) 140 ) { 141 finishWithResult(route) 142 return 143 } 144 145 if (viewModel.isSessionInaccessible(callingPackage, session)) { 146 Log.i(TAG, "Requested exercise session is inaccessible.") 147 finishCancelled() 148 return 149 } 150 151 if (viewModel.isReadRoutesPermissionGranted(callingPackage)) { 152 finishWithResult(route) 153 return 154 } 155 156 if (viewModel.isReadRoutesPermissionUserFixed(callingPackage)) { 157 finishCancelled() 158 return 159 } 160 161 val sessionDetails = 162 applicationContext.getString( 163 R.string.date_owner_format, 164 LocalDateTimeFormatter(applicationContext).formatLongDate(session.startTime), 165 data.appInfo.appName, 166 ) 167 val sessionTitle = 168 if (session.title.isNullOrBlank()) 169 ExerciseSessionFormatter.Companion.getExerciseType( 170 applicationContext, 171 session.exerciseType, 172 ) 173 else session.title 174 175 val view = 176 layoutInflater.inflate( 177 if (SettingsThemeHelper.isExpressiveTheme(applicationContext)) 178 R.layout.route_request_dialog_expressive 179 else R.layout.route_request_dialog_legacy, 180 null, 181 ) 182 val text = applicationContext.getString(R.string.request_route_header_title, requester) 183 val title = boldAppName(requester, text) 184 185 view.findViewById<MapView>(R.id.map_view).setRoute(session.route!!) 186 view.findViewById<TextView>(R.id.session_title).text = sessionTitle 187 view.findViewById<TextView>(R.id.date_app).text = sessionDetails 188 189 view.findViewById<LinearLayout>(R.id.more_info).setOnClickListener { 190 healthConnectLogger.logInteraction( 191 RouteRequestElement.EXERCISE_ROUTE_DIALOG_INFORMATION_BUTTON 192 ) 193 dialog?.hide() 194 setupInfoDialog() 195 infoDialog.show() 196 } 197 198 view.findViewById<Button>(R.id.route_dont_allow_button).setOnClickListener { 199 healthConnectLogger.logInteraction( 200 RouteRequestElement.EXERCISE_ROUTE_DIALOG_DONT_ALLOW_BUTTON 201 ) 202 finishCancelled() 203 } 204 205 val allowAllButton: Button = view.findViewById<Button>(R.id.route_allow_all_button) 206 207 allowAllButton.setOnClickListener { 208 healthConnectLogger.logInteraction( 209 RouteRequestElement.EXERCISE_ROUTE_DIALOG_ALWAYS_ALLOW_BUTTON 210 ) 211 viewModel.grantReadRoutesPermission(callingPackage) 212 finishWithResult(route) 213 } 214 215 view.findViewById<Button>(R.id.route_allow_button).setOnClickListener { 216 healthConnectLogger.logInteraction( 217 RouteRequestElement.EXERCISE_ROUTE_DIALOG_ALLOW_BUTTON 218 ) 219 finishWithResult(route) 220 } 221 222 dialog = 223 AlertDialogBuilder(this, RouteRequestElement.EXERCISE_ROUTE_REQUEST_DIALOG_CONTAINER) 224 .setCustomIcon(R.attr.healthConnectIcon) 225 .setCustomTitle(title) 226 .setView(view) 227 .setCancelable(false) 228 .setAdditionalLogging { 229 healthConnectLogger.logImpression( 230 RouteRequestElement.EXERCISE_ROUTE_DIALOG_ROUTE_VIEW 231 ) 232 healthConnectLogger.logImpression( 233 RouteRequestElement.EXERCISE_ROUTE_DIALOG_ALLOW_BUTTON 234 ) 235 healthConnectLogger.logImpression( 236 RouteRequestElement.EXERCISE_ROUTE_DIALOG_DONT_ALLOW_BUTTON 237 ) 238 healthConnectLogger.logImpression( 239 RouteRequestElement.EXERCISE_ROUTE_DIALOG_INFORMATION_BUTTON 240 ) 241 } 242 .create() 243 if (shouldShowDialog()) { 244 dialog?.show() 245 } 246 } 247 248 private fun shouldShowDialog() = 249 !dialog!!.isShowing && 250 migrationRestoreState in 251 listOf( 252 MigrationUiState.IDLE, 253 MigrationUiState.COMPLETE, 254 MigrationUiState.COMPLETE_IDLE, 255 MigrationUiState.ALLOWED_MIGRATOR_DISABLED, 256 MigrationUiState.ALLOWED_ERROR, 257 ) 258 259 private fun setupInfoDialog() { 260 val view = layoutInflater.inflate(R.layout.route_sharing_info_dialog, null) 261 infoDialog = 262 AlertDialogBuilder(this, RouteRequestElement.EXERCISE_ROUTE_EDUCATION_DIALOG_CONTAINER) 263 .setCustomIcon(R.attr.privacyPolicyIcon) 264 .setCustomTitle(getString(R.string.request_route_info_header_title)) 265 .setNegativeButton( 266 R.string.back_button, 267 RouteRequestElement.EXERCISE_ROUTE_EDUCATION_DIALOG_BACK_BUTTON, 268 ) { _, _ -> 269 dialog?.show() 270 } 271 .setView(view) 272 .setCancelable(false) 273 .create() 274 } 275 276 private fun maybeShowMigrationDialog(migrationRestoreState: MigrationRestoreState) { 277 val (migrationUiState, dataRestoreUiState, dataErrorState) = migrationRestoreState 278 279 if (dataRestoreUiState == DataRestoreUiState.IN_PROGRESS) { 280 showDataRestoreInProgressDialog(this) { _, _ -> finish() } 281 } else if (migrationUiState == MigrationUiState.IN_PROGRESS) { 282 showMigrationInProgressDialog( 283 this, 284 applicationContext.getString( 285 R.string.migration_in_progress_permissions_dialog_content, 286 requester, 287 ), 288 ) { _, _ -> 289 finish() 290 } 291 } else if ( 292 migrationUiState in 293 listOf( 294 MigrationUiState.ALLOWED_PAUSED, 295 MigrationUiState.ALLOWED_NOT_STARTED, 296 MigrationUiState.MODULE_UPGRADE_REQUIRED, 297 MigrationUiState.APP_UPGRADE_REQUIRED, 298 ) 299 ) { 300 showMigrationPendingDialog( 301 this, 302 applicationContext.getString( 303 R.string.migration_pending_permissions_dialog_content, 304 requester, 305 ), 306 positiveButtonAction = { _, _ -> dialog?.show() }, 307 negativeButtonAction = { _, _ -> finishCancelled() }, 308 ) 309 } else if (migrationUiState == MigrationUiState.COMPLETE) { 310 maybeShowWhatsNewDialog(this) { _, _ -> dialog?.show() } 311 } else { 312 // Show the request dialog 313 dialog?.show() 314 } 315 } 316 317 override fun onDestroy() { 318 dialog?.dismiss() 319 super.onDestroy() 320 } 321 322 private fun finishWithResult(route: ExerciseRoute) { 323 val result = Intent() 324 result.putExtra(EXTRA_SESSION_ID, sessionIdExtra) 325 result.putExtra(EXTRA_EXERCISE_ROUTE, route) 326 setResult(Activity.RESULT_OK, result) 327 finish() 328 } 329 330 private fun finishCancelled() { 331 val result = Intent() 332 sessionIdExtra?.let { result.putExtra(EXTRA_SESSION_ID, it) } 333 setResult(Activity.RESULT_CANCELED, result) 334 finish() 335 } 336 } 337