---
title: "ExerciseClient in Kotlin: Capabilities to endExerciseAsync"
canonical: "https://aifitnessapi.com/watch-apps/wear-os-exerciseclient-kotlin"
cluster: "Watch Apps"
primary_query: "exerciseclient startexerciseasync exerciseconfig"
last_reviewed: "2026-10-03"
description: "Wear OS ExerciseClient step by step: capabilities, setUpdateCallback, prepareExerciseAsync, ExerciseConfig, goals, pause and the end reasons."
publisher: "AIFitnessAPI — independent, not sponsored"
cite_as: "\"ExerciseClient in Kotlin: Capabilities to endExerciseAsync\", AIFitnessAPI, https://aifitnessapi.com/watch-apps/wear-os-exerciseclient-kotlin"
---

# ExerciseClient in Kotlin: Capabilities to endExerciseAsync

> On Wear OS you get ExerciseClient from HealthServices.getClient(context).exerciseClient, check getCapabilitiesAsync() for the exercise type, register one ExerciseUpdateCallback with setUpdateCallback, optionally warm GPS and heart rate with prepareExerciseAsync(WarmUpConfig), then start with startExerciseAsync(ExerciseConfig). Google's reference says Health Services allows a single active exercise across all apps, so starting one ends any other with ExerciseEndReason.AUTO_END_SUPERSEDED, and an exercise can be ended automatically with AUTO_END_MISSING_LISTENER if no callback is registered for five minutes. Google's guide tells you to run the workout from a foreground service with foregroundServiceType health and location, and to treat ExerciseUpdate's exerciseStateInfo.state as the source of truth rather than the return of pauseExerciseAsync().

