Current section
Files
Jump to
Current section
Files
lib/mob_push.ex
defmodule MobPush do
@moduledoc """
Server-side push notifications for Mob apps.
A thin server library that wraps:
- **APNs HTTP/2** (iOS) — token-based auth with a `.p8` key
- **FCM HTTP v1** (Android) — OAuth2 via Google service account
Token storage and fan-out are intentionally out of scope — bring your own persistence.
## Setup
Add to your `mix.exs`:
{:mob_push, "~> 0.1"}
Then run `mix mob_push.install` for interactive credential setup, or configure
manually in `config/runtime.exs`:
# iOS
config :mob_push, :apns,
key_id: "XXXXXXXXXX", # 10-char Key ID from Apple Developer portal
team_id: "XXXXXXXXXX", # 10-char Team ID from Membership Details
bundle_id: "com.example.myapp",
key_file: "/run/secrets/AuthKey_XXXXXXXXXX.p8",
env: :production # :sandbox | :production
# Android
config :mob_push, :fcm,
project_id: "my-firebase-project",
service_account_key: "/run/secrets/fcm_service_account.json"
See the README for the full credential walkthrough.
## Receiving device tokens in the app
In your `Mob.Screen`, request permission and register for push:
def on_mount(socket) do
socket = Mob.Permissions.request(socket, :notifications)
{:ok, socket}
end
def handle_info({:permission, :notifications, :granted}, socket) do
{:noreply, Mob.Notify.register_push(socket)}
end
def handle_info({:push_token, platform, token}, socket) do
MyApp.PushTokens.upsert(socket.assigns.user_id, token, platform)
{:noreply, socket}
end
## Sending notifications
MobPush.send(token, :ios, %{
title: "New message",
body: "Alice: Hey, are you free tonight?",
subtitle: "in #general",
badge: 3,
sound: "default",
data: %{screen: "chat", thread_id: "42"}
})
MobPush.send(token, :android, %{
title: "New message",
body: "Alice: Hey, are you free tonight?",
data: %{screen: "chat", thread_id: "42"},
android: %{
"notification" => %{
"icon" => "ic_notification",
"color" => "#FF6200EE",
"channel_id" => "messages"
}
}
})
## Handling received notifications in the app
All three delivery scenarios (foreground, background tap, killed-then-tapped)
deliver the same `{:notification, notif}` message to your screen:
def handle_info({:notification, notif}, socket) do
# notif has string keys: "title", "body", "data"
case get_in(notif, ["data", "screen"]) do
"chat" -> {:noreply, Mob.Socket.push_screen(socket, MyApp.ChatScreen)}
_ -> {:noreply, socket}
end
end
## Payload options
| Key | Platforms | Type | Description |
|----------------------|-----------|---------|------------------------------------------------------|
| `:title` | both | string | Notification title (required) |
| `:body` | both | string | Notification body text (required) |
| `:subtitle` | iOS | string | Second line under the title |
| `:data` | both | map | Arbitrary key-value pairs delivered to the app |
| `:badge` | iOS | integer | Badge count on the app icon (0 to clear) |
| `:sound` | iOS | string | `"default"` or a filename bundled in the app |
| `:content_available` | iOS | boolean | Silent push — wakes app in background, no alert |
| `:android` | Android | map | Raw FCM `AndroidConfig` for appearance customization |
## Return values
- `:ok` — accepted by APNs / FCM
- `{:error, :device_token_expired}` — stale APNs token; delete it
- `{:error, :device_token_not_found}` — FCM doesn't know this token; delete it
- `{:error, :auth_failed}` — credentials rejected; check your config
- `{:error, {:apns_error, reason}}` — APNs rejected with a reason string
- `{:error, {:fcm_error, status, message}}` — FCM HTTP error
- `{:error, :missing_apns_key_config}` — `:key_file` / `:key_pem` not configured
- `{:error, {:apns_key_file_unreadable, path, reason}}` — `.p8` file not readable
- `{:error, :missing_fcm_service_account_config}` — service account not configured
"""
@doc """
Send a push notification to a device.
`platform` is `:ios` or `:android`.
`payload` must include `:title` and `:body`. Optional keys: `:data`,
`:badge`, `:sound`, `:content_available`, `:android`.
"""
@spec send(device_token :: String.t(), platform :: :ios | :android, payload :: map()) ::
:ok | {:error, term()}
def send(device_token, :ios, payload), do: MobPush.APNS.send(device_token, payload)
def send(device_token, :android, payload), do: MobPush.FCM.send(device_token, payload)
def send(_token, platform, _payload), do: {:error, {:unknown_platform, platform}}
@doc """
Like `send/3` but raises on error.
"""
@spec send!(String.t(), :ios | :android, map()) :: :ok
def send!(device_token, platform, payload) do
case send(device_token, platform, payload) do
:ok -> :ok
{:error, reason} -> raise "MobPush.send!/3 failed: #{inspect(reason)}"
end
end
end