Packages
raxol_watch
0.1.0
Watch notification bridge for Raxol. Pushes glanceable summaries and accessibility announcements to Apple Watch (APNS) and Wear OS (FCM). Tap actions route back as events to the TEA app.
Current section
Files
Jump to
Current section
Files
lib/raxol/watch/notifier.ex
defmodule Raxol.Watch.Notifier do
@moduledoc """
Subscribes to accessibility announcements and pushes notifications
to all registered watch devices.
Debounces pushes to at most once per second to respect watch battery
budgets. Respects per-device mute and priority-only preferences.
"""
use GenServer
require Logger
alias Raxol.Watch.{DeviceRegistry, Formatter}
@compile {:no_warn_undefined, [Raxol.Core.Accessibility]}
@debounce_ms 1000
defstruct [
:push_backend,
:subscription_ref,
:debounce_timer,
pending: nil
]
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc "Push a notification to all registered devices immediately."
@spec push_to_all(map()) :: :ok
def push_to_all(notification) do
GenServer.cast(__MODULE__, {:push_all, notification})
end
@doc """
Synchronously flushes pending work. Useful in tests to avoid `Process.sleep`.
"""
@spec flush() :: :ok
def flush do
GenServer.call(__MODULE__, :flush)
end
# -- GenServer --
@impl true
def init(opts) do
push_backend = Keyword.get(opts, :push_backend, Raxol.Watch.Push.Noop)
ref = make_ref()
if Code.ensure_loaded?(Raxol.Core.Accessibility) do
try do
Raxol.Core.Accessibility.subscribe_to_announcements(ref)
catch
:exit, _ -> :ok
end
end
{:ok, %__MODULE__{push_backend: push_backend, subscription_ref: ref}}
end
@impl true
def handle_call(:flush, _from, state) do
{:reply, :ok, state}
end
@impl true
def handle_cast({:push_all, notification}, state) do
do_push_all(notification, state)
{:noreply, state}
end
@impl true
def handle_info({:announcement_added, _ref, message}, state) when is_binary(message) do
notification = Formatter.format_announcement(message)
{:noreply, debounce_push(notification, state)}
end
def handle_info({:announcement_added, _ref, %{message: message, priority: priority}}, state) do
notification = Formatter.format_announcement(message, priority)
if priority == :high do
# High priority: push immediately, skip debounce
do_push_all(notification, state)
{:noreply, %{state | pending: nil}}
else
{:noreply, debounce_push(notification, state)}
end
end
def handle_info({:announcement_added, _ref, %{message: message}}, state) do
notification = Formatter.format_announcement(message)
{:noreply, debounce_push(notification, state)}
end
def handle_info(:flush_pending, %{pending: nil} = state) do
{:noreply, state}
end
def handle_info(:flush_pending, %{pending: notification} = state) do
do_push_all(notification, state)
{:noreply, %{state | pending: nil, debounce_timer: nil}}
end
def handle_info(_, state), do: {:noreply, state}
# -- Private --
defp debounce_push(notification, state) do
if state.debounce_timer, do: Process.cancel_timer(state.debounce_timer)
timer = Process.send_after(self(), :flush_pending, @debounce_ms)
%{state | pending: notification, debounce_timer: timer}
end
defp do_push_all(notification, state) do
devices =
DeviceRegistry.list_devices()
|> Enum.reject(fn {_, _, prefs} -> prefs[:muted] end)
|> Enum.reject(fn {_, _, prefs} ->
prefs[:high_priority_only] and notification.priority != :high
end)
backend = state.push_backend
devices
|> Task.async_stream(
fn {token, platform, _prefs} ->
case backend.push(token, notification) do
:ok ->
:ok
{:error, reason} ->
Logger.warning("Push failed for #{platform} device #{String.slice(token, 0..7)}...: #{inspect(reason)}")
{:error, reason}
end
end,
max_concurrency: 10,
timeout: 10_000,
on_timeout: :kill_task
)
|> Enum.each(fn
{:exit, :timeout} ->
Logger.warning("Push task timed out")
_ ->
:ok
end)
end
end