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.test.app;
18 
19 import static android.health.connect.datatypes.StepsRecord.STEPS_COUNT_TOTAL;
20 
21 import static java.time.temporal.ChronoUnit.HOURS;
22 import static java.util.Objects.requireNonNull;
23 import static java.util.concurrent.Executors.newSingleThreadExecutor;
24 
25 import android.content.BroadcastReceiver;
26 import android.content.Context;
27 import android.content.Intent;
28 import android.health.connect.AggregateRecordsRequest;
29 import android.health.connect.AggregateRecordsResponse;
30 import android.health.connect.HealthConnectException;
31 import android.health.connect.HealthConnectManager;
32 import android.health.connect.InsertRecordsResponse;
33 import android.health.connect.ReadRecordsRequestUsingFilters;
34 import android.health.connect.ReadRecordsRequestUsingIds;
35 import android.health.connect.ReadRecordsResponse;
36 import android.health.connect.TimeInstantRangeFilter;
37 import android.health.connect.changelog.ChangeLogTokenRequest;
38 import android.health.connect.changelog.ChangeLogTokenResponse;
39 import android.health.connect.changelog.ChangeLogsRequest;
40 import android.health.connect.changelog.ChangeLogsResponse;
41 import android.health.connect.datatypes.ActiveCaloriesBurnedRecord;
42 import android.health.connect.datatypes.DataOrigin;
43 import android.health.connect.datatypes.ExerciseSessionRecord;
44 import android.health.connect.datatypes.ExerciseSessionType;
45 import android.health.connect.datatypes.Metadata;
46 import android.health.connect.datatypes.PlannedExerciseSessionRecord;
47 import android.health.connect.datatypes.Record;
48 import android.health.connect.datatypes.StepsRecord;
49 import android.health.connect.datatypes.WeightRecord;
50 import android.health.connect.datatypes.units.Mass;
51 import android.os.Bundle;
52 
53 import java.time.Instant;
54 import java.util.ArrayList;
55 import java.util.Arrays;
56 import java.util.List;
57 import java.util.stream.Collectors;
58 
59 /** Receives requests from test cases. Required to perform API calls in background. */
60 public class TestAppReceiver extends BroadcastReceiver {
61     public static final String ACTION_INSERT_STEPS_RECORDS = "action.INSERT_STEPS_RECORDS";
62     public static final String ACTION_INSERT_WEIGHT_RECORDS = "action.INSERT_WEIGHT_RECORDS";
63     public static final String ACTION_INSERT_EXERCISE_RECORD = "action.INSERT_EXERCISE_RECORD";
64     public static final String ACTION_INSERT_PLANNED_EXERCISE_RECORD =
65             "action.INSERT_PLANNED_EXERCISE_RECORD";
66     public static final String ACTION_READ_STEPS_RECORDS_USING_FILTERS =
67             "action.READ_STEPS_RECORDS_USING_FILTERS";
68     public static final String ACTION_READ_STEPS_RECORDS_USING_RECORD_IDS =
69             "action.READ_STEPS_RECORDS_USING_RECORD_IDS";
70     public static final String ACTION_AGGREGATE_STEPS_COUNT = "action.AGGREGATE_STEPS_COUNT";
71     public static final String ACTION_GET_CHANGE_LOG_TOKEN = "action.GET_CHANGE_LOG_TOKEN";
72     public static final String ACTION_GET_CHANGE_LOGS = "action.GET_CHANGE_LOGS";
73     public static final String ACTION_RESULT_SUCCESS = "action.SUCCESS";
74     public static final String ACTION_RESULT_ERROR = "action.ERROR";
75     public static final String EXTRA_RESULT_ERROR_CODE = "extra.ERROR_CODE";
76     public static final String EXTRA_RESULT_ERROR_MESSAGE = "extra.ERROR_MESSAGE";
77     public static final String EXTRA_RECORD_COUNT = "extra.RECORD_COUNT";
78     public static final String EXTRA_RECORD_IDS = "extra.RECORD_IDS";
79     public static final String EXTRA_RECORD_CLIENT_IDS = "extra.RECORD_CLIENT_IDS";
80 
81     /**
82      * This is used to represent either times for InstantRecords or start times for IntervalRecords.
83      */
84     public static final String EXTRA_TIMES = "extra.TIMES";
85 
86     public static final String EXTRA_END_TIMES = "extra.END_TIMES";
87 
88     /** Represents a list of values in {@code long}. */
89     public static final String EXTRA_RECORD_VALUES = "extra.RECORD_VALUES";
90 
91     /** Represents a long value. */
92     public static final String EXTRA_RECORD_VALUE = "extra.RECORD_VALUE";
93 
94     /** This is used to represent the ID of a training plan completed by an exercise. */
95     public static final String EXTRA_PLANNED_EXERCISE_SESSION_ID =
96             "extra.PLANNED_EXERCISE_SESSION_ID";
97 
98     public static final String EXTRA_TOKEN = "extra.TOKEN";
99 
100     /** Extra for a list of package names. */
101     public static final String EXTRA_PACKAGE_NAMES = "extra.PACKAGE_NAMES";
102 
103     public static final String EXTRA_SENDER_PACKAGE_NAME = "extra.SENDER_PACKAGE_NAME";
104     private static final String TEST_SUITE_RECEIVER =
105             "android.healthconnect.cts.utils.TestReceiver";
106 
107     @Override
onReceive(Context context, Intent intent)108     public void onReceive(Context context, Intent intent) {
109         switch (intent.getAction()) {
110             case ACTION_INSERT_STEPS_RECORDS:
111                 insertStepsRecords(context, intent);
112                 break;
113             case ACTION_INSERT_WEIGHT_RECORDS:
114                 insertWeightRecords(context, intent);
115                 break;
116             case ACTION_INSERT_EXERCISE_RECORD:
117                 insertExerciseRecord(context, intent);
118                 break;
119             case ACTION_INSERT_PLANNED_EXERCISE_RECORD:
120                 insertPlannedExerciseRecord(context, intent);
121                 break;
122             case ACTION_READ_STEPS_RECORDS_USING_FILTERS:
123                 readStepsRecordsUsingFilters(context, intent);
124                 break;
125             case ACTION_READ_STEPS_RECORDS_USING_RECORD_IDS:
126                 readStepsRecordsUsingIds(context, intent);
127                 break;
128             case ACTION_AGGREGATE_STEPS_COUNT:
129                 aggregateStepsCount(context, intent);
130                 break;
131             case ACTION_GET_CHANGE_LOG_TOKEN:
132                 getChangeLogToken(context, intent);
133                 break;
134             case ACTION_GET_CHANGE_LOGS:
135                 getChangeLogs(context, intent);
136                 break;
137             default:
138                 throw new IllegalStateException("Unsupported command: " + intent.getAction());
139         }
140     }
141 
insertStepsRecords(Context context, Intent intent)142     private static void insertStepsRecords(Context context, Intent intent) {
143         DefaultOutcomeReceiver<InsertRecordsResponse> outcome = new DefaultOutcomeReceiver<>();
144         getHealthConnectManager(context)
145                 .insertRecords(createStepsRecords(intent), newSingleThreadExecutor(), outcome);
146         sendInsertRecordsResult(context, intent, outcome);
147     }
148 
insertWeightRecords(Context context, Intent intent)149     private static void insertWeightRecords(Context context, Intent intent) {
150         DefaultOutcomeReceiver<InsertRecordsResponse> outcome = new DefaultOutcomeReceiver<>();
151         getHealthConnectManager(context)
152                 .insertRecords(createWeightRecords(intent), newSingleThreadExecutor(), outcome);
153         sendInsertRecordsResult(context, intent, outcome);
154     }
155 
insertExerciseRecord(Context context, Intent intent)156     private static void insertExerciseRecord(Context context, Intent intent) {
157         DefaultOutcomeReceiver<InsertRecordsResponse> outcome = new DefaultOutcomeReceiver<>();
158         getHealthConnectManager(context)
159                 .insertRecords(
160                         List.of(createExerciseRecord(intent)), newSingleThreadExecutor(), outcome);
161         sendInsertRecordsResult(context, intent, outcome);
162     }
163 
insertPlannedExerciseRecord(Context context, Intent intent)164     private static void insertPlannedExerciseRecord(Context context, Intent intent) {
165         DefaultOutcomeReceiver<InsertRecordsResponse> outcome = new DefaultOutcomeReceiver<>();
166         getHealthConnectManager(context)
167                 .insertRecords(
168                         List.of(createPlannedExerciseRecord(intent)),
169                         newSingleThreadExecutor(),
170                         outcome);
171         sendInsertRecordsResult(context, intent, outcome);
172     }
173 
readStepsRecordsUsingFilters(Context context, Intent intent)174     private void readStepsRecordsUsingFilters(Context context, Intent intent) {
175         DefaultOutcomeReceiver<ReadRecordsResponse<StepsRecord>> outcome =
176                 new DefaultOutcomeReceiver<>();
177         ReadRecordsRequestUsingFilters.Builder<StepsRecord> requestBuilder =
178                 new ReadRecordsRequestUsingFilters.Builder<>(StepsRecord.class);
179         for (String packageName : getPackageNames(intent)) {
180             requestBuilder.addDataOrigins(
181                     new DataOrigin.Builder().setPackageName(packageName).build());
182         }
183         getHealthConnectManager(context)
184                 .readRecords(requestBuilder.build(), newSingleThreadExecutor(), outcome);
185         sendReadRecordsResult(context, intent, outcome);
186     }
187 
readStepsRecordsUsingIds(Context context, Intent intent)188     private void readStepsRecordsUsingIds(Context context, Intent intent) {
189         DefaultOutcomeReceiver<ReadRecordsResponse<StepsRecord>> outcome =
190                 new DefaultOutcomeReceiver<>();
191         ReadRecordsRequestUsingIds.Builder<StepsRecord> requestBuilder =
192                 new ReadRecordsRequestUsingIds.Builder<>(StepsRecord.class);
193         List<String> recordIds = getRecordIds(intent);
194         for (String recordId : recordIds) {
195             requestBuilder.addId(recordId);
196         }
197         getHealthConnectManager(context)
198                 .readRecords(requestBuilder.build(), newSingleThreadExecutor(), outcome);
199         sendReadRecordsResult(context, intent, outcome);
200     }
201 
aggregateStepsCount(Context context, Intent intent)202     private void aggregateStepsCount(Context context, Intent intent) {
203         DefaultOutcomeReceiver<AggregateRecordsResponse<Long>> outcome =
204                 new DefaultOutcomeReceiver<>();
205 
206         AggregateRecordsRequest.Builder<Long> requestBuilder =
207                 new AggregateRecordsRequest.Builder<Long>(
208                                 new TimeInstantRangeFilter.Builder()
209                                         .setStartTime(Instant.EPOCH)
210                                         .setEndTime(Instant.now().plus(10, HOURS))
211                                         .build())
212                         .addAggregationType(STEPS_COUNT_TOTAL);
213         for (String packageName : getPackageNames(intent)) {
214             requestBuilder.addDataOriginsFilter(
215                     new DataOrigin.Builder().setPackageName(packageName).build());
216         }
217         getHealthConnectManager(context)
218                 .aggregate(requestBuilder.build(), newSingleThreadExecutor(), outcome);
219 
220         sendAggregateStepsResult(context, intent, outcome);
221     }
222 
getChangeLogToken(Context context, Intent intent)223     private void getChangeLogToken(Context context, Intent intent) {
224         DefaultOutcomeReceiver<ChangeLogTokenResponse> outcome = new DefaultOutcomeReceiver<>();
225 
226         getHealthConnectManager(context)
227                 .getChangeLogToken(
228                         new ChangeLogTokenRequest.Builder()
229                                 .addRecordType(ActiveCaloriesBurnedRecord.class)
230                                 .build(),
231                         newSingleThreadExecutor(),
232                         outcome);
233 
234         final HealthConnectException error = outcome.getError();
235         if (error == null) {
236             final Bundle extras = new Bundle();
237             extras.putString(EXTRA_TOKEN, outcome.getResult().getToken());
238             sendSuccess(context, intent, extras);
239         } else {
240             sendError(context, intent, error);
241         }
242     }
243 
getChangeLogs(Context context, Intent intent)244     private void getChangeLogs(Context context, Intent intent) {
245         String token = intent.getStringExtra(EXTRA_TOKEN);
246         DefaultOutcomeReceiver<ChangeLogsResponse> outcome = new DefaultOutcomeReceiver<>();
247 
248         getHealthConnectManager(context)
249                 .getChangeLogs(
250                         new ChangeLogsRequest.Builder(token).build(),
251                         newSingleThreadExecutor(),
252                         outcome);
253 
254         sendResult(context, intent, outcome);
255     }
256 
getHealthConnectManager(Context context)257     private static HealthConnectManager getHealthConnectManager(Context context) {
258         return requireNonNull(context.getSystemService(HealthConnectManager.class));
259     }
260 
sendReadRecordsResult( Context context, Intent intent, DefaultOutcomeReceiver<? extends ReadRecordsResponse<?>> outcome)261     private static void sendReadRecordsResult(
262             Context context,
263             Intent intent,
264             DefaultOutcomeReceiver<? extends ReadRecordsResponse<?>> outcome) {
265         final HealthConnectException error = outcome.getError();
266         if (error != null) {
267             sendError(context, intent, error);
268             return;
269         }
270 
271         final Bundle extras = new Bundle();
272         List<? extends Record> records = outcome.getResult().getRecords();
273         extras.putInt(EXTRA_RECORD_COUNT, records.size());
274         extras.putStringArrayList(EXTRA_RECORD_IDS, new ArrayList<>(getRecordIds(records)));
275         sendSuccess(context, intent, extras);
276     }
277 
sendAggregateStepsResult( Context context, Intent intent, DefaultOutcomeReceiver<? extends AggregateRecordsResponse<Long>> outcome)278     private static void sendAggregateStepsResult(
279             Context context,
280             Intent intent,
281             DefaultOutcomeReceiver<? extends AggregateRecordsResponse<Long>> outcome) {
282         final HealthConnectException error = outcome.getError();
283         if (error != null) {
284             sendError(context, intent, error);
285             return;
286         }
287 
288         Bundle extras = new Bundle();
289         long stepCounts = outcome.getResult().get(STEPS_COUNT_TOTAL);
290         extras.putLong(EXTRA_RECORD_VALUE, stepCounts);
291         sendSuccess(context, intent, extras);
292     }
293 
sendInsertRecordsResult( Context context, Intent intent, DefaultOutcomeReceiver<? extends InsertRecordsResponse> outcome)294     private static void sendInsertRecordsResult(
295             Context context,
296             Intent intent,
297             DefaultOutcomeReceiver<? extends InsertRecordsResponse> outcome) {
298         final HealthConnectException error = outcome.getError();
299         if (error != null) {
300             sendError(context, intent, error);
301             return;
302         }
303 
304         final Bundle extras = new Bundle();
305         List<? extends Record> records = outcome.getResult().getRecords();
306         ArrayList<String> recordIds =
307                 new ArrayList<>(
308                         records.stream()
309                                 .map(Record::getMetadata)
310                                 .map(Metadata::getId)
311                                 .collect(Collectors.toList()));
312         extras.putStringArrayList(EXTRA_RECORD_IDS, recordIds);
313         extras.putInt(EXTRA_RECORD_COUNT, records.size());
314         sendSuccess(context, intent, extras);
315     }
316 
sendResult( Context context, Intent intent, DefaultOutcomeReceiver<?> outcomeReceiver)317     private static void sendResult(
318             Context context, Intent intent, DefaultOutcomeReceiver<?> outcomeReceiver) {
319         final HealthConnectException error = outcomeReceiver.getError();
320         if (error != null) {
321             sendError(context, intent, error);
322             return;
323         }
324         sendSuccess(context, intent);
325     }
326 
sendSuccess(Context context, Intent intent)327     private static void sendSuccess(Context context, Intent intent) {
328         context.sendBroadcast(getSuccessIntent(intent));
329     }
330 
sendSuccess(Context context, Intent intent, Bundle extras)331     private static void sendSuccess(Context context, Intent intent, Bundle extras) {
332         context.sendBroadcast(getSuccessIntent(intent).putExtras(extras));
333     }
334 
getSuccessIntent(Intent intent)335     private static Intent getSuccessIntent(Intent intent) {
336         return new Intent(ACTION_RESULT_SUCCESS)
337                 .setClassName(getSenderPackageName(intent), TEST_SUITE_RECEIVER);
338     }
339 
sendError(Context context, Intent intent, HealthConnectException error)340     private static void sendError(Context context, Intent intent, HealthConnectException error) {
341         context.sendBroadcast(
342                 new Intent(ACTION_RESULT_ERROR)
343                         .setClassName(getSenderPackageName(intent), TEST_SUITE_RECEIVER)
344                         .putExtra(EXTRA_RESULT_ERROR_CODE, error.getErrorCode())
345                         .putExtra(EXTRA_RESULT_ERROR_MESSAGE, error.getMessage()));
346     }
347 
createStepsRecords(Intent intent)348     private static List<Record> createStepsRecords(Intent intent) {
349         List<Instant> startTimes = getTimes(intent, EXTRA_TIMES);
350         List<Instant> endTimes = getTimes(intent, EXTRA_END_TIMES);
351         String[] clientIds = intent.getStringArrayExtra(EXTRA_RECORD_CLIENT_IDS);
352         long[] values = intent.getLongArrayExtra(EXTRA_RECORD_VALUES);
353 
354         List<Record> result = new ArrayList<>();
355         for (int i = 0; i < startTimes.size(); i++) {
356             result.add(
357                     createStepsRecord(startTimes.get(i), endTimes.get(i), clientIds[i], values[i]));
358         }
359         return result;
360     }
361 
createStepsRecord( Instant startTime, Instant endTime, String clientId, long steps)362     private static StepsRecord createStepsRecord(
363             Instant startTime, Instant endTime, String clientId, long steps) {
364         Metadata.Builder metadataBuilder = new Metadata.Builder();
365         metadataBuilder.setClientRecordId(clientId);
366         return new StepsRecord.Builder(metadataBuilder.build(), startTime, endTime, steps).build();
367     }
368 
createWeightRecords(Intent intent)369     private static List<Record> createWeightRecords(Intent intent) {
370         List<Instant> times = getTimes(intent, EXTRA_TIMES);
371         String[] clientIds = intent.getStringArrayExtra(EXTRA_RECORD_CLIENT_IDS);
372         double[] values = intent.getDoubleArrayExtra(EXTRA_RECORD_VALUES);
373 
374         List<Record> result = new ArrayList<>();
375         for (int i = 0; i < times.size(); i++) {
376             result.add(createWeightRecord(times.get(i), clientIds[i], values[i]));
377         }
378         return result;
379     }
380 
createExerciseRecord(Intent intent)381     private static Record createExerciseRecord(Intent intent) {
382         String trainingPlanId = intent.getStringExtra(EXTRA_PLANNED_EXERCISE_SESSION_ID);
383         ExerciseSessionRecord record =
384                 new ExerciseSessionRecord.Builder(
385                                 new Metadata.Builder().build(),
386                                 getTimes(intent, EXTRA_TIMES).get(0),
387                                 getTimes(intent, EXTRA_END_TIMES).get(0),
388                                 ExerciseSessionType.EXERCISE_SESSION_TYPE_BIKING)
389                         .setPlannedExerciseSessionId(trainingPlanId)
390                         .build();
391         return record;
392     }
393 
createPlannedExerciseRecord(Intent intent)394     private static Record createPlannedExerciseRecord(Intent intent) {
395         PlannedExerciseSessionRecord record =
396                 new PlannedExerciseSessionRecord.Builder(
397                                 new Metadata.Builder().build(),
398                                 ExerciseSessionType.EXERCISE_SESSION_TYPE_BIKING,
399                                 getTimes(intent, EXTRA_TIMES).get(0),
400                                 getTimes(intent, EXTRA_END_TIMES).get(0))
401                         .build();
402         return record;
403     }
404 
createWeightRecord(Instant time, String clientId, double weight)405     private static WeightRecord createWeightRecord(Instant time, String clientId, double weight) {
406         return new WeightRecord.Builder(
407                         new Metadata.Builder().setClientRecordId(clientId).build(),
408                         time,
409                         Mass.fromGrams(weight))
410                 .build();
411     }
412 
getTimes(Intent intent, String key)413     private static List<Instant> getTimes(Intent intent, String key) {
414         return Arrays.stream(intent.getLongArrayExtra(key))
415                 .mapToObj(Instant::ofEpochMilli)
416                 .collect(Collectors.toList());
417     }
418 
getPackageNames(Intent intent)419     private static List<String> getPackageNames(Intent intent) {
420         List<String> packageNames = intent.getStringArrayListExtra(EXTRA_PACKAGE_NAMES);
421         return packageNames == null ? new ArrayList<>() : packageNames;
422     }
423 
getRecordIds(Intent intent)424     private static List<String> getRecordIds(Intent intent) {
425         List<String> recordIds = intent.getStringArrayListExtra(EXTRA_RECORD_IDS);
426         return recordIds == null ? new ArrayList<>() : recordIds;
427     }
428 
getSenderPackageName(Intent intent)429     private static String getSenderPackageName(Intent intent) {
430         return intent.getStringExtra(EXTRA_SENDER_PACKAGE_NAME);
431     }
432 
getRecordIds(List<? extends Record> records)433     private static List<String> getRecordIds(List<? extends Record> records) {
434         return records.stream().map(Record::getMetadata).map(Metadata::getId).toList();
435     }
436 }
437