defmodule MobNotify do
@moduledoc """
Local and push notifications — the device half, extracted from mob core's
`Mob.Notify` in Wave 2.
Requires `:notifications` permission (request via `Mob.Permissions.request/2`).
No `Info.plist` key needed on iOS. Android 13+ (API 33) requires
`POST_NOTIFICATIONS`, declared by this plugin's manifest.
All notifications arrive via `handle_info` regardless of app state (foreground,
background, or relaunched after being killed). Delivery plumbing (the
notification-center delegate, push-token forwarding, launch-notification
handoff) lives in mob CORE — this plugin owns scheduling, cancellation, and
push registration.
## Local notifications
MobNotify.schedule(socket,
id: "reminder_1",
title: "Time to check in",
body: "Open the app to see today's updates",
at: ~U[2027-04-16 09:00:00Z], # or delay_seconds: 60
data: %{screen: "reminders"}
)
MobNotify.cancel(socket, "reminder_1")
def handle_info({:notification, %{id: id, data: data, source: :local}}, socket), do: ...
## Push notifications (pairs with the `mob_push` package on your server)
# Call once after :notifications permission granted
MobNotify.register_push(socket)
def handle_info({:push_token, :ios, token}, socket), do: ...
def handle_info({:push_token, :android, token}, socket), do: ...
def handle_info({:notification, %{title: t, body: b, data: d, source: :push}}, socket), do: ...
`mob_push` is deliberately a SEPARATE package — it runs on your server (APNs
HTTP/2 + FCM v1) with zero device/NIF code. The wire contract between the two
is pinned by shared fixtures in `test/fixtures/push_contract.exs`, vendored
identically in both repos.
iOS: `UNUserNotificationCenter`. Android: `NotificationManager` + `AlarmManager` + FCM.
"""
@doc """
Schedule a local notification.
Options:
- `id:` (required) — string identifier, used to cancel the notification
- `title:` (required) — notification title
- `body:` (required) — notification body text
- `at: %DateTime{}` — absolute trigger time (UTC)
- `delay_seconds: integer` — trigger after N seconds (alternative to `at:`)
- `data: %{}` — arbitrary map passed back in the `handle_info` payload
"""
@spec schedule(Mob.Socket.t(), keyword()) :: Mob.Socket.t()
def schedule(socket, opts) do
:mob_notify_nif.notify_schedule(:json.encode(schedule_opts(opts)))
socket
end
@doc """
Build the option map passed to `notify_schedule/1`. Pure function exposed so
tests can pin defaults + serialisation without going through the NIF.
"""
@spec schedule_opts(keyword()) :: map()
def schedule_opts(opts) do
id = Keyword.fetch!(opts, :id)
title = Keyword.fetch!(opts, :title)
body = Keyword.fetch!(opts, :body)
data = Keyword.get(opts, :data, %{})
trigger_at =
case opts[:at] do
%DateTime{} = dt -> DateTime.to_unix(dt)
nil -> DateTime.to_unix(DateTime.utc_now()) + (opts[:delay_seconds] || 0)
end
%{
"id" => id,
"title" => title,
"body" => body,
"trigger_at" => trigger_at,
"data" => Map.new(data, fn {k, v} -> {to_string(k), v} end)
}
end
@doc """
Cancel a pending local notification by its id.
Has no effect if the notification has already been delivered.
"""
@spec cancel(Mob.Socket.t(), String.t()) :: Mob.Socket.t()
def cancel(socket, id) do
:mob_notify_nif.notify_cancel(id)
socket
end
@doc """
Register this device for push notifications.
The device token arrives as `{:push_token, platform, token_string}` where
`platform` is `:ios` or `:android`.
Send this token to your server and use the `mob_push` library to send
notifications to it.
"""
@spec register_push(Mob.Socket.t()) :: Mob.Socket.t()
def register_push(socket) do
:mob_notify_nif.notify_register_push()
socket
end
end