• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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