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_EXERCISE_SESSION;
20 import static android.health.connect.datatypes.validation.ValidationUtils.sortAndValidateTimeIntervalHolders;
21 
22 import android.annotation.FlaggedApi;
23 import android.annotation.NonNull;
24 import android.annotation.Nullable;
25 import android.health.connect.datatypes.validation.ExerciseSessionTypesValidation;
26 import android.health.connect.internal.datatypes.ExerciseSessionRecordInternal;
27 
28 import java.time.Instant;
29 import java.time.ZoneOffset;
30 import java.util.ArrayList;
31 import java.util.Collections;
32 import java.util.List;
33 import java.util.Objects;
34 import java.util.UUID;
35 import java.util.stream.Collectors;
36 
37 /**
38  * Captures exercise or a sequence of exercises. This can be a playing game like football or a
39  * sequence of fitness exercises.
40  *
41  * <p>Each record needs a start time, end time and session type. In addition, each record has two
42  * optional independent lists of time intervals: {@link ExerciseSegment} represents particular
43  * exercise within session, {@link ExerciseLap} represents a lap time within session.
44  */
45 @Identifier(recordIdentifier = RECORD_TYPE_EXERCISE_SESSION)
46 public final class ExerciseSessionRecord extends IntervalRecord {
47 
48     /**
49      * Metric identifier to retrieve total exercise session duration using aggregate APIs in {@link
50      * android.health.connect.HealthConnectManager}. Calculated in milliseconds.
51      */
52     @NonNull
53     public static final AggregationType<Long> EXERCISE_DURATION_TOTAL =
54             new AggregationType<>(
55                     AggregationType.AggregationTypeIdentifier.EXERCISE_SESSION_DURATION_TOTAL,
56                     AggregationType.SUM,
57                     RECORD_TYPE_EXERCISE_SESSION,
58                     Long.class);
59 
60     private final int mExerciseType;
61 
62     private final CharSequence mNotes;
63     private final CharSequence mTitle;
64     private final ExerciseRoute mRoute;
65 
66     // Represents if the route is recorded for this session, even if mRoute is null.
67     private final boolean mHasRoute;
68 
69     private final List<ExerciseSegment> mSegments;
70     private final List<ExerciseLap> mLaps;
71     private final String mPlannedExerciseSessionId;
72 
73     /**
74      * @param metadata Metadata to be associated with the record. See {@link Metadata}.
75      * @param startTime Start time of this activity
76      * @param startZoneOffset Zone offset of the user when the activity started
77      * @param endTime End time of this activity
78      * @param endZoneOffset Zone offset of the user when the activity finished
79      * @param notes Notes for this activity
80      * @param exerciseType Type of exercise (e.g. walking, swimming). Required field. Allowed
81      *     values: {@link ExerciseSessionType.ExerciseSessionTypes }
82      * @param title Title of this activity
83      * @param skipValidation Boolean flag to skip validation of record values.
84      */
85     @SuppressWarnings({"unchecked", "NullAway"})
ExerciseSessionRecord( @onNull Metadata metadata, @NonNull Instant startTime, @NonNull ZoneOffset startZoneOffset, @NonNull Instant endTime, @NonNull ZoneOffset endZoneOffset, @Nullable CharSequence notes, @NonNull @ExerciseSessionType.ExerciseSessionTypes int exerciseType, @Nullable CharSequence title, @Nullable ExerciseRoute route, boolean hasRoute, @NonNull List<ExerciseSegment> segments, @NonNull List<ExerciseLap> laps, @Nullable String plannedExerciseSessionId, boolean skipValidation)86     private ExerciseSessionRecord(
87             @NonNull Metadata metadata,
88             @NonNull Instant startTime,
89             @NonNull ZoneOffset startZoneOffset,
90             @NonNull Instant endTime,
91             @NonNull ZoneOffset endZoneOffset,
92             @Nullable CharSequence notes,
93             @NonNull @ExerciseSessionType.ExerciseSessionTypes int exerciseType,
94             @Nullable CharSequence title,
95             @Nullable ExerciseRoute route,
96             boolean hasRoute,
97             @NonNull List<ExerciseSegment> segments,
98             @NonNull List<ExerciseLap> laps,
99             @Nullable String plannedExerciseSessionId,
100             boolean skipValidation) {
101         super(
102                 metadata,
103                 startTime,
104                 startZoneOffset,
105                 endTime,
106                 endZoneOffset,
107                 skipValidation,
108                 /* enforceFutureTimeRestrictions= */ true);
109         mNotes = notes;
110         mExerciseType = exerciseType;
111         mTitle = title;
112         if (route != null && !hasRoute) {
113             throw new IllegalArgumentException("HasRoute must be true if the route is not null");
114         }
115         mRoute = route;
116         if (!skipValidation) {
117             ExerciseSessionTypesValidation.validateExerciseRouteTimestamps(
118                     startTime, endTime, route);
119         }
120         mHasRoute = hasRoute;
121         mSegments =
122                 Collections.unmodifiableList(
123                         (List<ExerciseSegment>)
124                                 sortAndValidateTimeIntervalHolders(startTime, endTime, segments));
125         if (!skipValidation) {
126             ExerciseSessionTypesValidation.validateSessionAndSegmentsTypes(exerciseType, mSegments);
127         }
128         mLaps =
129                 Collections.unmodifiableList(
130                         (List<ExerciseLap>)
131                                 sortAndValidateTimeIntervalHolders(startTime, endTime, laps));
132         mPlannedExerciseSessionId = plannedExerciseSessionId;
133     }
134 
135     /** Returns exerciseType of this session. */
136     @ExerciseSessionType.ExerciseSessionTypes
getExerciseType()137     public int getExerciseType() {
138         return mExerciseType;
139     }
140 
141     /** Returns notes for this activity. Returns null if the session doesn't have notes. */
142     @Nullable
getNotes()143     public CharSequence getNotes() {
144         return mNotes;
145     }
146 
147     /** Returns title of this session. Returns null if the session doesn't have title. */
148     @Nullable
getTitle()149     public CharSequence getTitle() {
150         return mTitle;
151     }
152 
153     /** Returns route of this session. Returns null if the session doesn't have route. */
154     @Nullable
getRoute()155     public ExerciseRoute getRoute() {
156         return mRoute;
157     }
158 
159     /**
160      * Returns segments of this session. Returns empty list if the session doesn't have exercise
161      * segments.
162      */
163     @NonNull
getSegments()164     public List<ExerciseSegment> getSegments() {
165         return mSegments;
166     }
167 
168     /**
169      * Returns laps of this session. Returns empty list if the session doesn't have exercise laps.
170      */
171     @NonNull
getLaps()172     public List<ExerciseLap> getLaps() {
173         return mLaps;
174     }
175 
176     /** Returns if this session has recorded route. */
177     @NonNull
hasRoute()178     public boolean hasRoute() {
179         return mHasRoute;
180     }
181 
182     /**
183      * Returns the ID of the {@link PlannedExerciseSessionRecord} that this session was based upon.
184      * If not set, returns null.
185      */
186     @Nullable
187     @FlaggedApi("com.android.healthconnect.flags.training_plans")
getPlannedExerciseSessionId()188     public String getPlannedExerciseSessionId() {
189         return mPlannedExerciseSessionId;
190     }
191 
192     @SuppressWarnings("NullAway") // TODO(b/317029272): fix this suppression
193     @Override
equals(Object o)194     public boolean equals(Object o) {
195         if (this == o) return true;
196         if (!(o instanceof ExerciseSessionRecord)) return false;
197         if (!super.equals(o)) return false;
198         ExerciseSessionRecord that = (ExerciseSessionRecord) o;
199         return getExerciseType() == that.getExerciseType()
200                 && RecordUtils.isEqualNullableCharSequences(getNotes(), that.getNotes())
201                 && RecordUtils.isEqualNullableCharSequences(getTitle(), that.getTitle())
202                 && Objects.equals(getRoute(), that.getRoute())
203                 && Objects.equals(getSegments(), that.getSegments())
204                 && Objects.equals(getPlannedExerciseSessionId(), that.getPlannedExerciseSessionId())
205                 && Objects.equals(getLaps(), that.getLaps());
206     }
207 
208     @Override
hashCode()209     public int hashCode() {
210         return Objects.hash(
211                 super.hashCode(),
212                 getExerciseType(),
213                 getNotes(),
214                 getTitle(),
215                 getRoute(),
216                 getSegments(),
217                 getPlannedExerciseSessionId(),
218                 getLaps());
219     }
220 
221     /** Builder class for {@link ExerciseSessionRecord} */
222     public static final class Builder {
223         private final Metadata mMetadata;
224         private final Instant mStartTime;
225         private final Instant mEndTime;
226         private ZoneOffset mStartZoneOffset;
227         private ZoneOffset mEndZoneOffset;
228         private final int mExerciseType;
229         private CharSequence mNotes;
230         private CharSequence mTitle;
231         private ExerciseRoute mRoute;
232         private final List<ExerciseSegment> mSegments;
233         private final List<ExerciseLap> mLaps;
234         private boolean mHasRoute;
235         @Nullable private String mPlannedExerciseSessionId;
236 
237         /**
238          * @param metadata Metadata to be associated with the record. See {@link Metadata}.
239          * @param startTime Start time of this activity
240          * @param endTime End time of this activity
241          * @param exerciseType Type of exercise (e.g. walking, swimming). Required field. Allowed
242          *     values: {@link ExerciseSessionType}
243          */
244         @SuppressWarnings("NullAway.Init") // TODO(b/317029272): fix this suppression
Builder( @onNull Metadata metadata, @NonNull Instant startTime, @NonNull Instant endTime, @ExerciseSessionType.ExerciseSessionTypes int exerciseType)245         public Builder(
246                 @NonNull Metadata metadata,
247                 @NonNull Instant startTime,
248                 @NonNull Instant endTime,
249                 @ExerciseSessionType.ExerciseSessionTypes int exerciseType) {
250             Objects.requireNonNull(metadata);
251             Objects.requireNonNull(startTime);
252             Objects.requireNonNull(endTime);
253             mMetadata = metadata;
254             mStartTime = startTime;
255             mEndTime = endTime;
256             mExerciseType = exerciseType;
257             mSegments = new ArrayList<>();
258             mLaps = new ArrayList<>();
259             mStartZoneOffset = ZoneOffset.systemDefault().getRules().getOffset(startTime);
260             mEndZoneOffset = ZoneOffset.systemDefault().getRules().getOffset(endTime);
261         }
262 
263         /** Sets the zone offset of the user when the session started */
264         @NonNull
setStartZoneOffset(@onNull ZoneOffset startZoneOffset)265         public Builder setStartZoneOffset(@NonNull ZoneOffset startZoneOffset) {
266             Objects.requireNonNull(startZoneOffset);
267 
268             mStartZoneOffset = startZoneOffset;
269             return this;
270         }
271 
272         /** Sets the zone offset of the user when the session ended */
273         @NonNull
setEndZoneOffset(@onNull ZoneOffset endZoneOffset)274         public Builder setEndZoneOffset(@NonNull ZoneOffset endZoneOffset) {
275             Objects.requireNonNull(endZoneOffset);
276 
277             mEndZoneOffset = endZoneOffset;
278             return this;
279         }
280 
281         /** Sets the start zone offset of this record to system default. */
282         @NonNull
clearStartZoneOffset()283         public Builder clearStartZoneOffset() {
284             mStartZoneOffset = RecordUtils.getDefaultZoneOffset();
285             return this;
286         }
287 
288         /** Sets the start zone offset of this record to system default. */
289         @NonNull
clearEndZoneOffset()290         public Builder clearEndZoneOffset() {
291             mEndZoneOffset = RecordUtils.getDefaultZoneOffset();
292             return this;
293         }
294 
295         /**
296          * Sets notes for this activity
297          *
298          * @param notes Notes for this activity
299          */
300         @SuppressWarnings("NullAway") // TODO(b/317029272): fix this suppression
301         @NonNull
setNotes(@ullable CharSequence notes)302         public Builder setNotes(@Nullable CharSequence notes) {
303             mNotes = notes;
304             return this;
305         }
306 
307         /**
308          * Sets a title of this activity
309          *
310          * @param title Title of this activity
311          */
312         @SuppressWarnings("NullAway") // TODO(b/317029272): fix this suppression
313         @NonNull
setTitle(@ullable CharSequence title)314         public Builder setTitle(@Nullable CharSequence title) {
315             mTitle = title;
316             return this;
317         }
318 
319         /**
320          * Sets route for this activity
321          *
322          * @param route ExerciseRoute for this activity
323          */
324         @SuppressWarnings("NullAway") // TODO(b/317029272): fix this suppression
325         @NonNull
setRoute(@ullable ExerciseRoute route)326         public Builder setRoute(@Nullable ExerciseRoute route) {
327             mRoute = route;
328             mHasRoute = (route != null);
329             return this;
330         }
331 
332         /**
333          * Sets segments for this session.
334          *
335          * @param laps list of {@link ExerciseLap} of this session
336          */
337         @NonNull
setLaps(@onNull List<ExerciseLap> laps)338         public Builder setLaps(@NonNull List<ExerciseLap> laps) {
339             Objects.requireNonNull(laps);
340             mLaps.clear();
341             mLaps.addAll(laps);
342             return this;
343         }
344 
345         /**
346          * Sets segments for this session.
347          *
348          * @param segments list of {@link ExerciseSegment} of this session
349          */
350         @NonNull
setSegments(@onNull List<ExerciseSegment> segments)351         public Builder setSegments(@NonNull List<ExerciseSegment> segments) {
352             Objects.requireNonNull(segments);
353             mSegments.clear();
354             mSegments.addAll(segments);
355             return this;
356         }
357 
358         /**
359          * Sets if the session contains route. Set by platform to indicate whether the route can be
360          * requested via UI intent.
361          *
362          * @param hasRoute flag whether the session has recorded {@link ExerciseRoute}
363          * @hide
364          */
365         @NonNull
setHasRoute(boolean hasRoute)366         public Builder setHasRoute(boolean hasRoute) {
367             mHasRoute = hasRoute;
368             return this;
369         }
370 
371         /** Sets the {@link PlannedExerciseSessionRecord} that this session was based upon. */
372         @NonNull
373         @FlaggedApi("com.android.healthconnect.flags.training_plans")
setPlannedExerciseSessionId(@ullable String id)374         public Builder setPlannedExerciseSessionId(@Nullable String id) {
375             mPlannedExerciseSessionId = id;
376             return this;
377         }
378 
379         /**
380          * @return Object of {@link ExerciseSessionRecord} without validating the values.
381          * @hide
382          */
383         @NonNull
buildWithoutValidation()384         public ExerciseSessionRecord buildWithoutValidation() {
385             return new ExerciseSessionRecord(
386                     mMetadata,
387                     mStartTime,
388                     mStartZoneOffset,
389                     mEndTime,
390                     mEndZoneOffset,
391                     mNotes,
392                     mExerciseType,
393                     mTitle,
394                     mRoute,
395                     mHasRoute,
396                     mSegments,
397                     mLaps,
398                     mPlannedExerciseSessionId,
399                     true);
400         }
401 
402         /** Returns {@link ExerciseSessionRecord} */
403         @NonNull
build()404         public ExerciseSessionRecord build() {
405             return new ExerciseSessionRecord(
406                     mMetadata,
407                     mStartTime,
408                     mStartZoneOffset,
409                     mEndTime,
410                     mEndZoneOffset,
411                     mNotes,
412                     mExerciseType,
413                     mTitle,
414                     mRoute,
415                     mHasRoute,
416                     mSegments,
417                     mLaps,
418                     mPlannedExerciseSessionId,
419                     false);
420         }
421     }
422 
423     /** @hide */
424     @Override
toRecordInternal()425     public ExerciseSessionRecordInternal toRecordInternal() {
426         ExerciseSessionRecordInternal recordInternal =
427                 (ExerciseSessionRecordInternal)
428                         new ExerciseSessionRecordInternal()
429                                 .setUuid(getMetadata().getId())
430                                 .setPackageName(getMetadata().getDataOrigin().getPackageName())
431                                 .setLastModifiedTime(
432                                         getMetadata().getLastModifiedTime().toEpochMilli())
433                                 .setClientRecordId(getMetadata().getClientRecordId())
434                                 .setClientRecordVersion(getMetadata().getClientRecordVersion())
435                                 .setManufacturer(getMetadata().getDevice().getManufacturer())
436                                 .setModel(getMetadata().getDevice().getModel())
437                                 .setDeviceType(getMetadata().getDevice().getType())
438                                 .setRecordingMethod(getMetadata().getRecordingMethod());
439         recordInternal.setStartTime(getStartTime().toEpochMilli());
440         recordInternal.setEndTime(getEndTime().toEpochMilli());
441         recordInternal.setStartZoneOffset(getStartZoneOffset().getTotalSeconds());
442         recordInternal.setEndZoneOffset(getEndZoneOffset().getTotalSeconds());
443 
444         if (getNotes() != null) {
445             recordInternal.setNotes(getNotes().toString());
446         }
447 
448         if (getTitle() != null) {
449             recordInternal.setTitle(getTitle().toString());
450         }
451 
452         recordInternal.setHasRoute(hasRoute());
453         if (getRoute() != null) {
454             recordInternal.setRoute(getRoute().toRouteInternal());
455         }
456 
457         if (getLaps() != null && !getLaps().isEmpty()) {
458             recordInternal.setExerciseLaps(
459                     getLaps().stream()
460                             .map(ExerciseLap::toExerciseLapInternal)
461                             .collect(Collectors.toList()));
462         }
463 
464         if (getSegments() != null && !getSegments().isEmpty()) {
465             recordInternal.setExerciseSegments(
466                     getSegments().stream()
467                             .map(ExerciseSegment::toSegmentInternal)
468                             .collect(Collectors.toList()));
469         }
470         recordInternal.setExerciseType(mExerciseType);
471         if (mPlannedExerciseSessionId != null) {
472             recordInternal.setPlannedExerciseSessionId(UUID.fromString(mPlannedExerciseSessionId));
473         }
474         return recordInternal;
475     }
476 }
477