Building a Reliable Control Handoff with the Wear OS Data Layer

Building a Reliable Control Handoff with the Wear OS Data Layer

When One Feature Has Two Masters

Modern Android experiences rarely live on a single screen anymore. A phone app might offer deep configuration and context, while a Wear OS app provides immediacy, glanceability, and physical convenience. The challenge begins when both surfaces are capable of controlling the same feature.

If a workout can be paused from the watch and the phone, which device is in charge? If playback is started on the phone, should the watch immediately reflect that authority? And what happens when connectivity drops, messages are delayed, or the user needs control right now?

This article explores a practical control sharing pattern between a phone app and a Wear OS app where only one device is the active controller at any given time. Either side can request control. The other side can grant it. And when communication fails, the user is never blocked thanks to an explicit emergency takeover path.

Under the hood, this is powered by the Wear OS data layer, combining messages for intent driven requests and data items for authoritative shared state. On top of that sits a small but strict contract that defines who owns control, when it changed, whether it was an emergency action, and which user is currently active.

The result is a system that feels responsive, resilient, and predictable across devices. One that makes failures visible instead of silent, and prioritizes user intent over perfect connectivity.

In the sections that follow, we will break this down piece by piece. We will start with the shared contract and data layer manager that both apps rely on, then walk through the phone and watch UIs, and finally look at the listener services that keep everything flowing even when the UI is not in the foreground.

A Shared Contract and a Single Source of Truth

When two apps on two different devices need to cooperate, the most important decision is not UI, timing, or even connectivity. It is the contract.

Before a phone and a watch can safely negotiate control, they need a shared understanding of:

  • where data lives
  • how requests are made
  • how authority is published
  • how identity is represented
  • how connection health is observed

This example starts by defining that contract explicitly and keeping it platform agnostic. Both apps import the same shared module, and neither side invents its own rules.

Paths, keys, and models

All communication is anchored to a small set of well known paths and keys. These paths intentionally separate intent from state.

If the embedded code does not load, open it on GitHub Gist.

Messages use /request-control to express intent. Data items under /device-control and /user-profile represent durable, authoritative state. This separation is deliberate. Messages may be lost. Data eventually converges.

The domain models mirror this thinking.

If the embedded code does not load, open it on GitHub Gist.

ControlStatus is the heart of the system. It answers three questions unambiguously:

  • who currently holds control
  • whether that control was taken via an emergency path
  • when the decision was made

Nothing else needs to be inferred.

One manager on both devices

Both the phone app and the watch app use the same data layer manager. The only difference is which DeviceType they identify as.

