Why strength training apps ignore Wear OS (and how we fixed it)

Most strength apps are iOS-first because a native Wear OS companion means dropping React Native for Kotlin. Here's the wall, the shared message protocol, and what it took.

Alex Moretti
11 min read
April 2026
EngineeringMobileWearables

Why don't most strength training apps support Wear OS?

Because writing a native Wear OS companion requires dropping React Native for Kotlin—a separate codebase most teams won't maintain. Arvo built a native Jetpack Compose module and a JSON message protocol shared with iOS, achieving parity without forking the app.

TL;DR

  • Wear OS doesn't support React Native—building a real companion app means writing native Kotlin + Jetpack Compose, a second codebase most cross-platform teams skip.
  • Arvo shares one JSON message protocol across iOS and Wear OS (workoutStateUpdate, setLogged, timerAdjust), so both watch apps speak the same language to the phone bridge.
  • Communication uses Wearable Data Layer API: MessageClient for real-time events, DataClient for background sync. It's the Android equivalent of WatchConnectivity.
  • Heart rate and workout sessions use Health Services (ExerciseClient); completed sessions write to Health Connect for Google Fit / Samsung Health interop.
  • The integration is managed by an Expo config plugin (withWearApp.ts) that edits settings.gradle, build.gradle, and MainApplication.kt—the native config survives `expo prebuild --clean`.

Market reality: Wear OS is the second-class citizen of fitness apps

Open the Wear OS Play Store and search “strength training.” You'll find companions from Nike, Adidas, a handful of step-counter apps, and a long tail of abandoned projects. The category leaders in lifting—Hevy, Strong, Fitbod, Boostcamp—don't ship Wear OS apps, or ship thin watch-face widgets that aren't real workout companions.

Meanwhile, on the Apple side, most serious lifting apps have Apple Watch support. It's the default assumption. Why the asymmetry?

It comes down to how cross-platform apps are built. The modern stack for fitness is React Native (or Flutter) wrapping a native shell. That stack targets iOS and Android phones beautifully. But it doesn't target Wear OS. The moment you want a wrist companion that's more than a notification mirror, you're writing native Kotlin.

On iOS, SwiftUI for watchOS is close enough to SwiftUI for iOS that teams can share mental model and some component patterns. On Android, there's no such continuity. Your phone app is TypeScript/JSX, your watch app is Kotlin/Compose. Two languages, two IDEs, two deployment pipelines. Most teams do the math and skip it.

The technical wall: no React Native on Wear OS

Could you run React Native on Wear OS? In theory, the Android runtime is there. In practice:

  • RN isn't tested on Wear OS form factors. Layout primitives assume phone screens. Round displays, rotary input, ambient mode, and tile surfaces don't map.
  • Health Services has no JS bindings. ExerciseClient, the API that gives you heart rate, VO2, workout state—it's Kotlin-only. You'd write a bridge anyway.
  • Performance. Wear OS devices have ~500MB RAM and weak CPUs. Shipping a JS runtime on top of the Android runtime on top of that is a bad idea.
  • Compose for Wear OS is where Google puts all watch-specific design primitives. You can't skip it and hit Material for Wear OS spec.

So we committed to native. That means a separate Gradle module, its own Kotlin source tree, its own build.gradle, and a cross-process communication layer to the phone app.

One message protocol, two platforms

The single biggest lever in keeping Wear OS + iOS Watch in parity was sharing the message protocol. Both watches talk to the phone app using the same JSON envelope:

// Shared message types (iOS + Wear OS)
type WatchMessage =
  | { type: 'workoutStateUpdate'; data: WorkoutState }
  | { type: 'setLogged';          data: SetLog }
  | { type: 'timerAdjust';        data: { deltaSeconds: number } }
  | { type: 'heartRateSample';    data: { bpm: number; ts: number } }
  | { type: 'exerciseSelected';   data: { exerciseId: string } }
  | { type: 'workoutComplete';    data: { summary: WorkoutSummary } }

On the phone side, watch-session.service.ts is platform-agnostic. It talks to whichever native module is registered:

// mobile/src/services/watch-session.service.ts
const nativeModule = Platform.select({
  ios:     NativeModules.WatchSessionManager,  // iOS, uses WatchConnectivity
  android: NativeModules.WearOSManager,         // Android, uses Wearable Data Layer
})

nativeModule.sendMessage({ type: 'setLogged', data: {...} })

React code calls useWatchSession() with isSupported = ios || android. Neither side of the phone app knows or cares which watch is listening. That's what kept the feature surface manageable.

On the watch side, iOS uses WatchConnectivity and Wear OS uses Wearable Data Layer. The envelope is identical. State machines, reducers, and UI components are conceptually mirrored even though the code is in different languages.

Wearable Data Layer: MessageClient vs DataClient

Google gives you two communication primitives for phone-to-watch on Android:

MessageClient:
  - Real-time, fire-and-forget messages
  - Use for: set log confirmations, timer ticks, HR samples
  - Not persisted: if watch is off, message is dropped

DataClient:
  - Syncs a key-value dataset, persists until delivered
  - Use for: current workout state, exercise list, user profile
  - Survives disconnections, syncs when watch reconnects

