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