Skip to content
AF
Watch Apps

ExerciseClient in Kotlin: Capabilities to endExerciseAsync

Last verified October 3, 2026 · 6 min read

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().

What Health Services is and when to choose it over a direct Bluetooth sensor is on Wear OS Health Services; how the exercise shapes a watch app's architecture is on 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 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#

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#

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#

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#

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#

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#

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; writing it to the phone-side record is the Health Connect integration.

Frequently asked questions

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.
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.
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.
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.

All 55 questions in Watch Apps →

Keep reading

Elsewhere on the site

Pages that share this one’s concepts and sources, from other sections.

Next steps

Was this page useful?

Independent comparison, last reviewed October 3, 2026. Pricing, rate limits, and feature availability change often — confirm current details in each provider’s official documentation before you commit. Product and company names are trademarks of their respective owners; AIFitnessAPI is not affiliated with, endorsed by, or sponsored by any product listed here.

← All watch apps · by AIFitnessAPI