We use both. Workout state (“here's what you're doing today, exercise 3 of 7, next is bench press”) goes in DataClient, because we want the watch to pick up the correct state even if it was off when the user started. Per-set events go in MessageClient, because timeliness matters more than durability—if a heart rate sample is lost, the next one is 1 second away.

The trap we hit: DataClient only syncs when the value changes. If you write the same state twice, the second write is a no-op. We briefly had a bug where restarting a workout with the same exercise order didn't trigger the watch to update. Solved by including a version counter in the state payload.

Health Connect vs HealthKit: architectural differences

On iOS, HealthKit is a privileged system framework you call directly. Permissions are per type (heart rate, workout, steps). You read, write, query with predicates. It's been around since 2014 and everyone uses it.

On Android, Health Connect is a separate app (installed from Play Store) that acts as an intermediary between data providers (Google Fit, Samsung Health, Fitbit, Garmin) and consumers (your app). It's newer (2022), the API is cleaner, but the distribution model is different: the user has to have Health Connect installed and grant your app permissions through its UI, not yours.

For strength training specifically, we write ExerciseSession records with start/end time, exercise type (strength training), and a linked workout title. Heart rate comes in as a separate series from Health Services at session capture time.

// ArvoWear: write completed session to Health Connect
val record = ExerciseSessionRecord(
  startTime    = workout.startedAt,
  endTime      = workout.endedAt,
  exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING,
  title        = workout.name,
  metadata     = Metadata.manualEntry(),
)
healthConnectClient.insertRecords(listOf(record))

One gotcha: Health Connect requires a privacy policy URL and a declared data types list in the manifest. The Play Store review flags apps that ask for exercise permissions without those in place. Budget a full release cycle for the first submission.

What it actually took: a config plugin that survives prebuild

Arvo's mobile app is managed with Expo. Running expo prebuild --clean wipes the android/ directory and regenerates it from the config. Any hand-edits are lost. So our Wear OS module had to be added via code, not Android Studio.

The durable solution is an Expo config plugin:

// mobile/plugins/withWearApp/withWearApp.ts
export const withWearApp: ConfigPlugin = (config) => {
  // 1. Add :wear module to settings.gradle
  config = withSettingsGradle(config, (cfg) => {
    cfg.modResults.contents += `\ninclude ':wear'\n`
    return cfg
  })

  // 2. Add Compose compiler plugin to root build.gradle
  config = withProjectBuildGradle(config, (cfg) => {
    cfg.modResults.contents = cfg.modResults.contents.replace(
      /dependencies\s*\{/,
      `dependencies {\n  classpath '${COMPOSE_COMPILER_PLUGIN}'`
    )
    return cfg
  })

  // 3. Register WearOSPackage in MainApplication.kt
  config = withMainApplication(config, (cfg) => {
    cfg.modResults.contents = addPackage(cfg.modResults.contents, 'WearOSPackage')
    return cfg
  })

  return config
}

The plugin runs on every prebuild. The mobile/wear/ directory lives at the root of the mobile repo, outside android/, so it survives. Gradle finds it via the plugin-injected include ':wear', builds it alongside the main app, and the result is a single APK that installs both the phone app and the watch companion.

One detail that cost us a day: the Jetpack Compose Kotlin compiler plugin has to be added at the root build.gradle, not just the :wear module. If you only add it at the module level, Compose builds fine but Wear OS preview annotations break in Android Studio, making iteration painful.

What shipped

Feature parity with the Apple Watch app:

  • On-wrist set logging with rotary-input weight adjustment.
  • Rest timer with haptic completion.
  • Live heart rate via Health Services ExerciseClient.
  • Workout summary on completion, synced back to the phone.
  • Health Connect export for Google Fit / Samsung Health.

Device coverage is Pixel Watch 1/2/3 and Galaxy Watch 4/5/6 on Wear OS 3+. We don't test older hardware; the Health Services API surface is only reliably stable from Wear OS 3 onwards.

The trade-offs we accepted: the Wear OS companion is roughly a month behind the iOS watch app on new features, because each change ships to two codebases. The shared message protocol keeps the gap small—usually we add a new WatchMessage variant, handle it on iOS first, then mirror the handler in Kotlin. But it's still two diffs, two test runs, two review cycles.

Why bother

The honest answer is that Wear OS users are underserved in strength training. If you're on Pixel Watch and you lift, your options are thin. That's a small but real audience, and the engineering bar to reach them is what keeps everyone else out.

We're happy to eat that cost. Arvo's Wear OS companion is live; see the Wear OS page for install instructions. The underlying watch session architecture is shared with the Apple Watch app, which we've written about in context of the multi-agent periodization engine. Pricing details are on the pricing page, and companion features include AI Cardio Coach and Gym Crew.


References: Android developer docs on Wearable Data Layer, Health Services, and Health Connect (developer.android.com, 2026). Compose for Wear OS Material guidelines. The React-Native-on-Wear-OS discussion draws on community threads rather than official Meta guidance; at time of writing, React Native does not officially target the Wear OS platform.