• 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.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