1 /*
2  * 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 
17 package android.healthconnect.cts.lib;
18 
19 import static android.health.connect.datatypes.FhirVersion.parseFhirVersion;
20 import static android.healthconnect.cts.lib.BundleHelper.INTENT_EXCEPTION;
21 import static android.healthconnect.cts.lib.BundleHelper.KILL_SELF_REQUEST;
22 import static android.healthconnect.cts.lib.BundleHelper.QUERY_TYPE;
23 
24 import static androidx.test.InstrumentationRegistry.getContext;
25 
26 import android.app.Instrumentation;
27 import android.content.BroadcastReceiver;
28 import android.content.Context;
29 import android.content.Intent;
30 import android.content.IntentFilter;
31 import android.health.connect.CreateMedicalDataSourceRequest;
32 import android.health.connect.DeleteMedicalResourcesRequest;
33 import android.health.connect.GetMedicalDataSourcesRequest;
34 import android.health.connect.MedicalResourceId;
35 import android.health.connect.ReadMedicalResourcesRequest;
36 import android.health.connect.ReadMedicalResourcesResponse;
37 import android.health.connect.ReadRecordsRequestUsingFilters;
38 import android.health.connect.ReadRecordsRequestUsingIds;
39 import android.health.connect.RecordIdFilter;
40 import android.health.connect.UpsertMedicalResourceRequest;
41 import android.health.connect.changelog.ChangeLogTokenRequest;
42 import android.health.connect.changelog.ChangeLogsRequest;
43 import android.health.connect.changelog.ChangeLogsResponse;
44 import android.health.connect.datatypes.MedicalDataSource;
45 import android.health.connect.datatypes.MedicalResource;
46 import android.health.connect.datatypes.Record;
47 import android.healthconnect.cts.utils.ProxyActivity;
48 import android.os.Bundle;
49 
50 import com.android.cts.install.lib.TestApp;
51 
52 import java.util.Arrays;
53 import java.util.Collections;
54 import java.util.List;
55 import java.util.concurrent.CountDownLatch;
56 import java.util.concurrent.TimeUnit;
57 import java.util.concurrent.TimeoutException;
58 import java.util.concurrent.atomic.AtomicReference;
59 
60 /** Performs API calls to HC on behalf of test apps. */
61 public class TestAppProxy {
62     private static final String TEST_APP_RECEIVER_CLASS_NAME =
63             "android.healthconnect.cts.testhelper.TestAppReceiver";
64     private static final long POLLING_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(30);
65 
66     public static final TestAppProxy APP_WRITE_PERMS_ONLY =
67             TestAppProxy.forPackageName("android.healthconnect.cts.testapp.writePermsOnly");
68 
69     private final String mPackageName;
70     private final boolean mInBackground;
71 
TestAppProxy(String packageName, boolean inBackground)72     private TestAppProxy(String packageName, boolean inBackground) {
73         mPackageName = packageName;
74         mInBackground = inBackground;
75     }
76 
77     /** Create a new {@link TestAppProxy} for given test app. */
forApp(TestApp testApp)78     public static TestAppProxy forApp(TestApp testApp) {
79         return forPackageName(testApp.getPackageName());
80     }
81 
82     /** Create a new {@link TestAppProxy} for given package name. */
forPackageName(String packageName)83     public static TestAppProxy forPackageName(String packageName) {
84         return new TestAppProxy(packageName, false);
85     }
86 
87     /**
88      * Create a new {@link TestAppProxy} for given package name which performs calls in the
89      * background.
90      */
forPackageNameInBackground(String packageName)91     public static TestAppProxy forPackageNameInBackground(String packageName) {
92         return new TestAppProxy(packageName, true);
93     }
94 
95     /** Returns the package name of the app. */
getPackageName()96     public String getPackageName() {
97         return mPackageName;
98     }
99 
100     /** Inserts a record to HC on behalf of the app. */
insertRecord(Record record)101     public String insertRecord(Record record) throws Exception {
102         return insertRecords(Collections.singletonList(record)).get(0);
103     }
104 
105     /** Inserts records to HC on behalf of the app. */
insertRecords(Record... records)106     public List<String> insertRecords(Record... records) throws Exception {
107         return insertRecords(Arrays.asList(records));
108     }
109 
110     /** Inserts records to HC on behalf of the app. */
insertRecords(List<? extends Record> records)111     public List<String> insertRecords(List<? extends Record> records) throws Exception {
112         Bundle requestBundle = BundleHelper.fromInsertRecordsRequest(records);
113         Bundle responseBundle = getFromTestApp(requestBundle);
114         return BundleHelper.toInsertRecordsResponse(responseBundle);
115     }
116 
117     /** Deletes records from HC on behalf of the app. */
deleteRecords(RecordIdFilter... recordIdFilters)118     public void deleteRecords(RecordIdFilter... recordIdFilters) throws Exception {
119         deleteRecords(Arrays.asList(recordIdFilters));
120     }
121 
122     /** Deletes records from HC on behalf of the app. */
deleteRecords(List<RecordIdFilter> recordIdFilters)123     public void deleteRecords(List<RecordIdFilter> recordIdFilters) throws Exception {
124         Bundle requestBundle = BundleHelper.fromDeleteRecordsByIdsRequest(recordIdFilters);
125         getFromTestApp(requestBundle);
126     }
127 
128     /** Updates records in HC on behalf of the app. */
updateRecords(Record... records)129     public void updateRecords(Record... records) throws Exception {
130         updateRecords(Arrays.asList(records));
131     }
132 
133     /** Updates records in HC on behalf of the app. */
updateRecords(List<Record> records)134     public void updateRecords(List<Record> records) throws Exception {
135         Bundle requestBundle = BundleHelper.fromUpdateRecordsRequest(records);
136         getFromTestApp(requestBundle);
137     }
138 
139     /** Read records from HC on behalf of the app. */
readRecords(ReadRecordsRequestUsingFilters<T> request)140     public <T extends Record> List<T> readRecords(ReadRecordsRequestUsingFilters<T> request)
141             throws Exception {
142         Bundle requestBundle = BundleHelper.fromReadRecordsRequestUsingFilters(request);
143         Bundle responseBundle = getFromTestApp(requestBundle);
144         return BundleHelper.toReadRecordsResponse(responseBundle);
145     }
146 
147     /** Read records from HC on behalf of the app. */
readRecords(ReadRecordsRequestUsingIds<T> request)148     public <T extends Record> List<T> readRecords(ReadRecordsRequestUsingIds<T> request)
149             throws Exception {
150         Bundle requestBundle = BundleHelper.fromReadRecordsRequestUsingIds(request);
151         Bundle responseBundle = getFromTestApp(requestBundle);
152         return BundleHelper.toReadRecordsResponse(responseBundle);
153     }
154 
155     /** Gets changelogs from HC on behalf of the app. */
getChangeLogs(ChangeLogsRequest request)156     public ChangeLogsResponse getChangeLogs(ChangeLogsRequest request) throws Exception {
157         Bundle requestBundle = BundleHelper.fromChangeLogsRequest(request);
158         Bundle responseBundle = getFromTestApp(requestBundle);
159         return BundleHelper.toChangeLogsResponse(responseBundle);
160     }
161 
162     /** Gets a change log token from HC on behalf of the app. */
getChangeLogToken(ChangeLogTokenRequest request)163     public String getChangeLogToken(ChangeLogTokenRequest request) throws Exception {
164         Bundle requestBundle = BundleHelper.fromChangeLogTokenRequest(request);
165         Bundle responseBundle = getFromTestApp(requestBundle);
166         return BundleHelper.toChangeLogTokenResponse(responseBundle);
167     }
168 
169     /**
170      * Inserts a Medical Data Source to HC on behalf of the app.
171      *
172      * @return the inserted data source
173      */
createMedicalDataSource(CreateMedicalDataSourceRequest request)174     public MedicalDataSource createMedicalDataSource(CreateMedicalDataSourceRequest request)
175             throws Exception {
176         Bundle requestBundle = BundleHelper.fromCreateMedicalDataSourceRequest(request);
177         Bundle responseBundle = getFromTestApp(requestBundle);
178         return BundleHelper.toMedicalDataSource(responseBundle);
179     }
180 
181     /** Gets a list of {@link MedicalDataSource}s given a list of ids on behalf of the app. */
getMedicalDataSources(List<String> ids)182     public List<MedicalDataSource> getMedicalDataSources(List<String> ids) throws Exception {
183         Bundle requestBundle = BundleHelper.fromMedicalDataSourceIds(ids);
184         Bundle responseBundle = getFromTestApp(requestBundle);
185         return BundleHelper.toMedicalDataSources(responseBundle);
186     }
187 
188     /** Gets a list of {@link MedicalDataSource}s given a {@link GetMedicalDataSourcesRequest}. */
getMedicalDataSources(GetMedicalDataSourcesRequest request)189     public List<MedicalDataSource> getMedicalDataSources(GetMedicalDataSourcesRequest request)
190             throws Exception {
191         Bundle requestBundle = BundleHelper.fromMedicalDataSourceRequest(request);
192         Bundle responseBundle = getFromTestApp(requestBundle);
193         return BundleHelper.toMedicalDataSources(responseBundle);
194     }
195 
196     /**
197      * Upserts a Medical Resource to HC on behalf of the app.
198      *
199      * @return the inserted resource
200      */
upsertMedicalResource(String datasourceId, String data)201     public MedicalResource upsertMedicalResource(String datasourceId, String data)
202             throws Exception {
203         String R4VersionString = "4.0.1";
204         UpsertMedicalResourceRequest request =
205                 new UpsertMedicalResourceRequest.Builder(
206                                 datasourceId, parseFhirVersion(R4VersionString), data)
207                         .build();
208         Bundle requestBundle = BundleHelper.fromUpsertMedicalResourceRequests(List.of(request));
209         Bundle responseBundle = getFromTestApp(requestBundle);
210         return BundleHelper.toMedicalResources(responseBundle).get(0);
211     }
212 
213     /**
214      * Reads a list of {@link MedicalResource}s for the provided {@code request} on behalf of the
215      * app.
216      */
readMedicalResources(ReadMedicalResourcesRequest request)217     public ReadMedicalResourcesResponse readMedicalResources(ReadMedicalResourcesRequest request)
218             throws Exception {
219         Bundle requestBundle = BundleHelper.fromReadMedicalResourcesRequest(request);
220         Bundle responseBundle = getFromTestApp(requestBundle);
221         return BundleHelper.toReadMedicalResourcesResponse(responseBundle);
222     }
223 
224     /**
225      * Reads a list of {@link MedicalResource}s for the provided {@code ids} on behalf of the app.
226      */
readMedicalResources(List<MedicalResourceId> ids)227     public List<MedicalResource> readMedicalResources(List<MedicalResourceId> ids)
228             throws Exception {
229         Bundle requestBundle = BundleHelper.fromMedicalResourceIdsForRead(ids);
230         Bundle responseBundle = getFromTestApp(requestBundle);
231         return BundleHelper.toMedicalResources(responseBundle);
232     }
233 
234     /** Deletes Medical Resources from HC on behalf of the app for the given {@code ids}. */
deleteMedicalResources(List<MedicalResourceId> ids)235     public void deleteMedicalResources(List<MedicalResourceId> ids) throws Exception {
236         Bundle requestBundle = BundleHelper.fromMedicalResourceIdsForDelete(ids);
237         getFromTestApp(requestBundle);
238     }
239 
240     /** Deletes Medical Resources from HC on behalf of the app for the given {@code request}. */
deleteMedicalResources(DeleteMedicalResourcesRequest request)241     public void deleteMedicalResources(DeleteMedicalResourcesRequest request) throws Exception {
242         Bundle requestBundle = BundleHelper.fromDeleteMedicalResourcesRequest(request);
243         getFromTestApp(requestBundle);
244     }
245 
246     /** Deletes Medical Data Source with data for the provided {@code id} on behalf of the app. */
deleteMedicalDataSourceWithData(String id)247     public void deleteMedicalDataSourceWithData(String id) throws Exception {
248         Bundle requestBundle = BundleHelper.fromMedicalDataSourceId(id);
249         getFromTestApp(requestBundle);
250     }
251 
252     /** Instructs the app to self-revokes the specified permission. */
selfRevokePermission(String permission)253     public void selfRevokePermission(String permission) throws Exception {
254         Bundle requestBundle = BundleHelper.forSelfRevokePermissionRequest(permission);
255         getFromTestApp(requestBundle);
256     }
257 
258     /** Instructs the app to kill itself. */
kill()259     public void kill() throws Exception {
260         Bundle requestBundle = BundleHelper.forKillSelfRequest();
261         getFromTestApp(requestBundle);
262     }
263 
264     /** Starts an activity on behalf of the app and returns the result. */
startActivityForResult(Intent intent)265     public Instrumentation.ActivityResult startActivityForResult(Intent intent) throws Exception {
266         return startActivityForResult(intent, null);
267     }
268 
269     /**
270      * Starts an activity on behalf of the app, executes the runnable and returns the result.
271      *
272      * <p>The corresponding test app must have the following activity declared in the Manifest.
273      *
274      * <pre>{@code
275      * <activity android:name="android.healthconnect.cts.utils.ProxyActivity"
276      *           android:exported="true">
277      *   <intent-filter>
278      *      <action android:name="android.healthconnect.cts.ACTION_START_ACTIVITY_FOR_RESULT"/>
279      *      <category android:name="android.intent.category.DEFAULT"/>
280      *   </intent-filter>
281      * </activity>
282      * }</pre>
283      */
startActivityForResult(Intent intent, Runnable runnable)284     public Instrumentation.ActivityResult startActivityForResult(Intent intent, Runnable runnable)
285             throws Exception {
286         Intent testAppIntent = new Intent(ProxyActivity.PROXY_ACTIVITY_ACTION);
287         testAppIntent.setPackage(mPackageName);
288         testAppIntent.putExtra(Intent.EXTRA_INTENT, intent);
289 
290         return ProxyActivity.launchActivityForResult(testAppIntent, runnable);
291     }
292 
getFromTestApp(Bundle bundleToCreateIntent)293     private Bundle getFromTestApp(Bundle bundleToCreateIntent) throws Exception {
294         final CountDownLatch latch = new CountDownLatch(1);
295         AtomicReference<Bundle> response = new AtomicReference<>();
296         AtomicReference<Exception> exceptionAtomicReference = new AtomicReference<>();
297         BroadcastReceiver broadcastReceiver =
298                 new BroadcastReceiver() {
299                     @Override
300                     public void onReceive(Context context, Intent intent) {
301                         if (intent.hasExtra(INTENT_EXCEPTION)) {
302                             exceptionAtomicReference.set(
303                                     (Exception) (intent.getSerializableExtra(INTENT_EXCEPTION)));
304                         } else {
305                             response.set(intent.getExtras());
306                         }
307                         latch.countDown();
308                     }
309                 };
310 
311         launchTestApp(bundleToCreateIntent, broadcastReceiver, latch);
312         if (exceptionAtomicReference.get() != null) {
313             throw exceptionAtomicReference.get();
314         }
315         return response.get();
316     }
317 
launchTestApp( Bundle bundleToCreateIntent, BroadcastReceiver broadcastReceiver, CountDownLatch latch)318     private void launchTestApp(
319             Bundle bundleToCreateIntent, BroadcastReceiver broadcastReceiver, CountDownLatch latch)
320             throws Exception {
321 
322         // Register broadcast receiver
323         final IntentFilter intentFilter = new IntentFilter();
324         intentFilter.addAction(bundleToCreateIntent.getString(QUERY_TYPE));
325         intentFilter.addCategory(Intent.CATEGORY_DEFAULT);
326         getContext().registerReceiver(broadcastReceiver, intentFilter, Context.RECEIVER_EXPORTED);
327 
328         // Launch the test app.
329         Intent intent;
330 
331         if (mInBackground) {
332             intent = new Intent().setClassName(mPackageName, TEST_APP_RECEIVER_CLASS_NAME);
333         } else {
334             intent = new Intent(Intent.ACTION_MAIN);
335             intent.setPackage(mPackageName);
336             intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
337             intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
338             intent.addCategory(Intent.CATEGORY_LAUNCHER);
339         }
340 
341         intent.putExtras(bundleToCreateIntent);
342 
343         Thread.sleep(500);
344 
345         if (mInBackground) {
346             getContext().sendBroadcast(intent);
347         } else {
348             getContext().startActivity(intent);
349         }
350 
351         // We don't wait for responses to kill requests. These kill the app & there is no easy or
352         // reliable way for the app to return a broadcast before being killed.
353         boolean isKillRequest =
354                 bundleToCreateIntent.getString(QUERY_TYPE).equals(KILL_SELF_REQUEST);
355         if (!isKillRequest && !latch.await(POLLING_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)) {
356             final String errorMessage =
357                     "Timed out while waiting to receive "
358                             + bundleToCreateIntent.getString(QUERY_TYPE)
359                             + " intent from "
360                             + mPackageName;
361             throw new TimeoutException(errorMessage);
362         }
363         getContext().unregisterReceiver(broadcastReceiver);
364     }
365 }
366