- Canonical: https://aifitnessapi.com/watch-apps/wear-os-exerciseclient-kotlin
- Last reviewed: 2026-10-03
- Publisher: AIFitnessAPI (https://aifitnessapi.com) — independent, not sponsored
- Cite as: "ExerciseClient in Kotlin: Capabilities to endExerciseAsync", AIFitnessAPI, https://aifitnessapi.com/watch-apps/wear-os-exerciseclient-kotlin

---

What Health Services is and when to choose it over a direct Bluetooth sensor is on [Wear OS Health Services](/devices/wear-os-health-services); how the exercise shapes a watch app's architecture is on [Wear OS exercise tracking](/watch-apps/wear-os-exercise-tracking). This page is the code path through `androidx.health.services.client.ExerciseClient`, from Google's guide "Record an exercise with ExerciseClient" and the Jetpack reference, both read on 2026-10-03.

## Dependency and app structure

Google's Gradle snippet, as of this reading, is `implementation("androidx.health:health-services-client:1.1.0")`; check the Jetpack release page for the current version before you pin it. The API is asynchronous and built on `ListenableFuture`, and the reference also lists `suspend` extensions such as `getCapabilities()`, `prepareExercise()`, `pauseExercise()` and `endExercise()` for coroutines.

Google's recommended structure is specific: screens in one activity, and "Manage the workout state, sensor data, ongoing activity, and data with a foreground service." The manifest declares the service with `android:foregroundServiceType="health|location"`, and the guide notes that an app designed only for Wear OS 4 or lower uses `location` alone. The foreground service is also what lets you show [an Ongoing Activity](/engagement/wear-os-ongoing-activity) while the workout runs.

## Permissions, by API level

Google's permissions page lists `READ_HEART_RATE` for heart rate, `ACTIVITY_RECOGNITION` for step-derived types such as `STEPS`, `DISTANCE` and `CALORIES_TOTAL`, and `ACCESS_FINE_LOCATION` for `LOCATION` and `ABSOLUTE_ELEVATION`. `BODY_SENSORS` applies "on Wear OS 5.1 (API level 35) and lower"; for apps targeting API level 36 the guide's migration adds `android.permission.health.READ_HEART_RATE` and caps the legacy permission with `android:maxSdkVersion="35"`. Health Services rejects `prepareExercise` and `startExercise` if the needed permissions are not already granted.

## Check capabilities first

```kotlin
val exerciseClient = HealthServices.getClient(context).exerciseClient

val capabilities = exerciseClient.getCapabilitiesAsync().await()
if (ExerciseType.RUNNING in capabilities.supportedExerciseTypes) {
    val running = capabilities.getExerciseTypeCapabilities(ExerciseType.RUNNING)
    val hasHeartRate = DataType.HEART_RATE_BPM in running.supportedDataTypes
    val canAutoPause = running.supportsAutoPauseAndResume
}
```

Google says to query this "at startup, because they can vary depending on the device" and "might change over time, such as after a software update", and that "`ExerciseClient` rejects requests that are not supported on the device." Use `supportedGoals` and `supportedMilestones` before you build a goal.

## One callback, owned by your app

```kotlin
val callback = object : ExerciseUpdateCallback {
    override fun onExerciseUpdateReceived(update: ExerciseUpdate) {
        val state = update.exerciseStateInfo.state
        val metrics = update.latestMetrics
        val checkpoint = update.activeDurationCheckpoint
        if (state.isEnded) { /* save partial workout, read endReason */ }
    }
    override fun onLapSummaryReceived(lapSummary: ExerciseLapSummary) {}
    override fun onAvailabilityChanged(dataType: DataType<*, *>, availability: Availability) {}
    override fun onRegistered() {}
    override fun onRegistrationFailed(throwable: Throwable) {}
}
exerciseClient.setUpdateCallback(callback)
```

The guide's rules: "Your app can only register a single listener at a time," set it "before starting the workout," and it "only receives updates about exercises your app owns." The reference adds two facts that matter after a process death. Health Services caches updates generated while no callback is active "and deliver[s] them as soon as the callback is registered again." And if there is "ever a five minute period where no `ExerciseUpdateCallback` is registered," the exercise is terminated with `ExerciseEndReason.AUTO_END_MISSING_LISTENER`. Re-register from your foreground service on restart, not from an activity that may never come back.

## Check for an exercise that is already running

```kotlin
val info = exerciseClient.getCurrentExerciseInfoAsync().await()
when (info.exerciseTrackedStatus) {
    OTHER_APP_IN_PROGRESS -> { /* confirm before replacing it */ }
    OWNED_EXERCISE_IN_PROGRESS -> { /* re-attach to your own workout */ }
    NO_EXERCISE_IN_PROGRESS -> { /* start fresh */ }
}
```

Google's reference states the single-exercise rule: starting an exercise "will terminate any active exercise currently in progress," and the app that loses it receives `ExerciseState.ENDED` with `ExerciseEndReason.AUTO_END_SUPERSEDED`. Ask before you replace somebody else's run.

## Warm up without starting the clock

```kotlin
val warmUp = WarmUpConfig(ExerciseType.RUNNING,
                          setOf(DataType.HEART_RATE_BPM, DataType.LOCATION))
exerciseClient.prepareExerciseAsync(warmUp).await()
```

The reference narrows what preparing does: "Sensors available for warmup are GPS `DataType.LOCATION` and HeartRate `DataType.HEART_RATE_BPM`." Other types are a no-op at this stage. The exercise is in `ExerciseState.PREPARING`, availability arrives through `onAvailabilityChanged`, and Google notes `activeDuration` is not affected. If the user leaves the pre-workout screen, the guide says to call `endExercise()` to turn the sensors off. Skip the prepare call entirely if neither heart rate nor location permission was granted, since warming those two is its only job.

## Start with an ExerciseConfig

```kotlin
val calorieGoal = ExerciseGoal.createOneTimeGoal(
    DataTypeCondition(
        dataType = DataType.CALORIES_TOTAL,
        threshold = 250.0,
        comparisonType = ComparisonType.GREATER_THAN_OR_EQUAL
    )
)
val everyKm = ExerciseGoal.createMilestone(
    condition = DataTypeCondition(
        dataType = DataType.DISTANCE_TOTAL,
        threshold = 1_000.0,
        comparisonType = ComparisonType.GREATER_THAN_OR_EQUAL
    ),
    period = 1_000.0
)

val config = ExerciseConfig.builder(ExerciseType.RUNNING)
    .setDataTypes(setOf(DataType.HEART_RATE_BPM, DataType.CALORIES_TOTAL,
                        DataType.DISTANCE, DataType.DISTANCE_TOTAL))
    .setIsGpsEnabled(true)
    .setIsAutoPauseAndResumeEnabled(false)
    .setExerciseGoals(listOf(calorieGoal, everyKm))
    .build()

exerciseClient.startExerciseAsync(config).await()
```

Google distinguishes the two goal kinds: a one-time goal is "triggered when a condition is met," while a milestone "can be triggered multiple times." `isGpsEnabled` "must be true when requesting location data", and when distance is requested without GPS it "defaults to using steps to estimate distance."

The `DataType` names come in pairs. Per the reference, `DISTANCE` is "A distance delta between each reading," while `DISTANCE_TOTAL` is "Total distance since the start of the active exercise". `HEART_RATE_BPM` is the current reading and `HEART_RATE_BPM_STATS` the statistics since the exercise began. Other pairs follow the same pattern: `CALORIES` and `CALORIES_TOTAL`, `STEPS` and `STEPS_TOTAL`, `SPEED` and `SPEED_STATS`, `PACE` and `PACE_STATS`. Request the one your screen shows. A total rebuilt by summing deltas yourself is a second source of truth.

## Pause, resume, end: the update decides

```kotlin
exerciseClient.pauseExerciseAsync().await()   // the button stays disabled...
// ...until exerciseStateInfo.state == ExerciseState.USER_PAUSED arrives
```

Google is explicit: "The workout is not considered paused when the call to `pauseExerciseAsync()` returns, but instead when that state is reflected in the `ExerciseUpdate` message." The reference adds that while paused, "active time and cumulative metrics such as distance will not accumulate," while instantaneous values like heart rate keep updating. The state set is larger than start and stop: `PREPARING`, `USER_STARTING`, `ACTIVE`, `USER_PAUSING`, `USER_PAUSED`, `USER_RESUMING`, `AUTO_PAUSING`, `AUTO_PAUSED`, `AUTO_RESUMING`, `ENDING` and `ENDED`.

`endExerciseAsync()` ends with `ExerciseEndReason.USER_END`. The reference warns that "any on device persisted data about the exercise will be deleted after the summary has been sent back," so write the final update to Room before you let go of it. Google's guide lists the other endings to handle the same way (save the partial workout, remove the Ongoing Activity, tell the user): another app taking over, and permission loss mid-workout with an end reason of `AUTO_END_PERMISSION_LOST`.

## The clock: use the checkpoint, not the update rate

Google says `ExerciseUpdate` delivery slows when the screen is off: messages arrive "only when awake or when a maximum reporting period is reached, such as every 150 seconds." So do not advance a timer per update. Seed it from `ActiveDurationCheckpoint`, using the guide's formula: `(now() - checkpoint.time) + checkpoint.activeDuration`.

Data point times are "the duration since the device booted." Convert with the boot instant, `Instant.ofEpochMilli(System.currentTimeMillis() - SystemClock.elapsedRealtime())`, then `getStartInstant()` or `getEndInstant()`. If you need faster screen-off delivery, for example streaming heart rate to a phone, check `supportedBatchingModeOverrides` for `BatchingMode.HEART_RATE_5_SECONDS`. Google cautions to use it "only in scenarios where it is absolutely necessary."

## Ambient mode and upload

Google says Health Services "batches workout data when the device screen is in ambient mode," so the guide advises not updating the display during the workout in ambient mode, and showing "either up-to-date information or a blank screen." For storage, the guide says to persist with Room and upload "at the end of the exercise using a mechanism like Work Manager." Getting the finished workout off the watch is covered in [Wear OS phone sync](/watch-apps/wear-os-phone-sync); writing it to the phone-side record is [the Health Connect integration](/integrate/google-health-connect).

## FAQ

### What does ExerciseEndReason AUTO_END_MISSING_LISTENER mean on Wear OS?

Health Services ended your exercise because no ExerciseUpdateCallback was registered for five minutes. Google's ExerciseClient reference states that the exercise will be terminated with that reason if there is ever a five minute period where no callback is registered, giving the example of a process that dies and does not re-register within five minutes. Updates generated while no callback is active are cached and delivered when one is registered again, so the fix is to re-register from your foreground service as soon as it restarts.

[Permalink](https://aifitnessapi.com/watch-apps/wear-os-exerciseclient-kotlin#faq-1)

### Which sensors does prepareExerciseAsync actually warm up?

Two. Google's reference for prepareExerciseAsync(WarmUpConfig) says the sensors available for warmup are GPS, as DataType.LOCATION, and heart rate, as DataType.HEART_RATE_BPM, and that other requested data types are a no-op for the prepare stage. The exercise sits in ExerciseState.PREPARING, availability changes arrive through onAvailabilityChanged, and Google's guide notes active duration is not affected. If the user has granted neither the heart rate nor the location permission, the guide says not to call prepare at all.

[Permalink](https://aifitnessapi.com/watch-apps/wear-os-exerciseclient-kotlin#faq-2)

### Should a Wear OS workout request DISTANCE or DISTANCE_TOTAL?

Whichever your screen shows, and usually DISTANCE_TOTAL for a running total. Google's DataType reference describes DISTANCE as a distance delta between each reading in meters and DISTANCE_TOTAL as total distance since the start of the active exercise. The same delta-and-aggregate pattern applies to CALORIES and CALORIES_TOTAL, STEPS and STEPS_TOTAL, and HEART_RATE_BPM and HEART_RATE_BPM_STATS. Summing deltas yourself gives you a second number that can drift from the one Health Services reports to every other app.

[Permalink](https://aifitnessapi.com/watch-apps/wear-os-exerciseclient-kotlin#faq-3)

### Why does my Wear OS workout timer jump when the screen turns back on?

Because updates are batched while the screen is off. Google's guide says the processor delivers ExerciseUpdate messages only when awake or when a maximum reporting period is reached, such as every 150 seconds, and that batched data is delivered immediately when the user looks at the screen. Do not tick a timer per update. Seed it from ActiveDurationCheckpoint with Google's formula, current time minus checkpoint.time plus checkpoint.activeDuration, and let the UI chronometer run on its own between updates.

[Permalink](https://aifitnessapi.com/watch-apps/wear-os-exerciseclient-kotlin#faq-4)
