Skip to content
Shiny.NET
Commercial SupportGuaranteed response times on Shiny bug fixes, straight from the maintainers. Plans from US$1,500 a year.See the plans

Wearables

GitHub GitHub stars for shinyorg/shiny
Downloads NuGet downloads for Shiny.Wearables
Frameworks
.NET
.NET MAUI
Operating Systems
Android
iOS

Shiny.Wearables talks to the companion app on a paired Apple Watch (WatchConnectivity) or Wear OS device (the Data Layer) through one API. Everything is bytes addressed by a path, in four shapes:

Shape What it is for iOS Wear OS
Message A live request that waits on the companion’s reply. The watch must be reachable; nothing queues. sendMessage + reply handler MessageClient.sendRequest
Context The latest state (“the current plan”). Only the newest value is kept. updateApplicationContext data item at /shiny/context
Transfer Queued data, delivered in order even if the watch is out of range or its app is closed. transferUserInfo data item at /shiny/transfer/{id}
File A queued file with string metadata. transferFile data item with an asset at /shiny/file/{id}

The same package runs inside a .NET Wear OS app too — there, the “wearable” it talks to is the phone. watchOS apps are written in Swift; see the protocol below.

MauiProgram.cs
builder.Services.AddWearables<MyWearableDelegate>();
// or, without a delegate
builder.Services.AddWearables();

On iOS and Android this registers IWearableManager. Everywhere else nothing is registered, so resolve it as optional (services.GetService<IWearableManager>()) in shared code.

  • The WCSession is activated at startup, not on first use: WatchConnectivity delivers what arrived while the app was closed as soon as the session activates, and only to a delegate that is already set. Call AddWearables during app startup.
  • iPad is not supported — WCSession.IsSupported is false there and GetStatus() reports IsSupported = false.
  • Mac Catalyst, macOS and tvOS have no paired watch; the package has no target for them.
  • The phone app and the watch app must share the same application id and signing key, or the Data Layer will not connect them.

  • The companion is found by the shiny_wearable capability. Shiny advertises it for your app automatically at startup. A Kotlin companion declares it in res/values/wear.xml:

    <resources>
    <string-array name="android_wear_capabilities">
    <item>shiny_wearable</item>
    </string-array>
    </resources>
  • Inbound traffic arrives through ShinyWearableListenerService, which Google Play services binds for anything under /shiny — including while your app is not running. No manifest edits are needed.

  • A device without the Wear OS app or Play services reports IsSupported = false.

A UI asks two different questions about a watch, and each needs its own flag:

Question Flag Survives the watch being off or out of range?
Does this user have a watch set up? — show “send to watch” at all status.IsConfigured Yes
Is the watch here right now? — a “connected” indicator, live messages status.IsReachable No

IsConfigured is IsPaired && IsAppInstalled. Both halves are persistent: a watch that is switched off, charging in another room or out of Bluetooth range is still paired and still has the app, so a feature gated on IsConfigured does not appear and disappear as the user walks around the house. IsReachable is the opposite — it is true only while a live message can actually be sent.

IsSupported is neither. It says whether this platform has a wearable API at all, and is false only on a platform with none — not iOS or Android, an iPad, or an Android device without Play services. A phone with no watch reports IsSupported = true.

status.Nodes lists every watch the platform knows about, connected or not, so a watch that is away can still be named in the UI. Each WearableNode carries:

  • IsConnected — the platform can reach it, over Bluetooth or (on Wear OS) the cloud
  • IsNearby — it is directly connected over Bluetooth
  • HasApp — it runs the companion app

A Wear OS watch reached through the cloud is IsConnected but not IsNearby. Transfers and context still go through it, but it does not count toward IsReachable, because a live message would take seconds. On iOS the two flags are the same — WatchConnectivity has no cloud route.

One thing Wear OS cannot see: a paired watch that has never had the companion app installed and is currently away. A watch is found either because it is connected or because it advertises the companion app’s capability, and one that is neither is invisible to the Data Layer.

GetStatus() is asynchronous, and changes arrive through IWearableDelegate.OnStatusChanged — raised on pair and unpair, the app being installed or removed, and reachability changes. Cache the last status there rather than calling GetStatus() from a binding, and seed it once at startup:

public class WatchStatusDelegate(WatchStatusState state) : WearableDelegate
{
public override Task OnStatusChanged(WearableStatus status)
{
state.Update(status);
return Task.CompletedTask;
}
}

The delegate runs off the UI thread; marshal before updating anything bound to the UI.

public class WatchViewModel(IWearableManager wearables)
{
public async Task Sync()
{
var status = await wearables.GetStatus();
if (!status.IsAppInstalled)
return;
// live — needs IsReachable; throws WearableException(NotReachable) otherwise
if (status.IsReachable)
{
// key/values go as a JSON object, and the reply comes back as key/values
var reply = await wearables.SendMessage("workout/start", new Dictionary<string, object?> { ["kind"] = "run", ["km"] = 5 });
var accepted = reply["accepted"].GetBoolean();
// plain text goes as UTF-8, and the reply comes back as text
string pong = await wearables.SendMessage("ping", "hello");
}
// latest state — delivered when the watch can take it
await wearables.UpdateContext(new Dictionary<string, object?> { ["plan"] = "5k" });
// queued, in order
var id = await wearables.Transfer("log", new Dictionary<string, object?> { ["laps"] = new[] { 1, 2, 3 } });
// queued file — leave it in place until OnTransferCompleted reports the id
var fileId = await wearables.TransferFile("maps/offline", "/path/to/city.bin", new Dictionary<string, string> { ["zoom"] = "14" });
var pending = await wearables.GetPendingTransfers();
await wearables.CancelTransfer(fileId);
var theirs = await wearables.GetReceivedContext();
}
}

Underneath, every payload is bytes, and the byte[] overloads are there for binary data. You rarely need them: SendMessage, UpdateContext and Transfer also take

  • key/values — any IReadOnlyDictionary<string, TValue> (Dictionary<string, object?>, Dictionary<string, string>, …), sent as a JSON object. This is how structured data goes; you never build a JSON string.
  • a string — plain text, sent as UTF-8.

The key/value SendMessage returns IReadOnlyDictionary<string, JsonElement> — empty when the companion replied with nothing, and a JsonException if the reply is not a JSON object; the text one returns the reply as a string. Received messages, context and transfers read the same way with GetString() and GetValues(), and WearableData.FromString / WearableData.FromValues build a delegate’s reply.

Values can be null, strings, booleans, numbers, DateTime, DateTimeOffset, TimeSpan, Guid, Uri, enums (written by name), byte[] (base64), JsonElement, nested dictionaries with string keys, and lists or arrays of these. Anything else throws NotSupportedException — break it into a nested dictionary or list. No reflection is used, so it is trim and AOT safe.

Paths are app-defined: sync, workout/start. Leading and trailing slashes are trimmed; whitespace, ? and # are rejected. Keep messages small — about 64 KB on iOS, 100 KB on Wear OS.

WearableErrorCode When
NotSupported No wearable API on this device
NotReachable SendMessage with no reachable wearable running the companion app
Failed The platform refused or failed the operation

Everything the watch sends goes to your IWearableDelegate, in the foreground or background. Derive from WearableDelegate and override what you need:

public class MyWearableDelegate : WearableDelegate
{
// what you return is the reply the watch gets
public override Task<byte[]?> OnMessageReceived(WearableMessage message) => message.Path switch
{
"ping" => Task.FromResult<byte[]?>(WearableData.FromString("pong")),
"workout/start" => Task.FromResult<byte[]?>(WearableData.FromValues(new Dictionary<string, object?>
{
["accepted"] = message.GetValues()["kind"].GetString() == "run"
})),
_ => Task.FromResult<byte[]?>(null)
};
public override Task OnContextReceived(WearableContext context)
{
var plan = context.GetValues()["plan"].GetString(); // or context.GetString()
return Task.CompletedTask;
}
public override Task OnTransferReceived(WearableTransfer transfer) => Task.CompletedTask;
// the file is already in the app's data directory — you own it now
public override Task OnFileReceived(WearableFile file)
{
File.Move(file.LocalPath, Path.Combine(FileSystem.AppDataDirectory, file.FileName), true);
return Task.CompletedTask;
}
public override Task OnTransferCompleted(WearableTransferResult result) => Task.CompletedTask;
public override Task OnStatusChanged(WearableStatus status) => Task.CompletedTask;
}

