1 /*
2  * Copyright (C) 2024 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 android.annotation.FlaggedApi;
20 import android.annotation.NonNull;
21 import android.annotation.Nullable;
22 import android.health.connect.internal.datatypes.PlannedExerciseSessionRecordInternal;
23 
24 import java.time.Duration;
25 import java.time.Instant;
26 import java.time.LocalDate;
27 import java.time.LocalTime;
28 import java.time.ZoneId;
29 import java.time.ZoneOffset;
30 import java.util.ArrayList;
31 import java.util.List;
32 import java.util.Objects;
33 import java.util.UUID;
34 import java.util.stream.Collectors;
35 
36 /**
37  * Captures a planned exercise session, also commonly referred to as a training plan.
38  *
39  * <p>Each record contains a start time, end time, an exercise type and a list of {@link
40  * PlannedExerciseBlock} which describe the details of the planned session. The start and end times
41  * may be in the future.
42  */
43 @Identifier(recordIdentifier = RecordTypeIdentifier.RECORD_TYPE_PLANNED_EXERCISE_SESSION)
44 @FlaggedApi("com.android.healthconnect.flags.training_plans")
45 public final class PlannedExerciseSessionRecord extends IntervalRecord {
46     private final Boolean mHasExplicitTime;
47 
48     @ExerciseSessionType.ExerciseSessionTypes private final int mExerciseType;
49 
50     @Nullable private final CharSequence mTitle;
51 
52     @Nullable private final CharSequence mNotes;
53 
54     private final List<PlannedExerciseBlock> mBlocks;
55 
56     @Nullable private final String mCompletedExerciseSessionId;
57 
58     /**
59      * @param metadata Metadata to be associated with the record. See {@link Metadata}.
60      * @param startTime Start time of this planned session. May be in the future.
61      * @param startZoneOffset Zone offset of the user when the planned session should start.
62      * @param endTime End time of this planned session. May be in the future.
63      * @param endZoneOffset Zone offset of the user when the planned session should end.
64      * @param hasExplicitTime Whether an explicit time value has been provided.
65      * @param title The title of this planned session.
66      * @param notes Notes for this planned session.
67      * @param exerciseType The exercise type of this planned s.ession. Allowed values: {@link
68      *     ExerciseSessionType.ExerciseSessionTypes }.
69      * @param blocks The {@link PlannedExerciseBlock} that contain the details of this planned
70      *     session.
71      * @param completedExerciseSessionId The id of the exercise session that completed this planned
72      *     exercise.
73      * @param skipValidation Boolean flag to skip validation of record values.
74      */
PlannedExerciseSessionRecord( @onNull Metadata metadata, @NonNull Instant startTime, @NonNull ZoneOffset startZoneOffset, @NonNull Instant endTime, @NonNull ZoneOffset endZoneOffset, @NonNull Boolean hasExplicitTime, @Nullable CharSequence title, @Nullable CharSequence notes, @NonNull @ExerciseSessionType.ExerciseSessionTypes int exerciseType, @NonNull List<PlannedExerciseBlock> blocks, @Nullable String completedExerciseSessionId, boolean skipValidation)75     private PlannedExerciseSessionRecord(
76             @NonNull Metadata metadata,
77             @NonNull Instant startTime,
78             @NonNull ZoneOffset startZoneOffset,
79             @NonNull Instant endTime,
80             @NonNull ZoneOffset endZoneOffset,
81             @NonNull Boolean hasExplicitTime,
82             @Nullable CharSequence title,
83             @Nullable CharSequence notes,
84             @NonNull @ExerciseSessionType.ExerciseSessionTypes int exerciseType,
85             @NonNull List<PlannedExerciseBlock> blocks,
86             @Nullable String completedExerciseSessionId,
87             boolean skipValidation) {
88         super(
89                 metadata,
90                 startTime,
91                 startZoneOffset,
92                 endTime,
93                 endZoneOffset,
94                 skipValidation,
95                 /* enforceFutureTimeRestrictions= */ false);
96         mHasExplicitTime = hasExplicitTime;
97         mTitle = title;
98         mNotes = notes;
99         mExerciseType = exerciseType;
100         mBlocks = blocks;
101         mCompletedExerciseSessionId = completedExerciseSessionId;
102     }
103 
104     /** Returns the exercise type of this planned session. */
105     @ExerciseSessionType.ExerciseSessionTypes
getExerciseType()106     public int getExerciseType() {
107         return mExerciseType;
108     }
109 
110     /** Returns notes for this planned session. Returns null if it doesn't have notes. */
111     @Nullable
getNotes()112     public CharSequence getNotes() {
113         return mNotes;
114     }
115 
116     /** Returns title of this planned session. Returns null if it doesn't have a title. */
117     @Nullable
getTitle()118     public CharSequence getTitle() {
119         return mTitle;
120     }
121 
122     /** Returns the start date of the planned session. */
123     @NonNull
getStartDate()124     public LocalDate getStartDate() {
125         return getStartTime().atOffset(getStartZoneOffset()).toLocalDate();
126     }
127 
128     /** Returns the expected duration of the planned session. */
129     @NonNull
getDuration()130     public Duration getDuration() {
131         return Duration.between(getStartTime(), getEndTime());
132     }
133 
134     /**
135      * Returns whether this planned session has an explicit time. If only a date was provided this
136      * will be false.
137      */
hasExplicitTime()138     public boolean hasExplicitTime() {
139         return mHasExplicitTime;
140     }
141 
142     /**
143      * Returns the id of exercise session that completed this planned session. Returns null if none
144      * exists.
145      */
146     @Nullable
getCompletedExerciseSessionId()147     public String getCompletedExerciseSessionId() {
148         return mCompletedExerciseSessionId;
149     }
150 
151     /**
152      * Returns the exercise blocks for this step.
153      *
154      * @return An unmodifiable list of {@link PlannedExerciseBlock}.
155      */
156     @NonNull
getBlocks()157     public List<PlannedExerciseBlock> getBlocks() {
158         return mBlocks;
159     }
160 
161     @Override
equals(@ullable Object o)162     public boolean equals(@Nullable Object o) {
163         if (this == o) return true;
164         if (!(o instanceof PlannedExerciseSessionRecord)) return false;
165         if (!super.equals(o)) return false;
166         PlannedExerciseSessionRecord that = (PlannedExerciseSessionRecord) o;
167         return RecordUtils.isEqualNullableCharSequences(this.getNotes(), that.getNotes())
168                 && RecordUtils.isEqualNullableCharSequences(this.getTitle(), that.getTitle())
169                 && this.getExerciseType() == that.getExerciseType()
170                 && this.hasExplicitTime() == that.hasExplicitTime()
171                 && Objects.equals(
172                         this.getCompletedExerciseSessionId(), that.getCompletedExerciseSessionId())
173                 && Objects.equals(this.getBlocks(), that.getBlocks());
174     }
175 
176     @Override
hashCode()177     public int hashCode() {
178         return Objects.hash(
179                 super.hashCode(),
180                 getNotes(),
181                 getTitle(),
182                 getExerciseType(),
183                 hasExplicitTime(),
184                 this.getCompletedExerciseSessionId(),
185                 this.getBlocks());
186     }
187 
188     /** Builder class for {@link PlannedExerciseSessionRecord}. */
189     public static final class Builder {
190         @NonNull private Metadata mMetadata;
191         @NonNull private Instant mStartTime;
192         @NonNull private Instant mEndTime;
193         @NonNull private ZoneOffset mStartZoneOffset;
194         @NonNull private ZoneOffset mEndZoneOffset;
195         @ExerciseSessionType.ExerciseSessionTypes private int mExerciseType;
196         @Nullable private CharSequence mNotes;
197         @Nullable private CharSequence mTitle;
198         private Boolean mHasExplicitTime;
199         private final List<PlannedExerciseBlock> mBlocks = new ArrayList<>();
200         @Nullable private String mCompletedExerciseSessionId;
201 
202         /**
203          * @param metadata Metadata to be associated with the record. See {@link Metadata}.
204          * @param exerciseType Type of exercise (e.g. walking, swimming). Allowed values: {@link
205          *     ExerciseSessionType}.
206          * @param startTime Expected start time of this planned session.
207          * @param endTime Expected end time of this planned session.
208          */
Builder( @onNull Metadata metadata, @ExerciseSessionType.ExerciseSessionTypes int exerciseType, @NonNull Instant startTime, @NonNull Instant endTime)209         public Builder(
210                 @NonNull Metadata metadata,
211                 @ExerciseSessionType.ExerciseSessionTypes int exerciseType,
212                 @NonNull Instant startTime,
213                 @NonNull Instant endTime) {
214             Objects.requireNonNull(metadata);
215             Objects.requireNonNull(startTime);
216             Objects.requireNonNull(endTime);
217             mMetadata = metadata;
218             mExerciseType = exerciseType;
219             mHasExplicitTime = true;
220             mStartTime = startTime;
221             mStartZoneOffset = ZoneOffset.systemDefault().getRules().getOffset(mStartTime);
222             mEndTime = endTime;
223             mEndZoneOffset = ZoneOffset.systemDefault().getRules().getOffset(mEndTime);
224         }
225 
226         /**
227          * @param metadata Metadata to be associated with the record. See {@link Metadata}.
228          * @param exerciseType Type of exercise (e.g. walking, swimming). Required field. Allowed
229          *     values: {@link ExerciseSessionType}.
230          * @param startDate The start date of this planned session. The underlying time of the
231          *     session will be set to noon of the specified day. The end time will be determined by
232          *     adding the duration to this.
233          * @param duration The expected duration of the planned session.
234          */
Builder( @onNull Metadata metadata, @ExerciseSessionType.ExerciseSessionTypes int exerciseType, @NonNull LocalDate startDate, @NonNull Duration duration)235         public Builder(
236                 @NonNull Metadata metadata,
237                 @ExerciseSessionType.ExerciseSessionTypes int exerciseType,
238                 @NonNull LocalDate startDate,
239                 @NonNull Duration duration) {
240             Objects.requireNonNull(metadata);
241             Objects.requireNonNull(startDate);
242             Objects.requireNonNull(duration);
243             mMetadata = metadata;
244             mExerciseType = exerciseType;
245             mHasExplicitTime = false;
246             mStartTime =
247                     startDate.atTime(LocalTime.NOON).atZone(ZoneId.systemDefault()).toInstant();
248             mStartZoneOffset = ZoneOffset.systemDefault().getRules().getOffset(mStartTime);
249             mEndTime = mStartTime.plus(duration);
250             mEndZoneOffset = ZoneOffset.systemDefault().getRules().getOffset(mEndTime);
251         }
252 
253         /** Set the metadata for the record. */
254         @NonNull
setMetadata(@onNull Metadata metadata)255         public Builder setMetadata(@NonNull Metadata metadata) {
256             Objects.requireNonNull(metadata);
257             this.mMetadata = metadata;
258             return this;
259         }
260 
261         /** Sets the exercise type. */
262         @NonNull
setExerciseType(@xerciseSessionType.ExerciseSessionTypes int exerciseType)263         public Builder setExerciseType(@ExerciseSessionType.ExerciseSessionTypes int exerciseType) {
264             this.mExerciseType = exerciseType;
265             return this;
266         }
267 
268         /** Sets the planned start time of the session. */
269         @NonNull
setStartTime(@onNull Instant startTime)270         public Builder setStartTime(@NonNull Instant startTime) {
271             Objects.requireNonNull(startTime);
272             this.mStartTime = startTime;
273             mStartZoneOffset = ZoneOffset.systemDefault().getRules().getOffset(mStartTime);
274             return this;
275         }
276 
277         /** Sets the planned end time of the session. */
278         @NonNull
setEndTime(@onNull Instant endTime)279         public Builder setEndTime(@NonNull Instant endTime) {
280             Objects.requireNonNull(endTime);
281             this.mEndTime = endTime;
282             mEndZoneOffset = ZoneOffset.systemDefault().getRules().getOffset(mEndTime);
283             return this;
284         }
285 
286         /** Sets the zone offset of when the workout should start. */
287         @NonNull
setStartZoneOffset(@onNull ZoneOffset startZoneOffset)288         public Builder setStartZoneOffset(@NonNull ZoneOffset startZoneOffset) {
289             Objects.requireNonNull(startZoneOffset);
290             mStartZoneOffset = startZoneOffset;
291             return this;
292         }
293 
294         /** Sets the zone offset of when the workout should end. */
295         @NonNull
setEndZoneOffset(@onNull ZoneOffset endZoneOffset)296         public Builder setEndZoneOffset(@NonNull ZoneOffset endZoneOffset) {
297             Objects.requireNonNull(endZoneOffset);
298             mEndZoneOffset = endZoneOffset;
299             return this;
300         }
301 
302         /** Sets the start zone offset of this record to system default. */
303         @NonNull
clearStartZoneOffset()304         public Builder clearStartZoneOffset() {
305             mStartZoneOffset = RecordUtils.getDefaultZoneOffset();
306             return this;
307         }
308 
309         /** Sets the start zone offset of this record to system default. */
310         @NonNull
clearEndZoneOffset()311         public Builder clearEndZoneOffset() {
312             mEndZoneOffset = RecordUtils.getDefaultZoneOffset();
313             return this;
314         }
315 
316         /**
317          * Sets notes for this activity.
318          *
319          * @param notes Notes for this activity.
320          */
321         @NonNull
setNotes(@ullable CharSequence notes)322         public Builder setNotes(@Nullable CharSequence notes) {
323             mNotes = notes;
324             return this;
325         }
326 
327         /**
328          * Sets a title of this planned session.
329          *
330          * @param title Title of this activity.
331          */
332         @NonNull
setTitle(@ullable CharSequence title)333         public Builder setTitle(@Nullable CharSequence title) {
334             mTitle = title;
335             return this;
336         }
337 
338         /**
339          * Adds a block to this planned session..
340          *
341          * @param block An {@link PlannedExerciseBlock} to add to this planned session..
342          */
343         @NonNull
addBlock(@onNull PlannedExerciseBlock block)344         public Builder addBlock(@NonNull PlannedExerciseBlock block) {
345             Objects.requireNonNull(block);
346             mBlocks.add(block);
347             return this;
348         }
349 
350         /**
351          * Sets the blocks of this planned session.
352          *
353          * @param blocks A list of {@link PlannedExerciseBlock} to set for this planned session.
354          */
355         @NonNull
setBlocks(@onNull List<PlannedExerciseBlock> blocks)356         public Builder setBlocks(@NonNull List<PlannedExerciseBlock> blocks) {
357             Objects.requireNonNull(blocks);
358             mBlocks.clear();
359             mBlocks.addAll(blocks);
360             return this;
361         }
362 
363         /** Clears the blocks of this planned session. */
364         @NonNull
clearBlocks()365         public Builder clearBlocks() {
366             mBlocks.clear();
367             return this;
368         }
369 
370         /**
371          * Set exercise session ID of the workout that completed this planned session.
372          *
373          * @hide
374          */
375         @NonNull
setCompletedExerciseSessionId(@ullable String id)376         public Builder setCompletedExerciseSessionId(@Nullable String id) {
377             this.mCompletedExerciseSessionId = id;
378             return this;
379         }
380 
381         /**
382          * @return Object of {@link PlannedExerciseSessionRecord} without validating the values.
383          * @hide
384          */
385         @NonNull
buildWithoutValidation()386         public PlannedExerciseSessionRecord buildWithoutValidation() {
387             return new PlannedExerciseSessionRecord(
388                     mMetadata,
389                     mStartTime,
390                     mStartZoneOffset,
391                     mEndTime,
392                     mEndZoneOffset,
393                     mHasExplicitTime,
394                     mTitle,
395                     mNotes,
396                     mExerciseType,
397                     List.copyOf(mBlocks),
398                     mCompletedExerciseSessionId,
399                     true);
400         }
401 
402         /** Returns {@link PlannedExerciseSessionRecord}. */
403         @NonNull
build()404         public PlannedExerciseSessionRecord build() {
405             if (!ExerciseSessionType.isKnownSessionType(mExerciseType)) {
406                 throw new IllegalArgumentException("Invalid exercise session type");
407             }
408             return new PlannedExerciseSessionRecord(
409                     mMetadata,
410                     mStartTime,
411                     mStartZoneOffset,
412                     mEndTime,
413                     mEndZoneOffset,
414                     mHasExplicitTime,
415                     mTitle,
416                     mNotes,
417                     mExerciseType,
418                     List.copyOf(mBlocks),
419                     mCompletedExerciseSessionId,
420                     false);
421         }
422     }
423 
424     /** @hide */
425     @Override
toRecordInternal()426     public PlannedExerciseSessionRecordInternal toRecordInternal() {
427         PlannedExerciseSessionRecordInternal recordInternal =
428                 (PlannedExerciseSessionRecordInternal)
429                         new PlannedExerciseSessionRecordInternal()
430                                 .setUuid(getMetadata().getId())
431                                 .setPackageName(getMetadata().getDataOrigin().getPackageName())
432                                 .setLastModifiedTime(
433                                         getMetadata().getLastModifiedTime().toEpochMilli())
434                                 .setClientRecordId(getMetadata().getClientRecordId())
435                                 .setClientRecordVersion(getMetadata().getClientRecordVersion())
436                                 .setManufacturer(getMetadata().getDevice().getManufacturer())
437                                 .setModel(getMetadata().getDevice().getModel())
438                                 .setDeviceType(getMetadata().getDevice().getType())
439                                 .setRecordingMethod(getMetadata().getRecordingMethod());
440         recordInternal.setStartTime(getStartTime().toEpochMilli());
441         recordInternal.setEndTime(getEndTime().toEpochMilli());
442         recordInternal.setStartZoneOffset(getStartZoneOffset().getTotalSeconds());
443         recordInternal.setEndZoneOffset(getEndZoneOffset().getTotalSeconds());
444         if (getNotes() != null) {
445             recordInternal.setNotes(getNotes().toString());
446         }
447         if (getTitle() != null) {
448             recordInternal.setTitle(getTitle().toString());
449         }
450         recordInternal.setExerciseType(getExerciseType());
451         recordInternal.setHasExplicitTime(hasExplicitTime());
452         // Although not possible to set this via public API, internally we may convert from internal
453         // representation to external, then back to internal. Thus, we need to preserve this value
454         // during a round trip.
455         if (getCompletedExerciseSessionId() != null) {
456             recordInternal.setCompletedExerciseSessionId(
457                     UUID.fromString(getCompletedExerciseSessionId()));
458         }
459         recordInternal.setExerciseBlocks(
460                 getBlocks().stream().map(it -> it.toInternalObject()).collect(Collectors.toList()));
461         return recordInternal;
462     }
463 }
464