1 /**
2  * Copyright (C) 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
5  * in compliance with the License. You may obtain a copy of the License at
6  *
7  * ```
8  *    http://www.apache.org/licenses/LICENSE-2.0
9  * ```
10  *
11  * Unless required by applicable law or agreed to in writing, software distributed under the License
12  * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
13  * or implied. See the License for the specific language governing permissions and limitations under
14  * the License.
15  */
16 package com.android.healthconnect.testapps.toolbox
17 
18 import android.content.BroadcastReceiver
19 import android.content.Context
20 import android.content.Intent
21 import android.health.connect.HealthConnectManager
22 import android.health.connect.ReadRecordsRequestUsingFilters
23 import android.health.connect.TimeInstantRangeFilter
24 import android.health.connect.datatypes.DataOrigin
25 import android.health.connect.datatypes.Record
26 import androidx.work.ListenableWorker
27 import androidx.work.OneTimeWorkRequestBuilder
28 import androidx.work.OutOfQuotaPolicy
29 import androidx.work.WorkManager
30 import androidx.work.WorkerParameters
31 import androidx.work.workDataOf
32 import com.android.healthconnect.testapps.toolbox.utils.GeneralUtils.Companion.readRecords
33 import com.android.healthconnect.testapps.toolbox.utils.GeneralUtils.Companion.requireByteArrayExtra
34 import com.android.healthconnect.testapps.toolbox.utils.GeneralUtils.Companion.requireSerializable
35 import com.android.healthconnect.testapps.toolbox.utils.GeneralUtils.Companion.requireSystemService
36 import com.android.healthconnect.testapps.toolbox.utils.asString
37 import com.android.healthconnect.testapps.toolbox.utils.deserialize
38 import com.android.healthconnect.testapps.toolbox.utils.launchFuture
39 import com.android.healthconnect.testapps.toolbox.utils.serialize
40 import com.google.common.util.concurrent.ListenableFuture
41 import kotlinx.coroutines.flow.MutableSharedFlow
42 import kotlinx.coroutines.flow.first
43 import kotlinx.coroutines.withTimeoutOrNull
44 import java.io.Serializable
45 import java.time.Instant
46 import java.util.UUID
47 import kotlin.time.Duration.Companion.seconds
48 
49 /**
50  * Calls the [ToolboxProxyReceiver] of the Toolbox app specified by the [packageName] argument
51  * and returns a response.
52  **/
callToolboxnull53 suspend fun callToolbox(
54     context: Context,
55     packageName: String,
56     payload: ToolboxProxyPayload,
57 ): String? {
58     val requestId = UUID.randomUUID().toString()
59 
60     context.sendToolboxRequest(
61         toolboxPackageName = packageName,
62         request = ToolboxProxyRequest(
63             id = requestId,
64             callerPackageName = context.packageName,
65             payload = payload,
66         ),
67     )
68 
69     return receiveToolboxResponse(requestId = requestId)
70 }
71 
72 /** A payload to be sent to [ToolboxProxyReceiver] as part of a request. */
73 sealed interface ToolboxProxyPayload : Serializable {
74 
75     /**
76      * Reads records of the specified [type] contributed by apps specified by the [packageNames]
77      * list, or by all apps if the [packageNames] list is empty.
78      */
79     data class ReadRecords(
80         val type: Class<out Record>,
81         val packageNames: List<String> = emptyList(),
82     ) : ToolboxProxyPayload
83 }
84 
85 private data class ToolboxProxyRequest(
86     val id: String,
87     val callerPackageName: String,
88     val payload: ToolboxProxyPayload,
89 ) : Serializable
90 
91 private data class ToolboxProxyResponse(
92     val requestId: String,
93     val response: String,
94 ) : Serializable
95 
Contextnull96 private fun Context.sendToolboxRequest(toolboxPackageName: String, request: ToolboxProxyRequest) {
97     sendBroadcast(
98         Intent(ACTION_REQUEST)
99             .setClassName(toolboxPackageName, ToolboxProxyReceiver::class.java.name)
100             .putExtra(EXTRA_REQUEST, request.serialize())
101     )
102 }
103 
Contextnull104 private fun Context.sendToolboxResponse(
105     toolboxPackageName: String,
106     response: ToolboxProxyResponse,
107 ) {
108     sendBroadcast(
109         Intent(ACTION_RESPONSE)
110             .setClassName(toolboxPackageName, ToolboxProxyReceiver::class.java.name)
111             .putExtra(EXTRA_RESPONSE, response)
112     )
113 }
114 
115 const val PKG_TOOLBOX_2: String = "com.android.healthconnect.testapps.toolbox2"
116 
117 private const val ACTION_REQUEST = "action.REQUEST"
118 private const val ACTION_RESPONSE = "action.RESPONSE"
119 private const val EXTRA_RESPONSE = "extra.RESPONSE"
120 private const val EXTRA_REQUEST = "extra.REQUEST"
121 
122 private val responseFlow =
123     MutableSharedFlow<ToolboxProxyResponse>(extraBufferCapacity = Int.MAX_VALUE)
124 
receiveToolboxResponsenull125 private suspend fun receiveToolboxResponse(requestId: String): String? =
126     withTimeoutOrNull(timeout = 5.seconds) {
127         responseFlow.first { it.requestId == requestId }.response
128     }
129 
130 class ToolboxProxyReceiver : BroadcastReceiver() {
onReceivenull131     override fun onReceive(context: Context, intent: Intent) {
132         when (intent.action) {
133             ACTION_REQUEST -> {
134                 enqueueWorker(
135                     context = context,
136                     requestBytes = intent.requireByteArrayExtra(EXTRA_REQUEST),
137                 )
138             }
139 
140             ACTION_RESPONSE -> {
141                 responseFlow.tryEmit(
142                     intent.requireSerializable<ToolboxProxyResponse>(EXTRA_RESPONSE)
143                 )
144             }
145         }
146     }
147 
enqueueWorkernull148     private fun enqueueWorker(context: Context, requestBytes: ByteArray) {
149         WorkManager.getInstance(context).enqueue(
150             OneTimeWorkRequestBuilder<ToolboxProxyWorker>()
151                 .setInputData(workDataOf(EXTRA_REQUEST to requestBytes))
152                 .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
153                 .build()
154         )
155     }
156 }
157 
158 class ToolboxProxyWorker(
159     private val context: Context,
160     params: WorkerParameters,
161 ) : ListenableWorker(context, params) {
162 
163     private val manager = context.requireSystemService<HealthConnectManager>()
164 
165     private val request =
166         requireNotNull(inputData.getByteArray(EXTRA_REQUEST)).deserialize<ToolboxProxyRequest>()
167 
startWorknull168     override fun startWork(): ListenableFuture<Result> =
169         launchFuture {
170             try {
171                 sendResponse(doWork())
172                 Result.success()
173             } catch (e: Exception) {
174                 sendResponse(e.toString())
175                 Result.failure()
176             }
177         }
178 
doWorknull179     private suspend fun doWork(): String =
180         when (val payload = request.payload) {
181             is ToolboxProxyPayload.ReadRecords -> readRecords(payload)
182         }
183 
sendResponsenull184     private fun sendResponse(response: String) {
185         context.sendToolboxResponse(
186             toolboxPackageName = request.callerPackageName,
187             response = ToolboxProxyResponse(requestId = request.id, response = response),
188         )
189     }
190 
readRecordsnull191     private suspend inline fun readRecords(payload: ToolboxProxyPayload.ReadRecords): String =
192         readRecords(
193             manager = manager,
194             request = ReadRecordsRequestUsingFilters.Builder(payload.type)
195                 .setTimeRangeFilter(
196                     TimeInstantRangeFilter.Builder()
197                         .setStartTime(Instant.EPOCH)
198                         .setEndTime(Instant.now())
199                         .build()
200                 )
201                 .apply {
202                     for (pkg in payload.packageNames) {
203                         addDataOrigins(DataOrigin.Builder().setPackageName(pkg).build())
204                     }
205                 }
206                 .build(),
207         ).joinToString(separator = "\n", transform = Record::asString)
208 }
209