Watch Surface
View Sourceraxol_watch pushes glanceable summaries from a Raxol app to iOS or Android devices. Accessibility announcements become notifications; taps come back as Raxol events. It's a low-bandwidth surface for status updates rather than full UI.
Quick Start
children = [
{Raxol.Watch.Supervisor, push_backend: Raxol.Watch.Push.APNS}
]
# APNS/FCM credentials are read by the backend module from its own config.Register a device:
# register(device_token, :apns | :fcm, opts)
Raxol.Watch.DeviceRegistry.register("device-token-here", :apns)
Raxol.Watch.DeviceRegistry.register("wear-os-token", :fcm, high_priority_only: true)Supported opts: muted: false, high_priority_only: false.
When the app announces something via Accessibility, registered devices get a push.
Push Backends
| Backend | Notes |
|---|---|
APNS | Apple Push Notification service |
FCM | Firebase Cloud Messaging (Android) |
Noop | Drops sends; logs a warning in prod |
pigeon is the optional dep that powers APNS/FCM. Without it the surface compiles but defaults to Noop.
Debouncing
Notifier subscribes to Accessibility events with a 1s debounce. Multiple rapid announcements coalesce into one push, useful when a form change emits five field-validation announcements in 200ms.
High-priority announcements (errors, alerts) bypass the debounce and push immediately.
Parallel send across devices via Task.async_stream. Failures log per-device but don't block the others.
Tap Actions
When a user taps a notification, ActionHandler.handle_action/2 translates the action ID into a Raxol.Core.Events.Event. Default mapping:
| Action ID | Event |
|---|---|
details | :key with key: :enter |
acknowledge | :key with key: :enter |
pause | :key with char: " " (space) |
quit | :key with char: "q" |
next | :key with key: :tab |
previous | :key with key: :tab, modifiers: [:shift] |
mute | :custom with %{action: :mute} (W4) |
pin | :custom with %{action: :pin} (W4) |
delete | :custom with %{action: :delete} (W4) |
dismiss | nil (no event emitted) |
Pass action_map: to merge in custom bindings:
Raxol.Watch.ActionHandler.handle_action("snooze",
action_map: %{"snooze" => {:key, %{key: :char, char: "s"}}}
)Custom event types beyond :key work too: any {type, data} where type is an atom produces Event.new(type, data).
Formatter attaches details + dismiss to normal notifications,
acknowledge + details + dismiss to high-priority ones, and the full
chat tap-back set (reply + mute + pin + delete + dismiss) to
chat-style notifications.
Quick-Reply (Text Input)
iOS UNTextInputNotificationAction and Android RemoteInput prompt the user for text before the action arrives back at the app. handle_reply_action/3 translates the action ID + typed text into a :reply event:
event = Raxol.Watch.ActionHandler.handle_reply_action("reply", "Sounds good!")
# %Event{type: :reply, data: %{action: "reply", text: "Sounds good!"}}
# Or dispatch through the configured channel:
Raxol.Watch.ActionHandler.dispatch_reply("reply", "Sounds good!", to: MyApp.TEA)
# Sends {:watch_action, %Event{type: :reply, ...}} to MyApp.TEAReplies use the same {:watch_action, event} channel as other tap-backs, so consumers pattern-match on event.type == :reply rather than a separate message tag.
Notification Categories
Raxol.Watch.Categories returns pure data for the host iOS / Android apps to register at launch:
# Host iOS app passes this to UNUserNotificationCenter.setNotificationCategories
ios_payload = Raxol.Watch.Categories.ios_categories()
# Host Android app reads per-category action arrays
chat_actions = Raxol.Watch.Categories.android_actions("raxol_chat")Three category buckets matching the :category field on notifications:
| Category | Actions |
|---|---|
raxol_alert | Details, Dismiss |
raxol_status | Details, Dismiss |
raxol_chat | Reply (text input), Mute, Pin, Delete, Dismiss |
Pure data, no platform calls. The host app translates to UNNotificationCategory (iOS) or NotificationCompat.Action + RemoteInput (Android).
Formatting
Formatter truncates the watch-glance body to 160 chars (using String.length, so emoji count correctly) and maps Raxol priority levels to APNS/FCM priority fields. Buffer content is stripped to plain text; styling doesn't survive the trip.
The full untruncated text is preserved under :body_long on every constructor, for the watch detail view that fetches when the user taps "expand".
Rich Notification Constructors (W1)
| Constructor | Carries |
|---|---|
format_announcement/2 | Text-only, priority-based actions |
format_model_summary/2 | Multi-line projection text, status actions |
format_audio/4 | :audio_url, chat actions |
format_image/4 | :image_url + :media_type (:photo default), chat actions |
format_sticker/4 | Convenience over format_image/4 with :media_type => :sticker |
format_location/4 | :location => %{lat, lng, label?}, chat actions |
format_long_message/3 | Body truncated to glance, :body_long carries full |
format_chat_message/3 | Same as format_long_message/3 but documented for chat use |
Notification fields exposed: body_long, audio_url, image_url, media_type (:sticker | :photo | :video_thumb), location. Existing constructors stay backward-compatible (new fields nil-defaulted).
APNS Payload Encoding (W2)
Raxol.Watch.Push.APNS.build_payload/1 emits the JSON payload with:
mutable-content: 1whenaudio_urlorimage_urlis present, so the host iOS app'sUNNotificationServiceExtensiontriggers an attachment fetch.interruption-level: "time-sensitive"andaps.sound: "default"for high-priority notifications (iOS 15+, surfaces past Focus modes).- Custom data at the top level:
raxol.audio_url,raxol.image_url,raxol.media_type(atom serialized as string),raxol.location(the{lat, lng, label?}map),raxol.body_long(only when distinct frombody).
build_payload/1 is @doc-public so consumers can introspect or test the payload shape without Pigeon mocking.
FCM Payload Encoding (W3)
Raxol.Watch.Push.FCM.build_notification_object/1 and build_data_payload/1 emit the FCM body:
notification.imagecarriesimage_url(Wear OS auto-downloads).data.categoryand JSON-encodeddata.actionsalways present.data.raxol_audio_url(string)data.raxol_media_type(string)data.raxol_location(JSON-encoded map; FCM data values must be strings)data.raxol_body_long(only when distinct frombody)
The host iOS / Wear OS app downloads the media and renders the notification: UNNotificationServiceExtension on iOS, NotificationCompat + BigPictureStyle / MessagingStyle on Android.
Device Registry
DeviceRegistry is ETS-backed with read_concurrency: true. Crash-safe init means the registry recovers cleanly if the GenServer restarts.
Devices don't expire automatically. Hook unregister/1 into your auth layer when sessions end.