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