class WearDataLayerManager(
    context: Context,
    private val localDevice: DeviceType,
    private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
) : DataClient.OnDataChangedListener, MessageClient.OnMessageReceivedListener {

This class deliberately owns all Wear OS APIs:

  • DataClient for shared state
  • MessageClient for point to point requests
  • NodeClient for connectivity awareness

It exposes everything else as state flows that the UI can safely observe.

If the embedded code does not load, open it on GitHub Gist.

This means the UI never talks to the data layer directly. It reacts to state.

Connection tracking as a first class concern

Rather than assuming connectivity, the manager checks and publishes it.

If the embedded code does not load, open it on GitHub Gist.

This information is later used to decide whether an emergency takeover should be shown. Connectivity is not hidden. It is surfaced.

Requesting control versus publishing control

A critical design choice here is that requesting control and taking control are two separate actions.

Requesting control sends a message.

suspend fun requestControl(): Boolean {
        val payload = localDevice.name.toByteArray()
        return sendMessageToNodes(WearPaths.REQUEST_CONTROL, payload)
    }

Taking control publishes data.

If the embedded code does not load, open it on GitHub Gist.

Publishing updates local state immediately, then syncs it to the data layer.

If the embedded code does not load, open it on GitHub Gist.

This is the emergency guarantee. Even if nothing else works, the local device becomes the source of truth immediately.

Profile sync as simple shared state

User identity is intentionally lightweight.

If the embedded code does not load, open it on GitHub Gist.

The watch does not log in. It reflects. This keeps responsibilities clean.

Messages drive intent, data drives reality

Incoming messages are handled immediately and converted into published state.

If the embedded code does not load, open it on GitHub Gist.

Incoming data updates simply refresh local flows.

If the embedded code does not load, open it on GitHub Gist.

At no point does the UI need to know whether something arrived via message or data. It just observes state.

Phone UI Flow: Requesting Control, Handling Failure, and Syncing Identity

On the phone, the UI acts as the most expressive control surface. It can afford richer feedback, longer explanations, and explicit fallback actions. The goal here is not just to request control, but to make success and failure equally visible to the user.

The phone screen is driven entirely by state coming from the shared WearDataLayerManager.

@Composable
fun PhoneControlScreen(manager: WearDataLayerManager, modifier: Modifier = Modifier) {
    val connection by manager.connectionStatus.collectAsState()
    val control by manager.controlStatus.collectAsState()
    val profile by manager.userProfile.collectAsState()

Nothing is queried imperatively. Connectivity, control ownership, and profile data all flow in reactively.

Local UI state and intent tracking

The screen maintains only UI specific state: whether a request is in progress, whether the emergency option should be revealed, and the editable username field.

val coroutineScope = rememberCoroutineScope()
    var username by rememberSaveable { mutableStateOf(profile?.username.orEmpty()) }
    var isRequesting by rememberSaveable { mutableStateOf(false) }
    var showEmergency by rememberSaveable { mutableStateOf(false) }
    val localDevice = DeviceType.PHONE

The DeviceType is explicit. This matters later when verifying whether control was actually granted.

Reflecting synced profile data

If the watch or another phone syncs profile data first, the UI reacts automatically.

LaunchedEffect(profile?.username) {
        profile?.username?.let { username = it }
    }

This ensures the phone remains the source of editing, but not necessarily the source of truth.

Requesting control with a bounded wait

The core interaction is the request control button.

If the embedded code does not load, open it on GitHub Gist.

This sequence is important:

  1. A message is sent to the watch. This expresses intent, not authority.
  2. The UI waits up to five seconds for the shared ControlStatus to reflect that the phone is now the holder.
  3. Success is defined by observed state, not by message delivery alone.

If the message is delivered but the data never changes, the request is treated as a failure.

Making failure explicit

If connectivity is down, or the request times out, the emergency takeover is revealed.

if (!connection.connected || showEmergency) {
    Button(onClick = { manager.takeControl(emergency = true) }) {
        Text("Emergency take over")
    }
}

This is a conscious UX choice. The user is not left guessing. The system admits that coordination failed and offers a clear override.

Triggering an emergency takeover immediately publishes control locally and marks it as such. When connectivity returns, the data layer syncs and the watch will converge on the same truth.

Syncing profile data to the watch

Profile sync is treated as a separate concern and an explicit action.

If the embedded code does not load, open it on GitHub Gist.

There is no background guessing. The user decides when the watch should update.

Why this flow works well on phones

This approach embraces the phone’s strengths:

  • clear progress indication
  • explicit timeout handling
  • visible connectivity status
  • deliberate emergency actions

Most importantly, the phone UI never assumes success. Control is not considered granted until the shared state confirms it.

Watch UI Flow: Mirrored Requests, Timeout Driven Emergency Reveal, and Profile Display

On the watch, the UI has the same responsibilities as the phone, but with different constraints. Space is limited, attention is brief, and the user may be in motion. That changes how we present state and how aggressively we surface recovery options.

The watch screen still follows the same rule as the phone: observe shared state, send intent messages, and only treat control as granted once the data layer confirms it.

If the embedded code does not load, open it on GitHub Gist.

Just like the phone:

  • connectionStatus is used to shape the UI
  • controlStatus shows who is holding control
  • userProfile is displayed if present
  • isRequesting and showEmergency are purely UI concerns

Showing the synced profile

The watch does not edit the profile. It reflects what the phone shared.

Text(
    text = profile?.let { "User: ${it.username}" } ?: "Waiting for profile sync",
    style = MaterialTheme.typography.body2,
    textAlign = TextAlign.Center
)

This is a small touch that improves the feeling of continuity between devices. If the phone is signed in as “James”, the watch can acknowledge that immediately without needing its own login flow.

Control summary and status visibility

The watch uses a compact summary component to show current ownership.

ControlSummary(control)

Even without seeing the implementation, the intent is clear: the watch needs a quick “who is in charge right now” signal, because that informs whether the user should request control or expect actions to be mirrored from the phone.

Emergency takeover is conditional and earned

The emergency action is shown in two cases:

  • the watch is offline (!connection.connected)
  • a request attempt failed (showEmergency)

If the embedded code does not load, open it on GitHub Gist.

This design matters. On a small screen, too many always visible actions create confusion. Here, “Emergency takeover” is a recovery path, not a primary path, and the UI treats it that way.

When tapped, it calls:

manager.takeControl(emergency = true)

Which means the watch publishes local authority immediately and lets the data layer reconcile when possible.

Requesting control with the same success criteria

The watch request flow mirrors the phone, including the same five second timeout and the same definition of success.

If the embedded code does not load, open it on GitHub Gist.

Again, message delivery is not enough. The watch waits for controlStatus.holder == DeviceType.WATCH. If it never arrives, the UI pivots into recovery mode by revealing the emergency option.

The watch also uses the request in progress state to replace the label with a progress indicator:

If the embedded code does not load, open it on GitHub Gist.

This is a small but important usability improvement. On a watch, you want immediate feedback that the tap did something.

Listener Services: Keeping Messages and Data Flowing in the Background

So far, the pattern looks very UI driven: the user taps a button, a request is sent, and state updates flow back into Compose. But real device to device coordination cannot depend on screens being open.

That is where listener services come in.

A WearableListenerService gives your app a background entry point for:

  • incoming messages (MessageClient)
  • data layer updates (DataClient)

This is what allows control handoffs, profile sync, and connectivity signals to continue working when the app is in the background or not running in the foreground UI process.

A bridge service that forwards events into the same manager

The service is intentionally thin. It does not reimplement logic. It just forwards events into the same WearDataLayerManager used by the UI.

If the embedded code does not load, open it on GitHub Gist.

A few details are doing a lot of work here:

  • The manager is constructed with applicationContext, which matches the fact that this service can outlive any Activity.
  • manager.refreshNodes() is called in onCreate() to populate connection state early. That way the UI can show “No watch connected” or a device count without waiting for the next interaction.
  • Both onMessageReceived and onDataChanged simply forward the payload. This is key to avoiding split brain behavior where the UI and the background service disagree on business rules.

This also reinforces the design choice from earlier sections: messages represent intent, but published state comes from data items. The service can receive a request message while the UI is closed, publish a new ControlStatus, and when the UI opens it will immediately render the latest state from the data layer.

Manifest wiring: only wake up for what you care about

The service does not automatically receive everything. You explicitly declare which message paths and data paths should wake it up.

This is the watch side example:

If the embedded code does not load, open it on GitHub Gist.

The intent filters mirror the shared contract:

  • Messages:- /request-control is the control request intent- /ping and /pong support lightweight connectivity checks
  • Data:- /device-control is the authoritative ControlStatus - /user-profile is the synced profile

This filter based approach keeps the service focused and reduces unnecessary wakeups. It also makes it obvious, at a glance, which parts of your contract are “background critical”.

Why this matters for the overall pattern

The listener service is what makes the whole setup feel reliable:

  • If the phone requests control while the watch UI is not open, the watch can still publish control.
  • If the phone syncs a profile, the watch can store it and show it later without requiring a fresh request.
  • If control changes, both sides converge on the same data layer truth as soon as they can observe it.

You are effectively treating the data layer as a shared state bus, and the listener service as the always on bridge into that bus.

Designing Control That Survives Real World Conditions

Sharing control between a phone and a watch is not just a technical problem. It is a trust problem. The user needs to trust that the device in their hand or on their wrist will respond, reflect reality, and recover gracefully when things go wrong.

This pattern works because it is explicit at every layer.

Control requests are expressed as intent via messages, but authority is always published as shared state. The UI never assumes success. It waits for confirmation from the data layer. When confirmation does not arrive, failure is acknowledged and a clear emergency path is offered instead of silent retry loops.

Connectivity is treated as first class state, not an implementation detail. Both the phone and the watch can surface when they are disconnected and adjust their UI accordingly. This makes degraded behavior understandable rather than mysterious.

Emergency takeovers are not hacks. They are designed into the flow. By publishing local control immediately and syncing later, the system prioritizes user intent over perfect coordination. The emergency flag ensures that this context is preserved and visible across devices.

Profile sync demonstrates the same philosophy at a smaller scale. The phone owns identity, the watch reflects it, and both sides converge through the same data layer mechanism used for control.

Key takeaways

  • Separate intent from authority. Use messages to ask and data to decide.
  • Treat shared state as the source of truth, not delivery success.
  • Make connectivity visible and actionable in the UI.
  • Design emergency paths as first class UX, not hidden fallbacks.
  • Keep business rules in one shared manager and reuse it everywhere, including background services.

Possible future extensions

This foundation leaves plenty of room to grow without changing the core contract:

  • Add acknowledgements or decline flows when a device wants to refuse a control request.
  • Persist control history using the timestamp to show who last held control and when.
  • Introduce a soft lock duration where control cannot immediately flip back and forth.
  • Expand UserProfile to include avatar colors or roles that affect UI presentation.
  • Add analytics around emergency takeovers to identify connectivity pain points in the wild.

By designing for failure as deliberately as success, you end up with a control sharing system that feels calm, predictable, and human, even when the radio is not.