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 android.health.connect.CreateMedicalDataSourceRequest;
20 import android.health.connect.DeleteMedicalResourcesRequest;
21 import android.health.connect.GetMedicalDataSourcesRequest;
22 import android.health.connect.MedicalResourceId;
23 import android.health.connect.ReadMedicalResourcesInitialRequest;
24 import android.health.connect.ReadMedicalResourcesPageRequest;
25 import android.health.connect.ReadMedicalResourcesRequest;
26 import android.health.connect.ReadMedicalResourcesResponse;
27 import android.health.connect.ReadRecordsRequestUsingFilters;
28 import android.health.connect.ReadRecordsRequestUsingIds;
29 import android.health.connect.RecordIdFilter;
30 import android.health.connect.TimeInstantRangeFilter;
31 import android.health.connect.UpsertMedicalResourceRequest;
32 import android.health.connect.changelog.ChangeLogTokenRequest;
33 import android.health.connect.changelog.ChangeLogsRequest;
34 import android.health.connect.changelog.ChangeLogsResponse;
35 import android.health.connect.datatypes.BasalMetabolicRateRecord;
36 import android.health.connect.datatypes.DataOrigin;
37 import android.health.connect.datatypes.Device;
38 import android.health.connect.datatypes.DistanceRecord;
39 import android.health.connect.datatypes.ExerciseLap;
40 import android.health.connect.datatypes.ExerciseRoute;
41 import android.health.connect.datatypes.ExerciseSegment;
42 import android.health.connect.datatypes.ExerciseSessionRecord;
43 import android.health.connect.datatypes.HeartRateRecord;
44 import android.health.connect.datatypes.InstantRecord;
45 import android.health.connect.datatypes.IntervalRecord;
46 import android.health.connect.datatypes.MedicalDataSource;
47 import android.health.connect.datatypes.MedicalResource;
48 import android.health.connect.datatypes.MenstruationPeriodRecord;
49 import android.health.connect.datatypes.Metadata;
50 import android.health.connect.datatypes.Record;
51 import android.health.connect.datatypes.SleepSessionRecord;
52 import android.health.connect.datatypes.SleepSessionRecord.Stage;
53 import android.health.connect.datatypes.StepsRecord;
54 import android.health.connect.datatypes.TotalCaloriesBurnedRecord;
55 import android.health.connect.datatypes.units.Energy;
56 import android.health.connect.datatypes.units.Length;
57 import android.health.connect.datatypes.units.Power;
58 import android.healthconnect.cts.utils.ToStringUtils;
59 import android.os.Bundle;
60 import android.util.Log;
61 
62 import java.lang.reflect.InvocationTargetException;
63 import java.time.Instant;
64 import java.time.ZoneOffset;
65 import java.util.ArrayList;
66 import java.util.HashSet;
67 import java.util.List;
68 import java.util.Objects;
69 import java.util.Set;
70 import java.util.function.Consumer;
71 import java.util.function.Function;
72 import java.util.stream.IntStream;
73 
74 /** Converters from/to bundles for HC request, response, and record types. */
75 public final class BundleHelper {
76     private static final String TAG = "TestApp-BundleHelper";
77     static final String PREFIX = "android.healthconnect.cts.";
78     public static final String QUERY_TYPE = PREFIX + "QUERY_TYPE";
79     public static final String INSERT_RECORDS_QUERY = PREFIX + "INSERT_RECORDS_QUERY";
80     public static final String READ_RECORDS_QUERY = PREFIX + "READ_RECORDS_QUERY";
81     public static final String READ_RECORDS_USING_IDS_QUERY =
82             PREFIX + "READ_RECORDS_USING_IDS_QUERY";
83     public static final String READ_CHANGE_LOGS_QUERY = PREFIX + "READ_CHANGE_LOGS_QUERY";
84     public static final String DELETE_RECORDS_QUERY = PREFIX + "DELETE_RECORDS_QUERY";
85     public static final String UPDATE_RECORDS_QUERY = PREFIX + "UPDATE_RECORDS_QUERY";
86     public static final String GET_CHANGE_LOG_TOKEN_QUERY = PREFIX + "GET_CHANGE_LOG_TOKEN_QUERY";
87     public static final String CREATE_MEDICAL_DATA_SOURCE_QUERY =
88             PREFIX + "CREATE_MEDICAL_DATA_SOURCE_QUERY";
89     public static final String GET_MEDICAL_DATA_SOURCES_USING_IDS_QUERY =
90             PREFIX + "GET_MEDICAL_DATA_SOURCES_USING_IDS_QUERY";
91     public static final String GET_MEDICAL_DATA_SOURCES_USING_REQUEST_QUERY =
92             PREFIX + "GET_MEDICAL_DATA_SOURCES_USING_REQUEST_QUERY";
93     public static final String UPSERT_MEDICAL_RESOURCES_QUERY =
94             PREFIX + "UPSERT_MEDICAL_RESOURCE_QUERY";
95     public static final String READ_MEDICAL_RESOURCES_BY_REQUEST_QUERY =
96             PREFIX + "READ_MEDICAL_RESOURCES_BY_REQUEST_QUERY";
97     public static final String READ_MEDICAL_RESOURCES_BY_IDS_QUERY =
98             PREFIX + "READ_MEDICAL_RESOURCES_BY_IDS_QUERY";
99     public static final String DELETE_MEDICAL_RESOURCES_BY_REQUEST_QUERY =
100             PREFIX + "DELETE_MEDICAL_RESOURCES_BY_REQUEST_QUERY";
101     public static final String DELETE_MEDICAL_RESOURCES_BY_IDS_QUERY =
102             PREFIX + "DELETE_MEDICAL_RESOURCES_BY_IDS_QUERY";
103     public static final String DELETE_MEDICAL_DATA_SOURCE_WITH_DATA_QUERY =
104             PREFIX + "DELETE_MEDICAL_DATA_SOURCE_WITH_DATA_QUERY";
105 
106     private static final String CREATE_MEDICAL_DATA_SOURCE_REQUEST =
107             PREFIX + "CREATE_MEDICAL_DATA_SOURCE_REQUEST";
108     private static final String GET_MEDICAL_DATA_SOURCES_REQUEST =
109             PREFIX + "GET_MEDICAL_DATA_SOURCES_REQUEST";
110     public static final String MEDICAL_DATA_SOURCE_RESPONSE =
111             PREFIX + "MEDICAL_DATA_SOURCE_RESPONSE";
112     public static final String MEDICAL_DATA_SOURCES_RESPONSE =
113             PREFIX + "MEDICAL_DATA_SOURCE_RESPONSE";
114     private static final String UPSERT_MEDICAL_RESOURCE_REQUESTS =
115             PREFIX + "UPSERT_MEDICAL_RESOURCE_REQUEST";
116     private static final String READ_MEDICAL_RESOURCES_REQUEST_IS_PAGE_REQUEST =
117             PREFIX + "READ_MEDICAL_RESOURCES_REQUEST_IS_PAGE_REQUEST";
118     private static final String READ_MEDICAL_RESOURCES_REQUEST_MEDICAL_RESOURCE_TYPE =
119             PREFIX + "READ_MEDICAL_RESOURCES_REQUEST_MEDICAL_RESOURCE_TYPE";
120     private static final String READ_MEDICAL_RESOURCES_REQUEST_DATA_SOURCE_IDS =
121             PREFIX + "READ_MEDICAL_RESOURCES_REQUEST_DATA_SOURCE_IDS";
122     private static final String READ_MEDICAL_RESOURCES_REQUEST_PAGE_TOKEN =
123             PREFIX + "READ_MEDICAL_RESOURCES_REQUEST_PAGE_TOKEN";
124     private static final String READ_MEDICAL_RESOURCES_REQUEST_PAGE_SIZE =
125             PREFIX + "READ_MEDICAL_RESOURCES_REQUEST_PAGE_SIZE";
126     private static final String MEDICAL_RESOURCE_IDS = PREFIX + "MEDICAL_RESOURCE_IDS";
127     public static final String MEDICAL_RESOURCES_RESPONSE = PREFIX + "MEDICAL_RESOURCE_RESPONSE";
128     public static final String READ_MEDICAL_RESOURCES_RESPONSE =
129             PREFIX + "READ_MEDICAL_RESOURCES_RESPONSE";
130     private static final String DELETE_MEDICAL_RESOURCES_REQUEST =
131             PREFIX + "DELETE_MEDICAL_RESOURCES_REQUEST";
132 
133     public static final String SELF_REVOKE_PERMISSION_REQUEST =
134             PREFIX + "SELF_REVOKE_PERMISSION_REQUEST";
135 
136     public static final String KILL_SELF_REQUEST = PREFIX + "KILL_SELF_REQUEST";
137 
138     public static final String INTENT_EXCEPTION = PREFIX + "INTENT_EXCEPTION";
139 
140     private static final String CHANGE_LOGS_RESPONSE = PREFIX + "CHANGE_LOGS_RESPONSE";
141     private static final String CHANGE_LOG_TOKEN = PREFIX + "CHANGE_LOG_TOKEN";
142     private static final String RECORD_CLASS_NAME = PREFIX + "RECORD_CLASS_NAME";
143     private static final String START_TIME_MILLIS = PREFIX + "START_TIME_MILLIS";
144     private static final String END_TIME_MILLIS = PREFIX + "END_TIME_MILLIS";
145     private static final String EXERCISE_SESSION_TYPE = PREFIX + "EXERCISE_SESSION_TYPE";
146     private static final String RECORD_LIST = PREFIX + "RECORD_LIST";
147     private static final String PACKAGE_NAME = PREFIX + "PACKAGE_NAME";
148     private static final String CLIENT_ID = PREFIX + "CLIENT_ID";
149     private static final String RECORD_ID = PREFIX + "RECORD_ID";
150     private static final String MEDICAL_DATA_SOURCE_ID = PREFIX + "MEDICAL_DATA_SOURCE_ID";
151     private static final String METADATA = PREFIX + "METADATA";
152     private static final String DEVICE = PREFIX + "DEVICE";
153     private static final String DEVICE_TYPE = PREFIX + "DEVICE_TYPE";
154     private static final String MANUFACTURER = PREFIX + "MANUFACTURER";
155     private static final String MODEL = PREFIX + "MODEL";
156     private static final String VALUES = PREFIX + "VALUES";
157     private static final String COUNT = PREFIX + "COUNT";
158     private static final String LENGTH_IN_METERS = PREFIX + "LENGTH_IN_METERS";
159     private static final String ENERGY_IN_CALORIES = PREFIX + "ENERGY_IN_CALORIES";
160     private static final String SAMPLE_TIMES = PREFIX + "SAMPLE_TIMES";
161     private static final String SAMPLE_VALUES = PREFIX + "SAMPLE_VALUES";
162     private static final String EXERCISE_ROUTE_TIMESTAMPS = PREFIX + "EXERCISE_ROUTE_TIMESTAMPS";
163     private static final String EXERCISE_ROUTE_LATITUDES = PREFIX + "EXERCISE_ROUTE_LATITUDES";
164     private static final String EXERCISE_ROUTE_LONGITUDES = PREFIX + "EXERCISE_ROUTE_LONGITUDES";
165     private static final String EXERCISE_ROUTE_ALTITUDES = PREFIX + "EXERCISE_ROUTE_ALTITUDES";
166     private static final String EXERCISE_ROUTE_HACCS = PREFIX + "EXERCISE_ROUTE_HACCS";
167     private static final String EXERCISE_ROUTE_VACCS = PREFIX + "EXERCISE_ROUTE_VACCS";
168     private static final String EXERCISE_HAS_ROUTE = PREFIX + "EXERCISE_HAS_ROUTE";
169     private static final String EXERCISE_LAPS = PREFIX + "EXERCISE_LAPS";
170     private static final String POWER_WATTS = PREFIX + "POWER_WATTS";
171     private static final String TIME_INSTANT_RANGE_FILTER = PREFIX + "TIME_INSTANT_RANGE_FILTER";
172     private static final String CHANGE_LOGS_REQUEST = PREFIX + "CHANGE_LOGS_REQUEST";
173     private static final String CHANGE_LOG_TOKEN_REQUEST = PREFIX + "CHANGE_LOG_TOKEN_REQUEST";
174     private static final String PERMISSION_NAME = PREFIX + "PERMISSION_NAME";
175     private static final String START_TIMES = PREFIX + "START_TIMES";
176     private static final String END_TIMES = PREFIX + "END_TIMES";
177     private static final String EXERCISE_SEGMENT_TYPES = PREFIX + "EXERCISE_SEGMENT_TYPES";
178     private static final String EXERCISE_SEGMENT_REP_COUNTS =
179             PREFIX + "EXERCISE_SEGMENT_REP_COUNTS";
180     private static final String NOTES = PREFIX + "NOTES";
181     private static final String TITLE = PREFIX + "TITLE";
182     private static final String START_ZONE_OFFSET = PREFIX + "START_ZONE_OFFSET";
183     private static final String END_ZONE_OFFSET = PREFIX + "END_ZONE_OFFSET";
184 
185     /** Converts an insert records request to a bundle. */
fromInsertRecordsRequest(List<? extends Record> records)186     public static Bundle fromInsertRecordsRequest(List<? extends Record> records) {
187         Bundle bundle = new Bundle();
188         bundle.putString(QUERY_TYPE, INSERT_RECORDS_QUERY);
189         bundle.putParcelableArrayList(RECORD_LIST, new ArrayList<>(fromRecordList(records)));
190         return bundle;
191     }
192 
193     /** Converts a bundle to an insert records request. */
toInsertRecordsRequest(Bundle bundle)194     public static List<? extends Record> toInsertRecordsRequest(Bundle bundle) {
195         return toRecordList(bundle.getParcelableArrayList(RECORD_LIST, Bundle.class));
196     }
197 
198     /** Converts an update records request to a bundle. */
fromUpdateRecordsRequest(List<Record> records)199     public static Bundle fromUpdateRecordsRequest(List<Record> records) {
200         Bundle bundle = new Bundle();
201         bundle.putString(QUERY_TYPE, UPDATE_RECORDS_QUERY);
202         bundle.putParcelableArrayList(RECORD_LIST, new ArrayList<>(fromRecordList(records)));
203         return bundle;
204     }
205 
206     /** Converts a bundle to an update records request. */
toUpdateRecordsRequest(Bundle bundle)207     public static List<? extends Record> toUpdateRecordsRequest(Bundle bundle) {
208         return toRecordList(bundle.getParcelableArrayList(RECORD_LIST, Bundle.class));
209     }
210 
211     /** Converts an insert records response to a bundle. */
fromInsertRecordsResponse(List<String> recordIds)212     public static Bundle fromInsertRecordsResponse(List<String> recordIds) {
213         Bundle bundle = new Bundle();
214         bundle.putStringArrayList(RECORD_ID, new ArrayList<>(recordIds));
215         return bundle;
216     }
217 
218     /** Converts a bundle to an insert records response. */
toInsertRecordsResponse(Bundle bundle)219     public static List<String> toInsertRecordsResponse(Bundle bundle) {
220         return bundle.getStringArrayList(RECORD_ID);
221     }
222 
223     /** Converts a ReadRecordsRequestUsingFilters to a bundle. */
fromReadRecordsRequestUsingFilters( ReadRecordsRequestUsingFilters<T> request)224     public static <T extends Record> Bundle fromReadRecordsRequestUsingFilters(
225             ReadRecordsRequestUsingFilters<T> request) {
226         Bundle bundle = new Bundle();
227         bundle.putString(QUERY_TYPE, READ_RECORDS_QUERY);
228         bundle.putString(RECORD_CLASS_NAME, request.getRecordType().getName());
229         bundle.putStringArrayList(
230                 PACKAGE_NAME,
231                 new ArrayList<>(
232                         request.getDataOrigins().stream()
233                                 .map(DataOrigin::getPackageName)
234                                 .toList()));
235 
236         if (request.getTimeRangeFilter() instanceof TimeInstantRangeFilter filter) {
237             bundle.putBoolean(TIME_INSTANT_RANGE_FILTER, true);
238 
239             Long startTime = transformOrNull(filter.getStartTime(), Instant::toEpochMilli);
240             Long endTime = transformOrNull(filter.getEndTime(), Instant::toEpochMilli);
241 
242             bundle.putSerializable(START_TIME_MILLIS, startTime);
243             bundle.putSerializable(END_TIME_MILLIS, endTime);
244         } else if (request.getTimeRangeFilter() != null) {
245             throw new IllegalArgumentException("Unsupported time range filter");
246         }
247 
248         return bundle;
249     }
250 
251     /** Converts a bundle to a ReadRecordsRequestUsingFilters. */
toReadRecordsRequestUsingFilters( Bundle bundle)252     public static ReadRecordsRequestUsingFilters<? extends Record> toReadRecordsRequestUsingFilters(
253             Bundle bundle) {
254         String recordClassName = bundle.getString(RECORD_CLASS_NAME);
255 
256         Class<? extends Record> recordClass = recordClassForName(recordClassName);
257 
258         ReadRecordsRequestUsingFilters.Builder<? extends Record> request =
259                 new ReadRecordsRequestUsingFilters.Builder<>(recordClass);
260 
261         if (bundle.getBoolean(TIME_INSTANT_RANGE_FILTER)) {
262             Long startTimeMillis = bundle.getSerializable(START_TIME_MILLIS, Long.class);
263             Long endTimeMillis = bundle.getSerializable(END_TIME_MILLIS, Long.class);
264 
265             Instant startTime = transformOrNull(startTimeMillis, Instant::ofEpochMilli);
266             Instant endTime = transformOrNull(endTimeMillis, Instant::ofEpochMilli);
267 
268             TimeInstantRangeFilter timeInstantRangeFilter =
269                     new TimeInstantRangeFilter.Builder()
270                             .setStartTime(startTime)
271                             .setEndTime(endTime)
272                             .build();
273 
274             request.setTimeRangeFilter(timeInstantRangeFilter);
275         }
276         List<String> packageNames = bundle.getStringArrayList(PACKAGE_NAME);
277 
278         if (packageNames != null) {
279             for (String packageName : packageNames) {
280                 request.addDataOrigins(
281                         new DataOrigin.Builder().setPackageName(packageName).build());
282             }
283         }
284 
285         return request.build();
286     }
287 
288     /** Converts a ReadRecordsRequestUsingFilters to a bundle. */
fromReadRecordsRequestUsingIds( ReadRecordsRequestUsingIds<T> request)289     public static <T extends Record> Bundle fromReadRecordsRequestUsingIds(
290             ReadRecordsRequestUsingIds<T> request) {
291         Bundle bundle = new Bundle();
292         bundle.putString(QUERY_TYPE, READ_RECORDS_USING_IDS_QUERY);
293         bundle.putString(RECORD_CLASS_NAME, request.getRecordType().getName());
294 
295         var recordIdFilters = request.getRecordIdFilters();
296         bundle.putStringArrayList(
297                 RECORD_ID,
298                 new ArrayList<>(
299                         recordIdFilters.stream()
300                                 .map(RecordIdFilter::getId)
301                                 .filter(Objects::nonNull)
302                                 .toList()));
303         bundle.putStringArrayList(
304                 CLIENT_ID,
305                 new ArrayList<>(
306                         recordIdFilters.stream()
307                                 .map(RecordIdFilter::getClientRecordId)
308                                 .filter(Objects::nonNull)
309                                 .toList()));
310 
311         return bundle;
312     }
313 
314     /** Converts a bundle to a ReadRecordsRequestUsingFilters. */
toReadRecordsRequestUsingIds( Bundle bundle)315     public static ReadRecordsRequestUsingIds<? extends Record> toReadRecordsRequestUsingIds(
316             Bundle bundle) {
317         String recordClassName = bundle.getString(RECORD_CLASS_NAME);
318         var request = new ReadRecordsRequestUsingIds.Builder<>(recordClassForName(recordClassName));
319         var recordIds = bundle.getStringArrayList(RECORD_ID);
320         if (recordIds != null) {
321             for (String id : recordIds) {
322                 request.addId(id);
323             }
324         }
325         var clientRecordIds = bundle.getStringArrayList(CLIENT_ID);
326         if (clientRecordIds != null) {
327             for (String clientId : clientRecordIds) {
328                 request.addClientRecordId(clientId);
329             }
330         }
331 
332         return request.build();
333     }
334 
335     /** Converts a read records response to a bundle. */
fromReadRecordsResponse(List<? extends Record> records)336     public static Bundle fromReadRecordsResponse(List<? extends Record> records) {
337         Bundle bundle = new Bundle();
338         bundle.putParcelableArrayList(RECORD_LIST, new ArrayList<>(fromRecordList(records)));
339         return bundle;
340     }
341 
342     /** Converts a bundle to a read records response. */
toReadRecordsResponse(Bundle bundle)343     public static <T extends Record> List<T> toReadRecordsResponse(Bundle bundle) {
344         return (List<T>) toRecordList(bundle.getParcelableArrayList(RECORD_LIST, Bundle.class));
345     }
346 
347     /** Converts a delete records request to a bundle. */
fromDeleteRecordsByIdsRequest(List<RecordIdFilter> recordIdFilters)348     public static Bundle fromDeleteRecordsByIdsRequest(List<RecordIdFilter> recordIdFilters) {
349         Bundle bundle = new Bundle();
350         bundle.putString(QUERY_TYPE, DELETE_RECORDS_QUERY);
351 
352         List<String> recordClassNames =
353                 recordIdFilters.stream()
354                         .map(RecordIdFilter::getRecordType)
355                         .map(Class::getName)
356                         .toList();
357         List<String> recordIds = recordIdFilters.stream().map(RecordIdFilter::getId).toList();
358 
359         bundle.putStringArrayList(RECORD_CLASS_NAME, new ArrayList<>(recordClassNames));
360         bundle.putStringArrayList(RECORD_ID, new ArrayList<>(recordIds));
361 
362         return bundle;
363     }
364 
365     /** Converts a bundle to a delete records request. */
toDeleteRecordsByIdsRequest(Bundle bundle)366     public static List<RecordIdFilter> toDeleteRecordsByIdsRequest(Bundle bundle) {
367         List<String> recordClassNames = bundle.getStringArrayList(RECORD_CLASS_NAME);
368         List<String> recordIds = bundle.getStringArrayList(RECORD_ID);
369 
370         return IntStream.range(0, recordClassNames.size())
371                 .mapToObj(
372                         i -> {
373                             String recordClassName = recordClassNames.get(i);
374                             Class<? extends Record> recordClass =
375                                     recordClassForName(recordClassName);
376                             String recordId = recordIds.get(i);
377                             return RecordIdFilter.fromId(recordClass, recordId);
378                         })
379                 .toList();
380     }
381 
382     /** Converts a ChangeLogTokenRequest to a bundle. */
fromChangeLogTokenRequest(ChangeLogTokenRequest request)383     public static Bundle fromChangeLogTokenRequest(ChangeLogTokenRequest request) {
384         Bundle bundle = new Bundle();
385         bundle.putString(QUERY_TYPE, GET_CHANGE_LOG_TOKEN_QUERY);
386         bundle.putParcelable(CHANGE_LOG_TOKEN_REQUEST, request);
387         return bundle;
388     }
389 
390     /** Converts a self-revoke permission request to a bundle. */
forSelfRevokePermissionRequest(String permission)391     public static Bundle forSelfRevokePermissionRequest(String permission) {
392         Bundle bundle = new Bundle();
393         bundle.putString(QUERY_TYPE, SELF_REVOKE_PERMISSION_REQUEST);
394         bundle.putString(PERMISSION_NAME, permission);
395         return bundle;
396     }
397 
398     /** Creates a bundle representing a kill-self request. */
forKillSelfRequest()399     public static Bundle forKillSelfRequest() {
400         Bundle bundle = new Bundle();
401         bundle.putString(QUERY_TYPE, KILL_SELF_REQUEST);
402         return bundle;
403     }
404 
405     /** Converts a bundle to a self-revoke permission request. */
toPermissionToSelfRevoke(Bundle bundle)406     public static String toPermissionToSelfRevoke(Bundle bundle) {
407         return bundle.getString(PERMISSION_NAME);
408     }
409 
410     /** Converts a bundle to a ChangeLogTokenRequest. */
toChangeLogTokenRequest(Bundle bundle)411     public static ChangeLogTokenRequest toChangeLogTokenRequest(Bundle bundle) {
412         return bundle.getParcelable(CHANGE_LOG_TOKEN_REQUEST, ChangeLogTokenRequest.class);
413     }
414 
415     /** Converts a changelog token response to a bundle. */
fromChangeLogTokenResponse(String token)416     public static Bundle fromChangeLogTokenResponse(String token) {
417         Bundle bundle = new Bundle();
418         bundle.putString(CHANGE_LOG_TOKEN, token);
419         return bundle;
420     }
421 
422     /** Converts a bundle to a change log token response. */
toChangeLogTokenResponse(Bundle bundle)423     public static String toChangeLogTokenResponse(Bundle bundle) {
424         return bundle.getString(CHANGE_LOG_TOKEN);
425     }
426 
427     /** Converts a ChangeLogsRequest to a bundle. */
fromChangeLogsRequest(ChangeLogsRequest request)428     public static Bundle fromChangeLogsRequest(ChangeLogsRequest request) {
429         Bundle bundle = new Bundle();
430         bundle.putString(QUERY_TYPE, READ_CHANGE_LOGS_QUERY);
431         bundle.putParcelable(CHANGE_LOGS_REQUEST, request);
432         return bundle;
433     }
434 
435     /** Converts a bundle to a ChangeLogsRequest. */
toChangeLogsRequest(Bundle bundle)436     public static ChangeLogsRequest toChangeLogsRequest(Bundle bundle) {
437         return bundle.getParcelable(CHANGE_LOGS_REQUEST, ChangeLogsRequest.class);
438     }
439 
440     /** Converts a ChangeLogsResponse to a bundle. */
fromChangeLogsResponse(ChangeLogsResponse response)441     public static Bundle fromChangeLogsResponse(ChangeLogsResponse response) {
442         Bundle bundle = new Bundle();
443         bundle.putParcelable(CHANGE_LOGS_RESPONSE, response);
444         return bundle;
445     }
446 
447     /** Converts a bundle to a ChangeLogsResponse. */
toChangeLogsResponse(Bundle bundle)448     public static ChangeLogsResponse toChangeLogsResponse(Bundle bundle) {
449         return bundle.getParcelable(CHANGE_LOGS_RESPONSE, ChangeLogsResponse.class);
450     }
451 
452     /** Converts a {@link CreateMedicalDataSourceRequest} from a bundle. */
toCreateMedicalDataSourceRequest(Bundle bundle)453     public static CreateMedicalDataSourceRequest toCreateMedicalDataSourceRequest(Bundle bundle) {
454         return bundle.getParcelable(
455                 CREATE_MEDICAL_DATA_SOURCE_REQUEST, CreateMedicalDataSourceRequest.class);
456     }
457 
458     /** Converts a {@link CreateMedicalDataSourceRequest} into a bundle. */
fromCreateMedicalDataSourceRequest( CreateMedicalDataSourceRequest request)459     public static Bundle fromCreateMedicalDataSourceRequest(
460             CreateMedicalDataSourceRequest request) {
461         Bundle bundle = new Bundle();
462         bundle.putString(QUERY_TYPE, CREATE_MEDICAL_DATA_SOURCE_QUERY);
463         bundle.putParcelable(CREATE_MEDICAL_DATA_SOURCE_REQUEST, request);
464         return bundle;
465     }
466 
467     /** Converts one UUID string into a bundle. */
fromMedicalDataSourceId(String id)468     public static Bundle fromMedicalDataSourceId(String id) {
469         Bundle bundle = new Bundle();
470         bundle.putString(QUERY_TYPE, DELETE_MEDICAL_DATA_SOURCE_WITH_DATA_QUERY);
471         bundle.putString(MEDICAL_DATA_SOURCE_ID, id);
472         return bundle;
473     }
474 
475     /** Converts one UUID strings back from a bundle. */
toMedicalDataSourceId(Bundle bundle)476     public static String toMedicalDataSourceId(Bundle bundle) {
477         return bundle.getString(MEDICAL_DATA_SOURCE_ID);
478     }
479 
480     /** Converts a list of UUID strings into a bundle. */
fromMedicalDataSourceIds(List<String> ids)481     public static Bundle fromMedicalDataSourceIds(List<String> ids) {
482         Bundle bundle = new Bundle();
483         bundle.putString(QUERY_TYPE, GET_MEDICAL_DATA_SOURCES_USING_IDS_QUERY);
484         bundle.putStringArrayList(MEDICAL_DATA_SOURCE_ID, new ArrayList<>(ids));
485         return bundle;
486     }
487 
488     /** Converts a list of UUID strings back from a bundle. */
toMedicalDataSourceIds(Bundle bundle)489     public static List<String> toMedicalDataSourceIds(Bundle bundle) {
490         return bundle.getStringArrayList(MEDICAL_DATA_SOURCE_ID);
491     }
492 
493     /** Converts a {@link GetMedicalDataSourcesRequest} into a bundle. */
fromMedicalDataSourceRequest(GetMedicalDataSourcesRequest request)494     public static Bundle fromMedicalDataSourceRequest(GetMedicalDataSourcesRequest request) {
495         Bundle bundle = new Bundle();
496         bundle.putString(QUERY_TYPE, GET_MEDICAL_DATA_SOURCES_USING_REQUEST_QUERY);
497         bundle.putParcelable(GET_MEDICAL_DATA_SOURCES_REQUEST, request);
498         return bundle;
499     }
500 
501     /** Converts a {@link GetMedicalDataSourcesRequest} into a bundle. */
toMedicalDataSourceRequest(Bundle bundle)502     public static GetMedicalDataSourcesRequest toMedicalDataSourceRequest(Bundle bundle) {
503         return bundle.getParcelable(
504                 GET_MEDICAL_DATA_SOURCES_REQUEST, GetMedicalDataSourcesRequest.class);
505     }
506 
507     /** Converts a list of {@link MedicalDataSource}s into a bundle. */
fromMedicalDataSources(List<MedicalDataSource> medicalDataSources)508     public static Bundle fromMedicalDataSources(List<MedicalDataSource> medicalDataSources) {
509         Bundle bundle = new Bundle();
510         bundle.putParcelableArrayList(
511                 MEDICAL_DATA_SOURCES_RESPONSE, new ArrayList<>(medicalDataSources));
512         return bundle;
513     }
514 
515     /** Converts a list of {@link MedicalDataSource}s back from a bundle. */
toMedicalDataSources(Bundle bundle)516     public static List<MedicalDataSource> toMedicalDataSources(Bundle bundle) {
517         return bundle.getParcelableArrayList(
518                 MEDICAL_DATA_SOURCES_RESPONSE, MedicalDataSource.class);
519     }
520 
521     /**
522      * Converts a {@link MedicalDataSource} to a bundle for sending to another app.
523      *
524      * <p>To convert back, use {@link #toMedicalDataSource(Bundle)}.
525      */
fromMedicalDataSource(MedicalDataSource medicalDataSource)526     public static Bundle fromMedicalDataSource(MedicalDataSource medicalDataSource) {
527         Bundle bundle = new Bundle();
528         bundle.putParcelable(MEDICAL_DATA_SOURCE_RESPONSE, medicalDataSource);
529         return bundle;
530     }
531 
532     /**
533      * Converts a {@link MedicalDataSource} back from a bundle.
534      *
535      * <p>To create, use {@link #fromMedicalDataSource(MedicalDataSource)}.
536      */
toMedicalDataSource(Bundle bundle)537     public static MedicalDataSource toMedicalDataSource(Bundle bundle) {
538         return bundle.getParcelable(MEDICAL_DATA_SOURCE_RESPONSE, MedicalDataSource.class);
539     }
540 
541     /** Converts a {@link CreateMedicalDataSourceRequest} from a bundle. */
toUpsertMedicalResourceRequests( Bundle bundle)542     public static List<UpsertMedicalResourceRequest> toUpsertMedicalResourceRequests(
543             Bundle bundle) {
544         return bundle.getParcelableArrayList(
545                 UPSERT_MEDICAL_RESOURCE_REQUESTS, UpsertMedicalResourceRequest.class);
546     }
547 
548     /** Converts a {@link CreateMedicalDataSourceRequest} into a bundle. */
fromUpsertMedicalResourceRequests( List<UpsertMedicalResourceRequest> requests)549     public static Bundle fromUpsertMedicalResourceRequests(
550             List<UpsertMedicalResourceRequest> requests) {
551         Bundle bundle = new Bundle();
552         bundle.putString(QUERY_TYPE, UPSERT_MEDICAL_RESOURCES_QUERY);
553         bundle.putParcelableArrayList(UPSERT_MEDICAL_RESOURCE_REQUESTS, new ArrayList<>(requests));
554         return bundle;
555     }
556 
557     /** Converts a {@link ReadMedicalResourcesRequest} into a bundle. */
fromReadMedicalResourcesRequest(ReadMedicalResourcesRequest request)558     public static Bundle fromReadMedicalResourcesRequest(ReadMedicalResourcesRequest request) {
559         Bundle bundle = new Bundle();
560         bundle.putString(QUERY_TYPE, READ_MEDICAL_RESOURCES_BY_REQUEST_QUERY);
561         bundle.putInt(READ_MEDICAL_RESOURCES_REQUEST_PAGE_SIZE, request.getPageSize());
562 
563         if (request instanceof ReadMedicalResourcesPageRequest) {
564             bundle.putBoolean(READ_MEDICAL_RESOURCES_REQUEST_IS_PAGE_REQUEST, true);
565             bundle.putString(
566                     READ_MEDICAL_RESOURCES_REQUEST_PAGE_TOKEN,
567                     ((ReadMedicalResourcesPageRequest) request).getPageToken());
568         } else if (request instanceof ReadMedicalResourcesInitialRequest) {
569             ReadMedicalResourcesInitialRequest initialRequest =
570                     (ReadMedicalResourcesInitialRequest) request;
571             bundle.putBoolean(READ_MEDICAL_RESOURCES_REQUEST_IS_PAGE_REQUEST, false);
572             bundle.putInt(
573                     READ_MEDICAL_RESOURCES_REQUEST_MEDICAL_RESOURCE_TYPE,
574                     initialRequest.getMedicalResourceType());
575             bundle.putStringArrayList(
576                     READ_MEDICAL_RESOURCES_REQUEST_DATA_SOURCE_IDS,
577                     new ArrayList<>(initialRequest.getDataSourceIds()));
578         } else {
579             throw new IllegalArgumentException(
580                     "Request was not of type ReadMedicalResourcesInitialRequest or"
581                             + " ReadMedicalResourcesPageRequest");
582         }
583 
584         // Check that no data was lost and that the request can be restored again. This could happen
585         // if new fields are added to the ReadMedicalResourcesRequest without including them here.
586         if (!toReadMedicalResourcesRequest(bundle).equals(request)) {
587             throw new IllegalStateException("Data may be lost when converting to/from Bundle");
588         }
589 
590         return bundle;
591     }
592 
593     /** Converts a {@link ReadMedicalResourcesRequest} from a bundle. */
toReadMedicalResourcesRequest(Bundle bundle)594     public static ReadMedicalResourcesRequest toReadMedicalResourcesRequest(Bundle bundle) {
595         boolean isPageRequest = bundle.getBoolean(READ_MEDICAL_RESOURCES_REQUEST_IS_PAGE_REQUEST);
596         int pageSize = bundle.getInt(READ_MEDICAL_RESOURCES_REQUEST_PAGE_SIZE);
597 
598         if (isPageRequest) {
599             String pageToken = bundle.getString(READ_MEDICAL_RESOURCES_REQUEST_PAGE_TOKEN);
600             return new ReadMedicalResourcesPageRequest.Builder(pageToken)
601                     .setPageSize(pageSize)
602                     .build();
603         } else {
604             int medicalResourceType =
605                     bundle.getInt(READ_MEDICAL_RESOURCES_REQUEST_MEDICAL_RESOURCE_TYPE);
606             Set<String> dataSourceIds =
607                     new HashSet<>(
608                             bundle.getStringArrayList(
609                                     READ_MEDICAL_RESOURCES_REQUEST_DATA_SOURCE_IDS));
610             return new ReadMedicalResourcesInitialRequest.Builder(medicalResourceType)
611                     .addDataSourceIds(dataSourceIds)
612                     .setPageSize(pageSize)
613                     .build();
614         }
615     }
616 
617     /** Converts a list of {@link MedicalResourceId}s from a bundle. */
toMedicalResourceIds(Bundle bundle)618     public static List<MedicalResourceId> toMedicalResourceIds(Bundle bundle) {
619         return bundle.getParcelableArrayList(MEDICAL_RESOURCE_IDS, MedicalResourceId.class);
620     }
621 
622     /**
623      * Converts a list of {@link MedicalResourceId}s into a bundle with QUERY_TYPE set to
624      * READ_MEDICAL_RESOURCES_BY_IDS_QUERY
625      */
fromMedicalResourceIdsForRead(List<MedicalResourceId> ids)626     public static Bundle fromMedicalResourceIdsForRead(List<MedicalResourceId> ids) {
627         Bundle bundle = new Bundle();
628         bundle.putString(QUERY_TYPE, READ_MEDICAL_RESOURCES_BY_IDS_QUERY);
629         bundle.putParcelableArrayList(MEDICAL_RESOURCE_IDS, new ArrayList<>(ids));
630         return bundle;
631     }
632 
633     /**
634      * Converts a list of {@link MedicalResourceId}s into a bundle with QUERY_TYPE set to
635      * DELETE_MEDICAL_RESOURCES_BY_IDS_QUERY
636      */
fromMedicalResourceIdsForDelete(List<MedicalResourceId> ids)637     public static Bundle fromMedicalResourceIdsForDelete(List<MedicalResourceId> ids) {
638         Bundle bundle = new Bundle();
639         bundle.putString(QUERY_TYPE, DELETE_MEDICAL_RESOURCES_BY_IDS_QUERY);
640         bundle.putParcelableArrayList(MEDICAL_RESOURCE_IDS, new ArrayList<>(ids));
641         return bundle;
642     }
643 
644     /**
645      * Converts a list of {@link MedicalResource}s to a bundle for sending to another app.
646      *
647      * <p>To convert back, use {@link #toMedicalResources(Bundle)}.
648      */
fromMedicalResources(List<MedicalResource> medicalResources)649     public static Bundle fromMedicalResources(List<MedicalResource> medicalResources) {
650         Bundle bundle = new Bundle();
651         bundle.putParcelableArrayList(
652                 MEDICAL_RESOURCES_RESPONSE, new ArrayList<>(medicalResources));
653         return bundle;
654     }
655 
656     /**
657      * Converts a list of {@link MedicalResource}s back from a bundle.
658      *
659      * <p>To create, use {@link #fromMedicalResources(List)}.
660      */
toMedicalResources(Bundle bundle)661     public static List<MedicalResource> toMedicalResources(Bundle bundle) {
662         return bundle.getParcelableArrayList(MEDICAL_RESOURCES_RESPONSE, MedicalResource.class);
663     }
664 
665     /** Converts a {@link ReadMedicalResourcesResponse} from a bundle. */
toReadMedicalResourcesResponse(Bundle bundle)666     public static ReadMedicalResourcesResponse toReadMedicalResourcesResponse(Bundle bundle) {
667         return bundle.getParcelable(
668                 READ_MEDICAL_RESOURCES_RESPONSE, ReadMedicalResourcesResponse.class);
669     }
670 
671     /** Converts a {@link ReadMedicalResourcesResponse} to a bundle for sending to another app. */
fromReadMedicalResourcesResponse(ReadMedicalResourcesResponse response)672     public static Bundle fromReadMedicalResourcesResponse(ReadMedicalResourcesResponse response) {
673         Bundle bundle = new Bundle();
674         bundle.putParcelable(READ_MEDICAL_RESOURCES_RESPONSE, response);
675         return bundle;
676     }
677 
678     /** Converts a {@link DeleteMedicalResourcesRequest} from a bundle. */
toDeleteMedicalResourcesRequest(Bundle bundle)679     public static DeleteMedicalResourcesRequest toDeleteMedicalResourcesRequest(Bundle bundle) {
680         return bundle.getParcelable(
681                 DELETE_MEDICAL_RESOURCES_REQUEST, DeleteMedicalResourcesRequest.class);
682     }
683 
684     /** Converts a {@link DeleteMedicalResourcesRequest} into a bundle. */
fromDeleteMedicalResourcesRequest(DeleteMedicalResourcesRequest request)685     public static Bundle fromDeleteMedicalResourcesRequest(DeleteMedicalResourcesRequest request) {
686         Bundle bundle = new Bundle();
687         bundle.putString(QUERY_TYPE, DELETE_MEDICAL_RESOURCES_BY_REQUEST_QUERY);
688         bundle.putParcelable(DELETE_MEDICAL_RESOURCES_REQUEST, request);
689         return bundle;
690     }
691 
fromRecordList(List<? extends Record> records)692     private static List<Bundle> fromRecordList(List<? extends Record> records) {
693         return records.stream().map(BundleHelper::fromRecord).toList();
694     }
695 
toRecordList(List<Bundle> bundles)696     private static List<? extends Record> toRecordList(List<Bundle> bundles) {
697         return bundles.stream().map(BundleHelper::toRecord).toList();
698     }
699 
fromRecord(Record record)700     private static Bundle fromRecord(Record record) {
701         Bundle bundle = new Bundle();
702         bundle.putString(RECORD_CLASS_NAME, record.getClass().getName());
703         bundle.putBundle(METADATA, fromMetadata(record.getMetadata()));
704 
705         if (record instanceof IntervalRecord intervalRecord) {
706             bundle.putLong(START_TIME_MILLIS, intervalRecord.getStartTime().toEpochMilli());
707             bundle.putLong(END_TIME_MILLIS, intervalRecord.getEndTime().toEpochMilli());
708             bundle.putInt(START_ZONE_OFFSET, intervalRecord.getStartZoneOffset().getTotalSeconds());
709             bundle.putInt(END_ZONE_OFFSET, intervalRecord.getEndZoneOffset().getTotalSeconds());
710         } else if (record instanceof InstantRecord instantRecord) {
711             bundle.putLong(START_TIME_MILLIS, instantRecord.getTime().toEpochMilli());
712             bundle.putInt(START_ZONE_OFFSET, instantRecord.getZoneOffset().getTotalSeconds());
713         } else {
714             throw new IllegalArgumentException("Unsupported record type: ");
715         }
716 
717         Bundle values;
718 
719         RecordFactory<? extends Record> recordFactory =
720                 RecordFactory.forDataType(record.getClass());
721 
722         if (recordFactory != null) {
723             values = recordFactory.getValuesBundle(record);
724         } else if (record instanceof BasalMetabolicRateRecord basalMetabolicRateRecord) {
725             values = getBasalMetabolicRateRecordValues(basalMetabolicRateRecord);
726         } else if (record instanceof ExerciseSessionRecord exerciseSessionRecord) {
727             values = getExerciseSessionRecordValues(exerciseSessionRecord);
728         } else if (record instanceof StepsRecord stepsRecord) {
729             values = getStepsRecordValues(stepsRecord);
730         } else if (record instanceof HeartRateRecord heartRateRecord) {
731             values = getHeartRateRecordValues(heartRateRecord);
732         } else if (record instanceof SleepSessionRecord sleepSessionRecord) {
733             values = getSleepRecordValues(sleepSessionRecord);
734         } else if (record instanceof DistanceRecord distanceRecord) {
735             values = getDistanceRecordValues(distanceRecord);
736         } else if (record instanceof TotalCaloriesBurnedRecord totalCaloriesBurnedRecord) {
737             values = getTotalCaloriesBurnedRecord(totalCaloriesBurnedRecord);
738         } else if (record instanceof MenstruationPeriodRecord) {
739             values = new Bundle();
740         } else {
741             throw new IllegalArgumentException(
742                     "Unsupported record type: " + record.getClass().getName());
743         }
744 
745         bundle.putBundle(VALUES, values);
746 
747         Record decodedRecord = toRecord(bundle);
748         if (!record.equals(decodedRecord)) {
749             Log.e(
750                     TAG,
751                     BundleHelper.class.getSimpleName()
752                             + ".java - record = "
753                             + ToStringUtils.recordToString(record));
754             Log.e(
755                     TAG,
756                     BundleHelper.class.getSimpleName()
757                             + ".java - decoded = "
758                             + ToStringUtils.recordToString(record));
759             throw new IllegalArgumentException(
760                     "Some fields are incorrectly encoded in " + record.getClass().getSimpleName());
761         }
762 
763         return bundle;
764     }
765 
toRecord(Bundle bundle)766     private static Record toRecord(Bundle bundle) {
767         Metadata metadata = toMetadata(bundle.getBundle(METADATA));
768 
769         String recordClassName = bundle.getString(RECORD_CLASS_NAME);
770 
771         Instant startTime = Instant.ofEpochMilli(bundle.getLong(START_TIME_MILLIS));
772         Instant endTime = Instant.ofEpochMilli(bundle.getLong(END_TIME_MILLIS));
773         ZoneOffset startZoneOffset = ZoneOffset.ofTotalSeconds(bundle.getInt(START_ZONE_OFFSET));
774         ZoneOffset endZoneOffset = ZoneOffset.ofTotalSeconds(bundle.getInt(END_ZONE_OFFSET));
775 
776         Bundle values = bundle.getBundle(VALUES);
777 
778         Class<? extends Record> recordClass = recordClassForName(recordClassName);
779         RecordFactory<? extends Record> recordFactory = RecordFactory.forDataType(recordClass);
780 
781         if (recordFactory != null) {
782             return recordFactory.newRecordFromValuesBundle(
783                     metadata, startTime, endTime, startZoneOffset, endZoneOffset, values);
784         } else if (Objects.equals(recordClassName, BasalMetabolicRateRecord.class.getName())) {
785             return createBasalMetabolicRateRecord(metadata, startTime, startZoneOffset, values);
786         } else if (Objects.equals(recordClassName, ExerciseSessionRecord.class.getName())) {
787             return createExerciseSessionRecord(
788                     metadata, startTime, endTime, startZoneOffset, endZoneOffset, values);
789         } else if (Objects.equals(recordClassName, HeartRateRecord.class.getName())) {
790             return createHeartRateRecord(
791                     metadata, startTime, endTime, startZoneOffset, endZoneOffset, values);
792         } else if (Objects.equals(recordClassName, StepsRecord.class.getName())) {
793             return createStepsRecord(
794                     metadata, startTime, endTime, startZoneOffset, endZoneOffset, values);
795         } else if (Objects.equals(recordClassName, SleepSessionRecord.class.getName())) {
796             return createSleepSessionRecord(
797                     metadata, startTime, endTime, startZoneOffset, endZoneOffset, values);
798         } else if (Objects.equals(recordClassName, DistanceRecord.class.getName())) {
799             return createDistanceRecord(
800                     metadata, startTime, endTime, startZoneOffset, endZoneOffset, values);
801         } else if (Objects.equals(recordClassName, TotalCaloriesBurnedRecord.class.getName())) {
802             return createTotalCaloriesBurnedRecord(
803                     metadata, startTime, endTime, startZoneOffset, endZoneOffset, values);
804         } else if (Objects.equals(recordClassName, MenstruationPeriodRecord.class.getName())) {
805             return new MenstruationPeriodRecord.Builder(metadata, startTime, endTime).build();
806         }
807 
808         throw new IllegalArgumentException("Unsupported record type: " + recordClassName);
809     }
810 
getBasalMetabolicRateRecordValues(BasalMetabolicRateRecord record)811     private static Bundle getBasalMetabolicRateRecordValues(BasalMetabolicRateRecord record) {
812         Bundle values = new Bundle();
813         values.putDouble(POWER_WATTS, record.getBasalMetabolicRate().getInWatts());
814         return values;
815     }
816 
createBasalMetabolicRateRecord( Metadata metadata, Instant time, ZoneOffset startZoneOffset, Bundle values)817     private static BasalMetabolicRateRecord createBasalMetabolicRateRecord(
818             Metadata metadata, Instant time, ZoneOffset startZoneOffset, Bundle values) {
819         double powerWatts = values.getDouble(POWER_WATTS);
820 
821         return new BasalMetabolicRateRecord.Builder(metadata, time, Power.fromWatts(powerWatts))
822                 .setZoneOffset(startZoneOffset)
823                 .build();
824     }
825 
getExerciseSessionRecordValues(ExerciseSessionRecord record)826     private static Bundle getExerciseSessionRecordValues(ExerciseSessionRecord record) {
827         Bundle values = new Bundle();
828 
829         values.putInt(EXERCISE_SESSION_TYPE, record.getExerciseType());
830 
831         ExerciseRoute route = record.getRoute();
832 
833         if (route != null) {
834             long[] timestamps =
835                     route.getRouteLocations().stream()
836                             .map(ExerciseRoute.Location::getTime)
837                             .mapToLong(Instant::toEpochMilli)
838                             .toArray();
839             double[] latitudes =
840                     route.getRouteLocations().stream()
841                             .mapToDouble(ExerciseRoute.Location::getLatitude)
842                             .toArray();
843             double[] longitudes =
844                     route.getRouteLocations().stream()
845                             .mapToDouble(ExerciseRoute.Location::getLongitude)
846                             .toArray();
847             List<Double> altitudes =
848                     route.getRouteLocations().stream()
849                             .map(ExerciseRoute.Location::getAltitude)
850                             .map(alt -> transformOrNull(alt, Length::getInMeters))
851                             .toList();
852             List<Double> hAccs =
853                     route.getRouteLocations().stream()
854                             .map(ExerciseRoute.Location::getHorizontalAccuracy)
855                             .map(hAcc -> transformOrNull(hAcc, Length::getInMeters))
856                             .toList();
857             List<Double> vAccs =
858                     route.getRouteLocations().stream()
859                             .map(ExerciseRoute.Location::getVerticalAccuracy)
860                             .map(vAcc -> transformOrNull(vAcc, Length::getInMeters))
861                             .toList();
862 
863             values.putLongArray(EXERCISE_ROUTE_TIMESTAMPS, timestamps);
864             values.putDoubleArray(EXERCISE_ROUTE_LATITUDES, latitudes);
865             values.putDoubleArray(EXERCISE_ROUTE_LONGITUDES, longitudes);
866             values.putSerializable(EXERCISE_ROUTE_ALTITUDES, new ArrayList<>(altitudes));
867             values.putSerializable(EXERCISE_ROUTE_HACCS, new ArrayList<>(hAccs));
868             values.putSerializable(EXERCISE_ROUTE_VACCS, new ArrayList<>(vAccs));
869         }
870 
871         values.putBoolean(EXERCISE_HAS_ROUTE, record.hasRoute());
872 
873         long[] segmentStartTimes =
874                 record.getSegments().stream()
875                         .map(ExerciseSegment::getStartTime)
876                         .mapToLong(Instant::toEpochMilli)
877                         .toArray();
878         long[] segmentEndTimes =
879                 record.getSegments().stream()
880                         .map(ExerciseSegment::getEndTime)
881                         .mapToLong(Instant::toEpochMilli)
882                         .toArray();
883         int[] segmentTypes =
884                 record.getSegments().stream().mapToInt(ExerciseSegment::getSegmentType).toArray();
885         int[] repCounts =
886                 record.getSegments().stream()
887                         .mapToInt(ExerciseSegment::getRepetitionsCount)
888                         .toArray();
889 
890         values.putLongArray(START_TIMES, segmentStartTimes);
891         values.putLongArray(END_TIMES, segmentEndTimes);
892         values.putIntArray(EXERCISE_SEGMENT_TYPES, segmentTypes);
893         values.putIntArray(EXERCISE_SEGMENT_REP_COUNTS, repCounts);
894 
895         List<ExerciseLap> laps = record.getLaps();
896         if (laps != null && !laps.isEmpty()) {
897             Bundle lapsBundle = new Bundle();
898             lapsBundle.putLongArray(
899                     START_TIMES,
900                     laps.stream()
901                             .map(ExerciseLap::getStartTime)
902                             .mapToLong(Instant::toEpochMilli)
903                             .toArray());
904             lapsBundle.putLongArray(
905                     END_TIMES,
906                     laps.stream()
907                             .map(ExerciseLap::getEndTime)
908                             .mapToLong(Instant::toEpochMilli)
909                             .toArray());
910             lapsBundle.putDoubleArray(
911                     LENGTH_IN_METERS,
912                     laps.stream()
913                             .map(ExerciseLap::getLength)
914                             .map(length -> length == null ? -1 : length.getInMeters())
915                             .mapToDouble(value -> value)
916                             .toArray());
917             values.putBundle(EXERCISE_LAPS, lapsBundle);
918         }
919 
920         values.putCharSequence(TITLE, record.getTitle());
921         values.putCharSequence(NOTES, record.getNotes());
922 
923         return values;
924     }
925 
createExerciseSessionRecord( Metadata metadata, Instant startTime, Instant endTime, ZoneOffset startZoneOffset, ZoneOffset endZoneOffset, Bundle values)926     private static ExerciseSessionRecord createExerciseSessionRecord(
927             Metadata metadata,
928             Instant startTime,
929             Instant endTime,
930             ZoneOffset startZoneOffset,
931             ZoneOffset endZoneOffset,
932             Bundle values) {
933         int exerciseType = values.getInt(EXERCISE_SESSION_TYPE);
934 
935         ExerciseSessionRecord.Builder record =
936                 new ExerciseSessionRecord.Builder(metadata, startTime, endTime, exerciseType);
937 
938         long[] routeTimestamps = values.getLongArray(EXERCISE_ROUTE_TIMESTAMPS);
939 
940         int locationCount = routeTimestamps == null ? 0 : routeTimestamps.length;
941 
942         if (locationCount > 0) {
943             double[] latitudes = values.getDoubleArray(EXERCISE_ROUTE_LATITUDES);
944             double[] longitudes = values.getDoubleArray(EXERCISE_ROUTE_LONGITUDES);
945             List<Double> altitudes =
946                     values.getSerializable(EXERCISE_ROUTE_ALTITUDES, ArrayList.class);
947             List<Double> hAccs = values.getSerializable(EXERCISE_ROUTE_HACCS, ArrayList.class);
948             List<Double> vAccs = values.getSerializable(EXERCISE_ROUTE_VACCS, ArrayList.class);
949             List<ExerciseRoute.Location> locations =
950                     IntStream.range(0, locationCount)
951                             .mapToObj(
952                                     i -> {
953                                         Instant time = Instant.ofEpochMilli(routeTimestamps[i]);
954                                         double latitude = latitudes[i];
955                                         double longitude = longitudes[i];
956                                         Double altitude = altitudes.get(i);
957                                         Double hAcc = hAccs.get(i);
958                                         Double vAcc = vAccs.get(i);
959 
960                                         var location =
961                                                 new ExerciseRoute.Location.Builder(
962                                                         time, latitude, longitude);
963 
964                                         if (altitude != null) {
965                                             location.setAltitude(Length.fromMeters(altitude));
966                                         }
967 
968                                         if (hAcc != null) {
969                                             location.setHorizontalAccuracy(Length.fromMeters(hAcc));
970                                         }
971 
972                                         if (vAcc != null) {
973                                             location.setVerticalAccuracy(Length.fromMeters(vAcc));
974                                         }
975 
976                                         return location.build();
977                                     })
978                             .toList();
979 
980             record.setRoute(new ExerciseRoute(locations));
981         }
982 
983         boolean hasRoute = values.getBoolean(EXERCISE_HAS_ROUTE);
984 
985         if (hasRoute && locationCount == 0) {
986             // Handle the `route == null && hasRoute == true` case which is a valid state.
987             setHasRoute(record, hasRoute);
988         }
989 
990         long[] segmentStartTimes = values.getLongArray(START_TIMES);
991         long[] segmentEndTimes = values.getLongArray(END_TIMES);
992         int[] segmentTypes = values.getIntArray(EXERCISE_SEGMENT_TYPES);
993         int[] repCounts = values.getIntArray(EXERCISE_SEGMENT_REP_COUNTS);
994 
995         List<ExerciseSegment> segments =
996                 IntStream.range(0, segmentStartTimes.length)
997                         .mapToObj(
998                                 i -> {
999                                     Instant segmentStartTime =
1000                                             Instant.ofEpochMilli(segmentStartTimes[i]);
1001                                     Instant segmentEndTime =
1002                                             Instant.ofEpochMilli(segmentEndTimes[i]);
1003                                     return new ExerciseSegment.Builder(
1004                                                     segmentStartTime,
1005                                                     segmentEndTime,
1006                                                     segmentTypes[i])
1007                                             .setRepetitionsCount(repCounts[i])
1008                                             .build();
1009                                 })
1010                         .toList();
1011 
1012         record.setSegments(segments);
1013 
1014         Bundle lapsBundle = values.getBundle(EXERCISE_LAPS);
1015         if (lapsBundle != null) {
1016             List<ExerciseLap> laps = new ArrayList<>();
1017             double[] lengths = lapsBundle.getDoubleArray(LENGTH_IN_METERS);
1018             long[] startTimes = lapsBundle.getLongArray(START_TIMES);
1019             long[] endTimes = lapsBundle.getLongArray(END_TIMES);
1020             for (int i = 0; i < lengths.length; i++) {
1021                 ExerciseLap.Builder lap =
1022                         new ExerciseLap.Builder(
1023                                 Instant.ofEpochMilli(startTimes[i]),
1024                                 Instant.ofEpochMilli(endTimes[i]));
1025                 if (lengths[i] > 0) {
1026                     lap.setLength(Length.fromMeters(lengths[i]));
1027                 }
1028                 laps.add(lap.build());
1029             }
1030             record.setLaps(laps);
1031         }
1032 
1033         record.setTitle(values.getCharSequence(TITLE));
1034         record.setNotes(values.getCharSequence(NOTES));
1035         record.setStartZoneOffset(startZoneOffset);
1036         record.setEndZoneOffset(endZoneOffset);
1037 
1038         return record.build();
1039     }
1040 
getHeartRateRecordValues(HeartRateRecord record)1041     private static Bundle getHeartRateRecordValues(HeartRateRecord record) {
1042         Bundle values = new Bundle();
1043         long[] times =
1044                 record.getSamples().stream()
1045                         .map(HeartRateRecord.HeartRateSample::getTime)
1046                         .mapToLong(Instant::toEpochMilli)
1047                         .toArray();
1048         long[] bpms =
1049                 record.getSamples().stream()
1050                         .mapToLong(HeartRateRecord.HeartRateSample::getBeatsPerMinute)
1051                         .toArray();
1052 
1053         values.putLongArray(SAMPLE_TIMES, times);
1054         values.putLongArray(SAMPLE_VALUES, bpms);
1055         return values;
1056     }
1057 
getSleepRecordValues(SleepSessionRecord record)1058     private static Bundle getSleepRecordValues(SleepSessionRecord record) {
1059         Bundle values = new Bundle();
1060         values.putLongArray(
1061                 START_TIMES,
1062                 record.getStages().stream()
1063                         .map(Stage::getStartTime)
1064                         .mapToLong(Instant::toEpochMilli)
1065                         .toArray());
1066         values.putLongArray(
1067                 END_TIMES,
1068                 record.getStages().stream()
1069                         .map(Stage::getEndTime)
1070                         .mapToLong(Instant::toEpochMilli)
1071                         .toArray());
1072         values.putIntArray(
1073                 SAMPLE_VALUES, record.getStages().stream().mapToInt(Stage::getType).toArray());
1074         values.putCharSequence(NOTES, record.getNotes());
1075         values.putCharSequence(TITLE, record.getTitle());
1076         return values;
1077     }
1078 
getDistanceRecordValues(DistanceRecord record)1079     private static Bundle getDistanceRecordValues(DistanceRecord record) {
1080         Bundle values = new Bundle();
1081         values.putDouble(LENGTH_IN_METERS, record.getDistance().getInMeters());
1082         return values;
1083     }
1084 
getTotalCaloriesBurnedRecord(TotalCaloriesBurnedRecord record)1085     private static Bundle getTotalCaloriesBurnedRecord(TotalCaloriesBurnedRecord record) {
1086         Bundle values = new Bundle();
1087         values.putDouble(ENERGY_IN_CALORIES, record.getEnergy().getInCalories());
1088         return values;
1089     }
1090 
createHeartRateRecord( Metadata metadata, Instant startTime, Instant endTime, ZoneOffset startZoneOffset, ZoneOffset endZoneOffset, Bundle values)1091     private static HeartRateRecord createHeartRateRecord(
1092             Metadata metadata,
1093             Instant startTime,
1094             Instant endTime,
1095             ZoneOffset startZoneOffset,
1096             ZoneOffset endZoneOffset,
1097             Bundle values) {
1098 
1099         long[] times = values.getLongArray(SAMPLE_TIMES);
1100         long[] bpms = values.getLongArray(SAMPLE_VALUES);
1101 
1102         List<HeartRateRecord.HeartRateSample> samples =
1103                 IntStream.range(0, times.length)
1104                         .mapToObj(
1105                                 i ->
1106                                         new HeartRateRecord.HeartRateSample(
1107                                                 bpms[i], Instant.ofEpochMilli(times[i])))
1108                         .toList();
1109 
1110         return new HeartRateRecord.Builder(metadata, startTime, endTime, samples)
1111                 .setStartZoneOffset(startZoneOffset)
1112                 .setEndZoneOffset(endZoneOffset)
1113                 .build();
1114     }
1115 
getStepsRecordValues(StepsRecord record)1116     private static Bundle getStepsRecordValues(StepsRecord record) {
1117         Bundle values = new Bundle();
1118         values.putLong(COUNT, record.getCount());
1119         return values;
1120     }
1121 
createStepsRecord( Metadata metadata, Instant startTime, Instant endTime, ZoneOffset startZoneOffset, ZoneOffset endZoneOffset, Bundle values)1122     private static StepsRecord createStepsRecord(
1123             Metadata metadata,
1124             Instant startTime,
1125             Instant endTime,
1126             ZoneOffset startZoneOffset,
1127             ZoneOffset endZoneOffset,
1128             Bundle values) {
1129         long count = values.getLong(COUNT);
1130 
1131         return new StepsRecord.Builder(metadata, startTime, endTime, count)
1132                 .setStartZoneOffset(startZoneOffset)
1133                 .setEndZoneOffset(endZoneOffset)
1134                 .build();
1135     }
1136 
createSleepSessionRecord( Metadata metadata, Instant startTime, Instant endTime, ZoneOffset startZoneOffset, ZoneOffset endZoneOffset, Bundle values)1137     private static SleepSessionRecord createSleepSessionRecord(
1138             Metadata metadata,
1139             Instant startTime,
1140             Instant endTime,
1141             ZoneOffset startZoneOffset,
1142             ZoneOffset endZoneOffset,
1143             Bundle values) {
1144         List<Stage> stages = new ArrayList<>();
1145         int[] stageInts = values.getIntArray(SAMPLE_VALUES);
1146         long[] startTimeMillis = values.getLongArray(START_TIMES);
1147         long[] endTimeMillis = values.getLongArray(END_TIMES);
1148         for (int i = 0; i < stageInts.length; i++) {
1149             stages.add(
1150                     new Stage(
1151                             Instant.ofEpochMilli(startTimeMillis[i]),
1152                             Instant.ofEpochMilli(endTimeMillis[i]),
1153                             stageInts[i]));
1154         }
1155         return new SleepSessionRecord.Builder(metadata, startTime, endTime)
1156                 .setStages(stages)
1157                 .setNotes(values.getCharSequence(NOTES))
1158                 .setTitle(values.getCharSequence(TITLE))
1159                 .setStartZoneOffset(startZoneOffset)
1160                 .setEndZoneOffset(endZoneOffset)
1161                 .build();
1162     }
1163 
createDistanceRecord( Metadata metadata, Instant startTime, Instant endTime, ZoneOffset startZoneOffset, ZoneOffset endZoneOffset, Bundle values)1164     private static DistanceRecord createDistanceRecord(
1165             Metadata metadata,
1166             Instant startTime,
1167             Instant endTime,
1168             ZoneOffset startZoneOffset,
1169             ZoneOffset endZoneOffset,
1170             Bundle values) {
1171         double lengthInMeters = values.getDouble(LENGTH_IN_METERS);
1172         return new DistanceRecord.Builder(
1173                         metadata, startTime, endTime, Length.fromMeters(lengthInMeters))
1174                 .setStartZoneOffset(startZoneOffset)
1175                 .setEndZoneOffset(endZoneOffset)
1176                 .build();
1177     }
1178 
createTotalCaloriesBurnedRecord( Metadata metadata, Instant startTime, Instant endTime, ZoneOffset startZoneOffset, ZoneOffset endZoneOffset, Bundle values)1179     private static TotalCaloriesBurnedRecord createTotalCaloriesBurnedRecord(
1180             Metadata metadata,
1181             Instant startTime,
1182             Instant endTime,
1183             ZoneOffset startZoneOffset,
1184             ZoneOffset endZoneOffset,
1185             Bundle values) {
1186         double energyInCalories = values.getDouble(ENERGY_IN_CALORIES);
1187         return new TotalCaloriesBurnedRecord.Builder(
1188                         metadata, startTime, endTime, Energy.fromCalories(energyInCalories))
1189                 .setStartZoneOffset(startZoneOffset)
1190                 .setEndZoneOffset(endZoneOffset)
1191                 .build();
1192     }
1193 
fromMetadata(Metadata metadata)1194     private static Bundle fromMetadata(Metadata metadata) {
1195         Bundle bundle = new Bundle();
1196         bundle.putString(RECORD_ID, metadata.getId());
1197         bundle.putString(PACKAGE_NAME, metadata.getDataOrigin().getPackageName());
1198         bundle.putString(CLIENT_ID, metadata.getClientRecordId());
1199         bundle.putBundle(DEVICE, fromDevice(metadata.getDevice()));
1200         return bundle;
1201     }
1202 
fromDevice(Device device)1203     private static Bundle fromDevice(Device device) {
1204         Bundle bundle = new Bundle();
1205         bundle.putString(MANUFACTURER, device.getManufacturer());
1206         bundle.putString(MODEL, device.getModel());
1207         bundle.putInt(DEVICE_TYPE, device.getType());
1208         return bundle;
1209     }
1210 
toMetadata(Bundle bundle)1211     private static Metadata toMetadata(Bundle bundle) {
1212         Metadata.Builder metadata = new Metadata.Builder();
1213 
1214         ifNotNull(bundle.getString(RECORD_ID), metadata::setId);
1215         ifNotNull(
1216                 bundle.getString(PACKAGE_NAME),
1217                 packageName ->
1218                         metadata.setDataOrigin(
1219                                 new DataOrigin.Builder().setPackageName(packageName).build()));
1220         metadata.setClientRecordId(bundle.getString(CLIENT_ID));
1221 
1222         Bundle deviceBundle = bundle.getBundle(DEVICE);
1223         ifNotNull(
1224                 deviceBundle,
1225                 nonNullDeviceBundle -> {
1226                     Device.Builder deviceBuilder = new Device.Builder();
1227                     ifNotNull(
1228                             nonNullDeviceBundle.getString(MANUFACTURER),
1229                             deviceBuilder::setManufacturer);
1230                     ifNotNull(nonNullDeviceBundle.getString(MODEL), deviceBuilder::setModel);
1231                     deviceBuilder.setType(
1232                             nonNullDeviceBundle.getInt(DEVICE_TYPE, Device.DEVICE_TYPE_UNKNOWN));
1233                     metadata.setDevice(deviceBuilder.build());
1234                 });
1235 
1236         return metadata.build();
1237     }
1238 
ifNotNull(T obj, Consumer<T> consumer)1239     private static <T> void ifNotNull(T obj, Consumer<T> consumer) {
1240         if (obj == null) {
1241             return;
1242         }
1243         consumer.accept(obj);
1244     }
1245 
transformOrNull(T obj, Function<T, R> transform)1246     private static <T, R> R transformOrNull(T obj, Function<T, R> transform) {
1247         if (obj == null) {
1248             return null;
1249         }
1250         return transform.apply(obj);
1251     }
1252 
recordClassForName(String className)1253     private static Class<? extends Record> recordClassForName(String className) {
1254         try {
1255             return (Class<? extends Record>) Class.forName(className);
1256         } catch (ClassNotFoundException e) {
1257             throw new IllegalArgumentException(e);
1258         }
1259     }
1260 
1261     /**
1262      * Calls {@code ExerciseSessionRecord.Builder.setHasRoute} using reflection as the method is
1263      * hidden.
1264      */
setHasRoute(ExerciseSessionRecord.Builder record, boolean hasRoute)1265     private static void setHasRoute(ExerciseSessionRecord.Builder record, boolean hasRoute) {
1266         // Getting a hidden method by its signature using getMethod() throws an exception in test
1267         // apps, but iterating throw all the methods and getting the needed one works.
1268         for (var method : record.getClass().getMethods()) {
1269             if (method.getName().equals("setHasRoute")) {
1270                 try {
1271                     method.invoke(record, hasRoute);
1272                 } catch (IllegalAccessException | InvocationTargetException e) {
1273                     throw new IllegalArgumentException(e);
1274                 }
1275             }
1276         }
1277     }
1278 
BundleHelper()1279     private BundleHelper() {}
1280 }
1281