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.health.connect.datatypes;
18 
19 import static android.health.connect.datatypes.RecordTypeIdentifier.RECORD_TYPE_HEART_RATE;
20 
21 import android.annotation.NonNull;
22 import android.annotation.Nullable;
23 import android.health.connect.HealthConnectManager;
24 import android.health.connect.datatypes.validation.ValidationUtils;
25 import android.health.connect.internal.datatypes.HeartRateRecordInternal;
26 
27 import java.time.Instant;
28 import java.time.ZoneOffset;
29 import java.util.HashSet;
30 import java.util.List;
31 import java.util.Objects;
32 import java.util.Set;
33 
34 /** Captures the user's heart rate. Each record represents a series of measurements. */
35 @Identifier(recordIdentifier = RecordTypeIdentifier.RECORD_TYPE_HEART_RATE)
36 public final class HeartRateRecord extends IntervalRecord {
37     /**
38      * Metric identifier to get max heart rate in beats per minute using aggregate APIs in {@link
39      * HealthConnectManager}
40      */
41     @NonNull
42     public static final AggregationType<Long> BPM_MAX =
43             new AggregationType<>(
44                     AggregationType.AggregationTypeIdentifier.HEART_RATE_RECORD_BPM_MAX,
45                     AggregationType.MAX,
46                     RECORD_TYPE_HEART_RATE,
47                     Long.class);
48     /**
49      * Metric identifier to get min heart rate in beats per minute using aggregate APIs in {@link
50      * HealthConnectManager}
51      */
52     @NonNull
53     public static final AggregationType<Long> BPM_MIN =
54             new AggregationType<>(
55                     AggregationType.AggregationTypeIdentifier.HEART_RATE_RECORD_BPM_MIN,
56                     AggregationType.MIN,
57                     RECORD_TYPE_HEART_RATE,
58                     Long.class);
59 
60     /**
61      * Metric identifier to get avg heart rate using aggregate APIs in {@link HealthConnectManager}
62      */
63     @NonNull
64     public static final AggregationType<Long> BPM_AVG =
65             new AggregationType<>(
66                     AggregationType.AggregationTypeIdentifier.HEART_RATE_RECORD_BPM_AVG,
67                     AggregationType.AVG,
68                     RECORD_TYPE_HEART_RATE,
69                     Long.class);
70 
71     /**
72      * Metric identifier to retrieve the number of heart rate measurements using aggregate APIs in
73      * {@link HealthConnectManager}
74      */
75     @NonNull
76     public static final AggregationType<Long> HEART_MEASUREMENTS_COUNT =
77             new AggregationType<>(
78                     AggregationType.AggregationTypeIdentifier.HEART_RATE_RECORD_MEASUREMENTS_COUNT,
79                     AggregationType.COUNT,
80                     RECORD_TYPE_HEART_RATE,
81                     Long.class);
82 
83     private final List<HeartRateSample> mHeartRateSamples;
84 
HeartRateRecord( @onNull Metadata metadata, @NonNull Instant startTime, @NonNull ZoneOffset startZoneOffset, @NonNull Instant endTime, @NonNull ZoneOffset endZoneOffset, @NonNull List<HeartRateSample> heartRateSamples, boolean skipValidation)85     private HeartRateRecord(
86             @NonNull Metadata metadata,
87             @NonNull Instant startTime,
88             @NonNull ZoneOffset startZoneOffset,
89             @NonNull Instant endTime,
90             @NonNull ZoneOffset endZoneOffset,
91             @NonNull List<HeartRateSample> heartRateSamples,
92             boolean skipValidation) {
93         super(
94                 metadata,
95                 startTime,
96                 startZoneOffset,
97                 endTime,
98                 endZoneOffset,
99                 skipValidation,
100                 /* enforceFutureTimeRestrictions= */ true);
101         Objects.requireNonNull(heartRateSamples);
102         if (!skipValidation) {
103             ValidationUtils.validateSampleStartAndEndTime(
104                     startTime,
105                     endTime,
106                     heartRateSamples.stream().map(HeartRateSample::getTime).toList());
107         }
108         mHeartRateSamples = heartRateSamples;
109     }
110 
111     /**
112      * @return heart rate samples corresponding to this record
113      */
114     @NonNull
getSamples()115     public List<HeartRateSample> getSamples() {
116         return mHeartRateSamples;
117     }
118 
119     /**
120      * Indicates whether some other object is "equal to" this one.
121      *
122      * @param object the reference object with which to compare.
123      * @return {@code true} if this object is the same as the obj
124      */
125     @Override
equals(@ullable Object object)126     public boolean equals(@Nullable Object object) {
127         if (super.equals(object) && object instanceof HeartRateRecord) {
128             HeartRateRecord other = (HeartRateRecord) object;
129             if (getSamples().size() != other.getSamples().size()) return false;
130             for (int idx = 0; idx < getSamples().size(); idx++) {
131                 if (getSamples().get(idx).getBeatsPerMinute()
132                                 != other.getSamples().get(idx).getBeatsPerMinute()
133                         || getSamples().get(idx).getTime().toEpochMilli()
134                                 != other.getSamples().get(idx).getTime().toEpochMilli()) {
135                     return false;
136                 }
137             }
138             return true;
139         }
140         return false;
141     }
142 
143     /** Returns a hash code value for the object. */
144     @Override
hashCode()145     public int hashCode() {
146         return Objects.hash(super.hashCode(), getSamples());
147     }
148 
149     /** A class to represent heart rate samples */
150     public static final class HeartRateSample {
151         private final long mBeatsPerMinute;
152         private final Instant mTime;
153 
154         /**
155          * Heart rate sample for entries of {@link HeartRateRecord}
156          *
157          * @param beatsPerMinute Heart beats per minute.
158          * @param time The point in time when the measurement was taken.
159          */
HeartRateSample(long beatsPerMinute, @NonNull Instant time)160         public HeartRateSample(long beatsPerMinute, @NonNull Instant time) {
161             this(beatsPerMinute, time, false);
162         }
163 
164         /**
165          * Heart rate sample for entries of {@link HeartRateRecord}
166          *
167          * @param beatsPerMinute Heart beats per minute.
168          * @param time The point in time when the measurement was taken.
169          * @param skipValidation Boolean flag to skip validation of record values.
170          * @hide
171          */
HeartRateSample(long beatsPerMinute, @NonNull Instant time, boolean skipValidation)172         public HeartRateSample(long beatsPerMinute, @NonNull Instant time, boolean skipValidation) {
173             Objects.requireNonNull(time);
174             if (!skipValidation) {
175                 ValidationUtils.requireInRange(beatsPerMinute, 1, (long) 300, "beatsPerMinute");
176             }
177 
178             mBeatsPerMinute = beatsPerMinute;
179             mTime = time;
180         }
181 
182         /**
183          * @return beats per minute for this sample
184          */
getBeatsPerMinute()185         public long getBeatsPerMinute() {
186             return mBeatsPerMinute;
187         }
188 
189         /**
190          * @return time at which this sample was recorded
191          */
192         @NonNull
getTime()193         public Instant getTime() {
194             return mTime;
195         }
196 
197         /**
198          * Indicates whether some other object is "equal to" this one.
199          *
200          * @param object the reference object with which to compare.
201          * @return {@code true} if this object is the same as the obj
202          */
203         @Override
equals(@ullable Object object)204         public boolean equals(@Nullable Object object) {
205             if (super.equals(object) && object instanceof HeartRateSample) {
206                 HeartRateSample other = (HeartRateSample) object;
207                 return getBeatsPerMinute() == other.getBeatsPerMinute()
208                         && getTime().toEpochMilli() == other.getTime().toEpochMilli();
209             }
210             return false;
211         }
212 
213         /**
214          * Returns a hash code value for the object.
215          *
216          * @return a hash code value for this object.
217          */
218         @Override
hashCode()219         public int hashCode() {
220             return Objects.hash(super.hashCode(), getBeatsPerMinute(), getTime());
221         }
222     }
223 
224     /**
225      * Builder class for {@link HeartRateRecord}
226      *
227      * @see HeartRateRecord
228      */
229     public static final class Builder {
230         private final Metadata mMetadata;
231         private final Instant mStartTime;
232         private final Instant mEndTime;
233         private final List<HeartRateSample> mHeartRateSamples;
234         private ZoneOffset mStartZoneOffset;
235         private ZoneOffset mEndZoneOffset;
236         /**
237          * @param metadata Metadata to be associated with the record. See {@link Metadata}.
238          * @param startTime Start time of this activity
239          * @param endTime End time of this activity
240          * @param heartRateSamples Samples of recorded heart rate
241          * @throws IllegalArgumentException if {@code heartRateSamples} is empty
242          */
Builder( @onNull Metadata metadata, @NonNull Instant startTime, @NonNull Instant endTime, @NonNull List<HeartRateSample> heartRateSamples)243         public Builder(
244                 @NonNull Metadata metadata,
245                 @NonNull Instant startTime,
246                 @NonNull Instant endTime,
247                 @NonNull List<HeartRateSample> heartRateSamples) {
248             Objects.requireNonNull(metadata);
249             Objects.requireNonNull(startTime);
250             Objects.requireNonNull(endTime);
251             Objects.requireNonNull(heartRateSamples);
252             if (heartRateSamples.isEmpty()) {
253                 throw new IllegalArgumentException("record samples should not be empty");
254             }
255 
256             mMetadata = metadata;
257             mStartTime = startTime;
258             mEndTime = endTime;
259             mHeartRateSamples = heartRateSamples;
260             mStartZoneOffset = ZoneOffset.systemDefault().getRules().getOffset(startTime);
261             mEndZoneOffset = ZoneOffset.systemDefault().getRules().getOffset(endTime);
262         }
263 
264         /**
265          * Sets the zone offset of the user when the activity started. By default, the starting zone
266          * offset is set the current zone offset.
267          */
268         @NonNull
setStartZoneOffset(@onNull ZoneOffset startZoneOffset)269         public Builder setStartZoneOffset(@NonNull ZoneOffset startZoneOffset) {
270             Objects.requireNonNull(startZoneOffset);
271 
272             mStartZoneOffset = startZoneOffset;
273             return this;
274         }
275 
276         /**
277          * Sets the zone offset of the user when the activity ended. By default, the end zone offset
278          * is set the current zone offset.
279          */
280         @NonNull
setEndZoneOffset(@onNull ZoneOffset endZoneOffset)281         public Builder setEndZoneOffset(@NonNull ZoneOffset endZoneOffset) {
282             Objects.requireNonNull(endZoneOffset);
283 
284             mEndZoneOffset = endZoneOffset;
285             return this;
286         }
287 
288         /** Sets the start zone offset of this record to system default. */
289         @NonNull
clearStartZoneOffset()290         public Builder clearStartZoneOffset() {
291             mStartZoneOffset = RecordUtils.getDefaultZoneOffset();
292             return this;
293         }
294 
295         /** Sets the start zone offset of this record to system default. */
296         @NonNull
clearEndZoneOffset()297         public Builder clearEndZoneOffset() {
298             mEndZoneOffset = RecordUtils.getDefaultZoneOffset();
299             return this;
300         }
301 
302         /**
303          * @return Object of {@link HeartRateRecord} without validating the values.
304          * @hide
305          */
306         @NonNull
buildWithoutValidation()307         public HeartRateRecord buildWithoutValidation() {
308             return new HeartRateRecord(
309                     mMetadata,
310                     mStartTime,
311                     mStartZoneOffset,
312                     mEndTime,
313                     mEndZoneOffset,
314                     mHeartRateSamples,
315                     true);
316         }
317 
318         /**
319          * @return Object of {@link HeartRateRecord}
320          */
321         @NonNull
build()322         public HeartRateRecord build() {
323             return new HeartRateRecord(
324                     mMetadata,
325                     mStartTime,
326                     mStartZoneOffset,
327                     mEndTime,
328                     mEndZoneOffset,
329                     mHeartRateSamples,
330                     false);
331         }
332     }
333 
334     /** @hide */
335     @Override
toRecordInternal()336     public HeartRateRecordInternal toRecordInternal() {
337         HeartRateRecordInternal recordInternal =
338                 (HeartRateRecordInternal)
339                         new HeartRateRecordInternal()
340                                 .setUuid(getMetadata().getId())
341                                 .setPackageName(getMetadata().getDataOrigin().getPackageName())
342                                 .setLastModifiedTime(
343                                         getMetadata().getLastModifiedTime().toEpochMilli())
344                                 .setClientRecordId(getMetadata().getClientRecordId())
345                                 .setClientRecordVersion(getMetadata().getClientRecordVersion())
346                                 .setManufacturer(getMetadata().getDevice().getManufacturer())
347                                 .setModel(getMetadata().getDevice().getModel())
348                                 .setDeviceType(getMetadata().getDevice().getType())
349                                 .setRecordingMethod(getMetadata().getRecordingMethod());
350         Set<HeartRateRecordInternal.HeartRateSample> samples = new HashSet<>(getSamples().size());
351 
352         for (HeartRateRecord.HeartRateSample heartRateSample : getSamples()) {
353             samples.add(
354                     new HeartRateRecordInternal.HeartRateSample(
355                             (int) heartRateSample.getBeatsPerMinute(),
356                             heartRateSample.getTime().toEpochMilli()));
357         }
358         recordInternal.setSamples(samples);
359         recordInternal.setStartTime(getStartTime().toEpochMilli());
360         recordInternal.setEndTime(getEndTime().toEpochMilli());
361         recordInternal.setStartZoneOffset(getStartZoneOffset().getTotalSeconds());
362         recordInternal.setEndZoneOffset(getEndZoneOffset().getTotalSeconds());
363 
364         return recordInternal;
365     }
366 }
367