With several delegates registered, each is asked for a reply in turn and the first non-null reply wins; the watch gets an empty reply if none answers. A delegate that throws is logged and skipped. The other callbacks run on every delegate.

Received files are moved to {AppData}/Shiny.Wearables/{transferId}/{fileName} before OnFileReceived runs (on iOS the platform deletes its copy the moment the callback returns). File names from the sender are reduced to their last segment, so a sender cannot write outside that folder.

On Wear OS, OnTransferCompleted fires when the receiver deletes the data item — its delivery receipt — so the result carries the id but an empty Path.

The wire format is in WearableProtocol. A companion written natively speaks it like this.

Shape Watch sends / receives
Message ["path": String, "data": Data]; reply ["data": Data]
Context ["data": Data]
Transfer ["id": String, "path": String, "data": Data]
File transferFile(url, metadata: ["id", "path", "name", "metadata": [String: String]])
import WatchConnectivity
final class PhoneLink: NSObject, WCSessionDelegate {
func send(_ path: String, _ json: Data) async throws -> Data {
try await withCheckedThrowingContinuation { cont in
WCSession.default.sendMessage(["path": path, "data": json],
replyHandler: { reply in cont.resume(returning: reply["data"] as? Data ?? Data()) },
errorHandler: { cont.resume(throwing: $0) })
}
}
// messages from the phone always carry a reply handler — always call it
func session(_ session: WCSession, didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void) {
let path = message["path"] as? String ?? ""
replyHandler(["data": handle(path, message["data"] as? Data ?? Data())])
}
func session(_ session: WCSession, didReceiveApplicationContext ctx: [String: Any]) { /* ctx["data"] */ }
func session(_ session: WCSession, didReceiveUserInfo info: [String: Any] = [:]) { /* info["path"], info["data"] */ }
func session(_ session: WCSession, activationDidCompleteWith s: WCSessionActivationState, error: Error?) {}
}
Shape Data Layer
Message sendRequest(node, "/shiny/message/{path}", bytes); the reply is the RPC result
Context PutDataMapRequest.create("/shiny/context") with data byte array
Transfer urgent item /shiny/transfer/{id} with id, path, data; the receiver deletes it once handled
File urgent item /shiny/file/{id} with id, path, name, metadata (DataMap of strings) and the file asset
// ask the phone
val node = Wearable.getCapabilityClient(ctx)
.getCapability("shiny_wearable", CapabilityClient.FILTER_REACHABLE).await().nodes.first()
val reply: ByteArray = Wearable.getMessageClient(ctx)
.sendRequest(node.id, "/shiny/message/sync", json.toByteArray()).await()
// answer the phone
class PhoneListener : WearableListenerService() {
override fun onRequest(nodeId: String, path: String, request: ByteArray): Task<ByteArray>? =
Tasks.forResult(handle(path.removePrefix("/shiny/message/"), request))
override fun onDataChanged(events: DataEventBuffer) {
events.filter { it.type == DataEvent.TYPE_CHANGED }.forEach {
val item = it.dataItem
val map = DataMapItem.fromDataItem(item).dataMap
when {
item.uri.path == "/shiny/context" -> onContext(map.getByteArray("data"))
item.uri.path!!.startsWith("/shiny/transfer/") -> {
onTransfer(map.getString("path"), map.getByteArray("data"))
Wearable.getDataClient(this).deleteDataItems(item.uri) // the phone's delivery receipt
}
}
}
}
}
// queue a transfer to the phone
val id = UUID.randomUUID().toString().replace("-", "")
val req = PutDataMapRequest.create("/shiny/transfer/$id").apply {
dataMap.putString("id", id); dataMap.putString("path", "log"); dataMap.putByteArray("data", bytes)
}.setUrgent().asPutDataRequest()
Wearable.getDataClient(ctx).putDataItem(req)

Declare the listener service with intent filters for MESSAGE_RECEIVED, REQUEST_RECEIVED and DATA_CHANGED, data scheme wear, host * and path prefix /shiny.