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