Wearables
Getting Started
Section titled “Getting Started”| GitHub | |
| Downloads |
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.
builder.Services.AddWearables<MyWearableDelegate>();// or, without a delegatebuilder.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
WCSessionis 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. CallAddWearablesduring app startup. - iPad is not supported —
WCSession.IsSupportedis false there andGetStatus()reportsIsSupported = false. - Mac Catalyst, macOS and tvOS have no paired watch; the package has no target for them.
Android / Wear OS
Section titled “Android / Wear OS”-
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_wearablecapability. Shiny advertises it for your app automatically at startup. A Kotlin companion declares it inres/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.
Watch Status
Section titled “Watch Status”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 cloudIsNearby— it is directly connected over BluetoothHasApp— 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.
Using It
Section titled “Using It”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(); }}Payloads
Section titled “Payloads”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 |
The Delegate
Section titled “The Delegate”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.
Protocol for Swift and Kotlin Companions
Section titled “Protocol for Swift and Kotlin Companions”The wire format is in WearableProtocol. A companion written natively speaks it like this.
watchOS (Swift)
Section titled “watchOS (Swift)”| 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?) {}}Wear OS (Kotlin)
Section titled “Wear OS (Kotlin)”| 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 phoneval 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 phoneclass 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 phoneval 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.


