ExerciseUpdate.kt

/*
 * Copyright (C) 2021 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.health.services.client.data

import androidx.health.services.client.proto.DataProto.ExerciseUpdate.LatestMetricsEntry as LatestMetricsEntryProto
import androidx.annotation.RestrictTo
import androidx.annotation.RestrictTo.Scope
import androidx.health.services.client.data.ExerciseEndReason.Companion.toProto
import androidx.health.services.client.data.ExerciseUpdate.ActiveDurationCheckpoint
import androidx.health.services.client.proto.DataProto
import androidx.health.services.client.proto.DataProto.AchievedExerciseGoal
import java.time.Duration
import java.time.Instant

/** Contains the latest updated state and metrics for the current exercise. */
@Suppress("ParcelCreator")
public class ExerciseUpdate internal constructor(
    /** Returns the list of the latest [DataPoint]s. */
    public val latestMetrics: DataPointContainer,

    /**
     * Returns the latest [ExerciseGoalType.ONE_TIME_GOAL] [ExerciseGoal]s that have been achieved.
     * [ExerciseGoalType.MILESTONE] [ExerciseGoal]s will be returned via
     * [latestMilestoneMarkerSummaries].
     */
    public val latestAchievedGoals: Set<ExerciseGoal<out Number>>,

    /** Returns the latest [MilestoneMarkerSummary]s. */
    public val latestMilestoneMarkerSummaries: Set<MilestoneMarkerSummary>,

    /**
     * Returns the [ExerciseStateInfo] containing the current [ExerciseState] and
     * [ExerciseEndReason], if applicable.
     */
    public val exerciseStateInfo: ExerciseStateInfo,

    /**
     * Returns the [ExerciseConfig] used by the exercise when the [ExerciseUpdate] was dispatched
     * and returns `null` if the exercise is in prepare phase and hasn't been started yet.
     */
    public val exerciseConfig: ExerciseConfig? = null,

    /**
     * Returns the [ActiveDurationCheckpoint] which can be used to determine the active duration of
     * the exercise in a way that is consistent with Health Services. Clients can anchor their
     * application timers against this to ensure their view of the active duration matches the view
     * of Health Services.
     */
    public val activeDurationCheckpoint: ActiveDurationCheckpoint? = null,

    /** The duration since boot when this ExerciseUpdate was created. */
    private val updateDurationFromBoot: Duration? = null,

    /**
     * Returns the time at which the exercise was started or `null` if the exercise is in prepare
     * phase and hasn't started yet.
     */
    public val startTime: Instant? = null,

    internal val activeDurationLegacy: Duration,
) {
    @RestrictTo(Scope.LIBRARY)
    public constructor(
        proto: DataProto.ExerciseUpdate
    ) : this(
        exerciseUpdateProtoToDataPointContainer(proto),
        proto.latestAchievedGoalsList.map { ExerciseGoal.fromProto(it.exerciseGoal) }.toSet(),
        proto.mileStoneMarkerSummariesList.map { MilestoneMarkerSummary(it) }.toSet(),
        ExerciseStateInfo(
            ExerciseState.fromProto(proto.state)
                ?: throw IllegalArgumentException("Invalid ExerciseState: ${proto.state}"),
            ExerciseEndReason.fromProto(proto.exerciseEndReason)
        ),
        if (proto.hasExerciseConfig()) ExerciseConfig(proto.exerciseConfig) else null,
        if (proto.hasActiveDurationCheckpoint()) {
            ActiveDurationCheckpoint.fromProto(proto.activeDurationCheckpoint)
        } else {
            null
        },
        if (proto.hasUpdateDurationFromBootMs()) {
            Duration.ofMillis(proto.updateDurationFromBootMs)
        } else {
            null
        },
        if (proto.hasStartTimeEpochMs()) Instant.ofEpochMilli(proto.startTimeEpochMs) else null,
        Duration.ofMillis(proto.activeDurationMs),
    )

    /**
     * This records the last time the exercise transitioned from an active to an inactive state or
     * from an inactive to an active state, where inactive states match those found in
     * [ExerciseState.isPaused] or [ExerciseState.isEnded]. This can be used to calculate the
     * exercise active duration in a way that is consistent with Health Service's view of the
     * exercise.
     *
     * If the exercise is currently inactive, the exercise’s active duration will match
     * [activeDuration] below. If the exercise is active, the active duration can be calculated by
     * `activeDuration + (now() - time)`.
     */
    public class ActiveDurationCheckpoint(
        /**
         * Returns the time at which the exercise last transitioned between the active or inactive
         * states.
         */
        public val time: Instant,

        /**
         * Returns the active duration of the exercise at the time it last transitioned to "active",
         * or the duration when it transitioned to inactive if it's currently paused or stopped.
         */
        public val activeDuration: Duration,
    ) {

        @RestrictTo(Scope.LIBRARY)
        internal fun toProto(): DataProto.ExerciseUpdate.ActiveDurationCheckpoint =
            DataProto.ExerciseUpdate.ActiveDurationCheckpoint.newBuilder()
                .setTimeEpochMs(time.toEpochMilli())
                .setActiveDurationMs(activeDuration.toMillis())
                .build()

        override fun toString(): String =
            "ActiveDurationCheckpoint(time=$time, activeDuration=$activeDuration)"

        override fun equals(other: Any?): Boolean {
            if (this === other) return true
            if (javaClass != other?.javaClass) return false

            other as ActiveDurationCheckpoint

            if (time != other.time) return false
            if (activeDuration != other.activeDuration) return false

            return true
        }

        override fun hashCode(): Int {
            var result = time.hashCode()
            result = 31 * result + activeDuration.hashCode()
            return result
        }

        internal companion object {
            @RestrictTo(Scope.LIBRARY)
            internal fun fromProto(
                proto: DataProto.ExerciseUpdate.ActiveDurationCheckpoint
            ): ActiveDurationCheckpoint? =
                ActiveDurationCheckpoint(
                    Instant.ofEpochMilli(proto.timeEpochMs),
                    Duration.ofMillis(proto.activeDurationMs)
                )
        }
    }

    internal val proto: DataProto.ExerciseUpdate = getExerciseUpdateProto()

    private fun getExerciseUpdateProto(): DataProto.ExerciseUpdate {
        val builder =
            DataProto.ExerciseUpdate.newBuilder()
                .setState(exerciseStateInfo.state.toProto())
                .setActiveDurationMs(activeDurationLegacy.toMillis())
                .addAllLatestMetrics(
                    latestMetrics.sampleDataPoints
                        .groupBy { it.dataType }
                        .map {
                            LatestMetricsEntryProto.newBuilder()
                                .setDataType(it.key.proto)
                                .addAllDataPoints(it.value.map(SampleDataPoint<*>::proto))
                                .build()
                        }
                        // If we don't sort, equals() may not work.
                        .sortedBy { entry -> entry.dataType.name }
                )
                .addAllLatestMetrics(
                    latestMetrics.intervalDataPoints
                        .groupBy { it.dataType }
                        .map {
                            LatestMetricsEntryProto.newBuilder()
                                .setDataType(it.key.proto)
                                .addAllDataPoints(it.value.map(IntervalDataPoint<*>::proto))
                                .build()
                        }
                        // If we don't sort, equals() may not work.
                        .sortedBy { entry -> entry.dataType.name }
                )
                .addAllLatestAggregateMetrics(
                    latestMetrics.statisticalDataPoints
                        .map { it.proto }
                        // If we don't sort, equals() may not work.
                        .sortedBy { entry -> entry.statisticalDataPoint.dataType.name }
                ).addAllLatestAggregateMetrics(latestMetrics.cumulativeDataPoints
                    .map { it.proto }
                    // If we don't sort, equals() may not work.
                    .sortedBy { entry -> entry.cumulativeDataPoint.dataType.name })
                .addAllLatestAchievedGoals(latestAchievedGoals.map {
                    AchievedExerciseGoal.newBuilder().setExerciseGoal(it.proto).build()
                }).addAllMileStoneMarkerSummaries(latestMilestoneMarkerSummaries.map { it.proto })
                .setExerciseEndReason((exerciseStateInfo.endReason).toProto())

        startTime?.let { builder.setStartTimeEpochMs(startTime.toEpochMilli()) }
        updateDurationFromBoot?.let {
            builder.setUpdateDurationFromBootMs(updateDurationFromBoot.toMillis())
        }
        exerciseConfig?.let { builder.setExerciseConfig(exerciseConfig.toProto()) }
        activeDurationCheckpoint?.let {
            builder.setActiveDurationCheckpoint(activeDurationCheckpoint.toProto())
        }

        return builder.build()
    }

    /**
     * Returns the duration since boot when this ExerciseUpdate was created.
     *
     * @throws IllegalStateException if this [ExerciseUpdate] does not contain a valid
     * `updateDurationFromBoot` which may happen if the Health Services app is out of date
     */
    public fun getUpdateDurationFromBoot(): Duration =
        updateDurationFromBoot
            ?: error(
                "updateDurationFromBoot unavailable; is the Health Services APK out of date?"
            )

    /**
     * Returns the ActiveDuration of the exercise at the time of the provided [IntervalDataPoint].
     * The provided [IntervalDataPoint] should be present in this [ExerciseUpdate].
     *
     * @throws IllegalArgumentException if [dataPoint] is not present in this [ExerciseUpdate]
     * @throws IllegalStateException if this [ExerciseUpdate] does not contain a valid
     * `updateDurationFromBoot` which may happen if the Health Services app is out of date
     */
    public fun getActiveDurationAtDataPoint(dataPoint: IntervalDataPoint<*>): Duration =
        getActiveDurationAtDataPoint(dataPoint, dataPoint.endDurationFromBoot)

    /**
     * Returns the ActiveDuration of the exercise at the time of the provided [SampleDataPoint].
     * The provided [SampleDataPoint] should be present in this [ExerciseUpdate].
     *
     * @throws IllegalArgumentException if [dataPoint] is not present in this [ExerciseUpdate]
     */
    public fun getActiveDurationAtDataPoint(dataPoint: SampleDataPoint<*>): Duration =
        getActiveDurationAtDataPoint(dataPoint, dataPoint.timeDurationFromBoot)

    private fun getActiveDurationAtDataPoint(
        dataPoint: DataPoint<*>,
        durationFromBoot: Duration
    ): Duration {
        val dataPointList = latestMetrics.dataPoints[dataPoint.dataType]
        if (dataPointList?.indexOf(dataPoint) == -1) {
            throw IllegalArgumentException("dataPoint not found in ExerciseUpdate")
        }

        // If activeDurationCheckpoint is null, user has not started their activity yet. Default to
        // zero.
        if (activeDurationCheckpoint == null) {
            return Duration.ZERO
        }

        // If we are paused then the last active time applies to all updates.
        if (exerciseStateInfo.state == ExerciseState.USER_PAUSED ||
            exerciseStateInfo.state == ExerciseState.AUTO_PAUSED
        ) {
            return activeDurationCheckpoint.activeDuration
        }

        // Active duration applies to when this update was generated so calculate for the given time
        // by working backwards.
        // First find time since this point was generated.
        val durationSinceProvidedTime = getUpdateDurationFromBoot().minus(durationFromBoot)
        return activeDurationLegacy.minus(durationSinceProvidedTime)
    }

    override fun toString(): String =
        "ExerciseUpdate(" +
            "state=$exerciseStateInfo.state, " +
            "startTime=$startTime, " +
            "updateDurationFromBoot=$updateDurationFromBoot, " +
            "latestMetrics=$latestMetrics, " +
            "latestAchievedGoals=$latestAchievedGoals, " +
            "latestMilestoneMarkerSummaries=$latestMilestoneMarkerSummaries, " +
            "exerciseConfig=$exerciseConfig, " +
            "activeDurationCheckpoint=$activeDurationCheckpoint, " +
            "exerciseEndReason=${exerciseStateInfo.endReason}" +
            ")"

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is ExerciseUpdate) return false

        if (startTime != other.startTime) return false
        if (latestMetrics != other.latestMetrics) return false
        if (latestAchievedGoals != other.latestAchievedGoals) return false
        if (latestMilestoneMarkerSummaries != other.latestMilestoneMarkerSummaries) return false
        if (exerciseConfig != other.exerciseConfig) return false
        if (activeDurationCheckpoint != other.activeDurationCheckpoint) return false
        if (exerciseStateInfo != other.exerciseStateInfo) return false
        if (updateDurationFromBoot != other.updateDurationFromBoot) return false

        return true
    }

    override fun hashCode(): Int {
        var result = startTime?.hashCode() ?: 0
        result = 31 * result + latestMetrics.hashCode()
        result = 31 * result + latestAchievedGoals.hashCode()
        result = 31 * result + latestMilestoneMarkerSummaries.hashCode()
        result = 31 * result + (exerciseConfig?.hashCode() ?: 0)
        result = 31 * result + (activeDurationCheckpoint?.hashCode() ?: 0)
        result = 31 * result + exerciseStateInfo.hashCode()
        result = 31 * result + (updateDurationFromBoot?.hashCode() ?: 0)
        return result
    }

    public companion object {
        internal fun exerciseUpdateProtoToDataPointContainer(
            proto: DataProto.ExerciseUpdate
        ): DataPointContainer {
            val dataPoints = mutableListOf<DataPoint<*>>()

            proto.latestMetricsList
                .flatMap { it.dataPointsList }
                .forEach {
                    dataPoints += DataPoint.fromProto(it)
                }
            proto.latestAggregateMetricsList
                .forEach {
                    dataPoints += DataPoint.fromProto(it)
                }

            return DataPointContainer(dataPoints)
        }
    }
}