# mob_push
Server-side push notifications for mobile apps built with [Mob](https://hexdocs.pm/mob) (or any app that uses APNs and FCM).
[](https://hex.pm/packages/mob_push)
[](https://hexdocs.pm/mob_push)
A focused Elixir 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.
## Installation
```elixir
def deps do
[
{:mob_push, "~> 0.2"}
]
end
```
### Guided setup (recommended)
Two interactive wizards handle the full credential flow without leaving your terminal:
**iOS (APNs):**
```bash
mix mob_push.setup.apns
```
The wizard:
1. Auto-detects your bundle ID and team ID from the Xcode project
2. Opens the Apple Developer portal → Keys page in your browser
3. Tells you exactly what to click (name, APNs checkbox, Download)
4. Watches `~/Downloads` for the `.p8` file and moves it to `~/.mob/keys/`
5. Prompts for sandbox or production
6. Appends the ready-to-use config block to `config/runtime.exs`
One click in the browser is unavoidable — Apple has no API for creating APNs Auth Keys.
Options: `--dry-run` to narrate steps without writing any files.
**Android (FCM):**
```bash
mix mob_push.setup.fcm
```
The wizard:
1. Opens your browser to Google sign-in (OAuth 2.0)
2. Lets you pick an existing Firebase project or create a new one
3. Registers your Android app and downloads `google-services.json`
4. Enables the FCM HTTP v1 API
5. Creates a `mob-fcm` service account with minimal IAM permissions
6. Generates a service-account JSON key and saves it to `~/.mob/keys/`
7. Appends the config block to `config/runtime.exs`
Options: `--dry-run` to narrate all steps without making any API calls or writing files.
### Fallback: manual onboarding task
If the wizards don't fit your environment, the original interactive install task
generates config stubs with `System.get_env` wrappers and step-by-step instructions:
```bash
mix mob_push.install
```
Options: `--ios-only`, `--android-only`, `--skip-all`.
Or configure the `config/runtime.exs` block completely by hand — see [Configuration](#configuration).
---
## Account setup
### iOS — Apple Developer account
Push notifications require a paid Apple Developer account ($99/year).
1. Enroll at [developer.apple.com/programs/enroll](https://developer.apple.com/programs/enroll/)
2. Individual accounts are approved instantly. Organisation accounts require a D-U-N-S number and can take several days.
Official docs: [Apple — Registering your app with APNs](https://developer.apple.com/documentation/usernotifications/registering-your-app-with-apns)
### Android — Firebase project
FCM is free. You need a Google account and a Firebase project:
1. Go to [console.firebase.google.com](https://console.firebase.google.com)
2. Click **Add project**, follow the wizard (~2 minutes)
3. Google Analytics is not required for push notifications
If you already have a Google Cloud project you can import it into Firebase instead of creating a new one.
Official docs: [Firebase — Add Firebase to your Android project](https://firebase.google.com/docs/android/setup)
---
## Getting your credentials
### iOS — APNs auth key
You need five things from the [Apple Developer portal](https://developer.apple.com/account):
**Step 1 — Enable push on your App ID**
- Go to *Certificates, Identifiers & Profiles → Identifiers*
- Select your app (or create one if you haven't yet)
- Under *Capabilities*, enable **Push Notifications** and save
Official docs: [Configuring push notifications](https://developer.apple.com/documentation/usernotifications/configuring-apns-with-certificates)
**Step 2 — Create an APNs Auth Key (.p8)**
- Go to *Certificates, Identifiers & Profiles → Keys*
- Click **+**, give it a name, tick **Apple Push Notifications service (APNs)**, click Continue → Register
- Click **Download** — Apple only lets you download it once. Store it safely (treat it like a private key).
Official docs: [Creating APNs authentication token signing key](https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns)
**Step 3 — Note your Key ID**
Shown next to the key name on the Keys list, and embedded in the downloaded filename (`AuthKey_XXXXXXXXXX.p8`). 10 characters, uppercase alphanumeric.
**Step 4 — Note your Team ID**
Shown in the top-right corner of the developer portal, and under *Membership Details*. 10 characters, uppercase alphanumeric.
**Step 5 — Note your Bundle ID**
Your app's bundle identifier — the one you used when creating the App ID, e.g. `com.example.myapp`. Found in *Identifiers*. Must match exactly what's in your Xcode project and what you pass to `Mob.Notify.register_push/1`.
### Android — FCM service account
**Step 1 — Open your Firebase project**
Go to [console.firebase.google.com](https://console.firebase.google.com) and select your project.
**Step 2 — Generate a service account key**
- Click the gear icon (⚙) → **Project Settings**
- Select the **Service accounts** tab
- Under *Firebase Admin SDK*, click **Generate new private key** → **Generate key**
- A JSON file is downloaded — store it safely (treat it like a password). It grants full FCM send access.
Official docs: [Firebase Admin SDK — Initialize the SDK](https://firebase.google.com/docs/admin/setup#initialize_the_sdk_in_non-google_environments)
**Step 3 — Note your Project ID**
Shown at the top of *Project Settings* (also visible in the Firebase console URL as `https://console.firebase.google.com/project/YOUR-PROJECT-ID`). Looks like `my-app-a1b2c`.
**Step 4 — Enable the FCM API (if needed)**
New Firebase projects have the FCM HTTP v1 API enabled by default, but if you see authentication errors, verify it:
- Go to [console.cloud.google.com/apis/library/fcm.googleapis.com](https://console.cloud.google.com/apis/library/fcm.googleapis.com)
- Select your project from the dropdown and click **Enable** if it isn't already enabled
**Step 5 — Add `google-services.json` to your Android project**
The Android Firebase SDK requires a `google-services.json` config file. Without it, the Android build will fail.
- In Firebase console → gear icon → **Project Settings** → **Your apps**
- Select your Android app (or click **Add app → Android** to register it — you'll need your app's package name, e.g. `com.example.myapp`)
- Click **Download google-services.json**
- Place the file at `android/app/google-services.json` in your Mob project
This file contains project identifiers (not credentials) and is generally safe to commit. Keep it out of public repos if your Firebase project has billing enabled.
---
## Configuration
Add to `config/runtime.exs` (recommended — keeps secrets out of source control):
```elixir
import Config
# iOS push notifications (APNs)
config :mob_push, :apns,
key_id: System.get_env("APNS_KEY_ID", "YOUR_KEY_ID"),
team_id: System.get_env("APNS_TEAM_ID", "YOUR_TEAM_ID"),
bundle_id: System.get_env("APNS_BUNDLE_ID", "com.example.yourapp"),
key_file: System.get_env("APNS_KEY_FILE", "/path/to/AuthKey_XXXXXXXXXX.p8"),
env: if(config_env() == :prod, do: :production, else: :sandbox)
# Android push notifications (FCM HTTP v1)
config :mob_push, :fcm,
project_id: System.get_env("FCM_PROJECT_ID", "your-firebase-project-id"),
service_account_key: System.get_env("FCM_SERVICE_ACCOUNT_KEY", "/path/to/service-account.json")
```
### APNs config reference
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `:key_id` | string | yes | 10-char Key ID from the Apple Developer portal Keys page |
| `:team_id` | string | yes | 10-char Team ID from Membership Details |
| `:bundle_id` | string | yes | Your app's bundle identifier, e.g. `com.example.myapp` |
| `:key_file` | string | one of | Path to the `.p8` auth key file on disk |
| `:key_pem` | string | one of | PEM string contents of the `.p8` key (alternative to `:key_file`) |
| `:env` | atom | no | `:sandbox` (default) or `:production`. Use `:sandbox` during development — APNs sandbox and production use different endpoints and different device tokens. |
**Sandbox vs production:** The sandbox APNs endpoint (`api.sandbox.push.apple.com`) only accepts tokens from apps installed via Xcode or TestFlight development builds. The production endpoint (`api.push.apple.com`) only accepts tokens from App Store or TestFlight production builds. Using the wrong environment returns a 403 or silently drops the notification.
### FCM config reference
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `:project_id` | string | yes | Firebase project ID (e.g. `my-app-a1b2c`) |
| `:service_account_key` | string | one of | Path to the service account JSON file on disk |
| `:service_account_json` | map | one of | Already-decoded service account map (alternative to file path, useful when credentials come from a secret manager) |
---
## Usage
### Step 1 — Request permission and register in the app
In your Mob screen, call `Mob.Permissions.request/2` and `Mob.Notify.register_push/1`:
```elixir
defmodule MyApp.HomeScreen do
use Mob.Screen
@impl Mob.Screen
def on_mount(socket) do
socket = Mob.Permissions.request(socket, :notifications)
{:ok, socket}
end
@impl Mob.Screen
def handle_info({:permission, :notifications, :granted}, socket) do
{:noreply, Mob.Notify.register_push(socket)}
end
def handle_info({:permission, :notifications, :denied}, socket) do
{:noreply, socket}
end
def handle_info({:push_token, platform, token}, socket) do
MyApp.PushTokens.upsert(socket.assigns.user_id, token, platform)
{:noreply, socket}
end
end
```
The `{:push_token, platform, token}` message arrives once the OS issues a registration token. `platform` is `:ios` or `:android`. Store the token alongside the platform — you need both when sending.
### Step 2 — Send a notification from your server
Call `MobPush.send/3` from anywhere on your server — a Phoenix controller, LiveView event, background job (Oban), etc.:
```elixir
# Basic alert
MobPush.send(device_token, :ios, %{
title: "New message",
body: "Alice: Hey, are you free tonight?"
})
# With data payload
MobPush.send(device_token, :android, %{
title: "New message",
body: "Alice: Hey, are you free tonight?",
data: %{screen: "chat", thread_id: "42"}
})
# iOS — badge count + sound
MobPush.send(device_token, :ios, %{
title: "3 new messages",
body: "Alice, Bob and 1 other",
subtitle: "in #general",
badge: 3,
sound: "default"
})
# iOS — silent background push (wakes app, no alert shown)
MobPush.send(device_token, :ios, %{
title: "",
body: "",
content_available: true,
data: %{action: "sync"}
})
# Raise on failure instead of returning {:error, reason}
MobPush.send!(device_token, :android, %{title: "Hi", body: "World"})
```
### 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 in the notification tray |
| `:data` | both | map | Arbitrary key-value pairs delivered to the app. Values are coerced to strings. |
| `:badge` | iOS | integer | Badge count shown on the app icon |
| `:sound` | iOS | string | `"default"` for the system sound, or a filename bundled in the app (without extension) |
| `:content_available` | iOS | boolean | Silent push — wakes the app in the background without showing an alert |
| `:android` | Android | map | Raw FCM `AndroidConfig` map — see [Notification appearance (Android)](#notification-appearance-android) |
### Return values
| Value | Meaning |
|-------|---------|
| `:ok` | Accepted by APNs / FCM (delivery is best-effort from here) |
| `{:error, :device_token_expired}` | APNs rejected — token is stale, deregister it |
| `{:error, :device_token_not_found}` | FCM rejected — token unknown, deregister it |
| `{:error, :auth_failed}` | Credentials rejected — check your config |
| `{:error, {:apns_error, reason}}` | APNs rejected with a reason string (e.g. `"BadDeviceToken"`, `"Unregistered"`) |
| `{:error, {:fcm_error, status, message}}` | FCM HTTP error |
| `{:error, :missing_apns_key_config}` | `:key_file` or `:key_pem` not set in config |
| `{:error, {:apns_key_file_unreadable, path, reason}}` | `.p8` file could not be read |
| `{:error, :missing_fcm_service_account_config}` | Neither `:service_account_key` nor `:service_account_json` is set |
---
## Notification delivery lifecycle
Understanding when and how your app receives the notification payload matters for building a good UX.
### Three delivery scenarios
**1. Foreground** — app is running and the screen is visible
The OS does not show a system notification. Mob intercepts the payload and sends `{:notification, notif}` directly to your screen process.
**2. Background** — app is running but hidden (user pressed Home)
The OS shows a system notification in the tray. When the user taps it, the app comes to the foreground and your screen receives `{:notification, notif}`.
**3. Killed** — app is not running
The OS shows a system notification. When tapped, the app launches fresh and `{:notification, notif}` is delivered to your screen once the BEAM has booted.
### Handling notifications in your screen
```elixir
def handle_info({:notification, notif}, socket) do
# notif is a map with string keys:
# %{"title" => "...", "body" => "...", "data" => %{"screen" => "chat"}}
case get_in(notif, ["data", "screen"]) do
"chat" -> {:noreply, Mob.Socket.push_screen(socket, MyApp.ChatScreen)}
"inbox" -> {:noreply, Mob.Socket.push_screen(socket, MyApp.InboxScreen)}
_ -> {:noreply, socket}
end
end
```
### How delivery works under the hood (Android)
FCM carries two parallel payloads: a `notification` object (displayed by the OS when the app is killed/backgrounded) and a `data` object containing `mob_notification_json` — a JSON-encoded copy of the title, body, and data. This duplication is intentional:
- **Killed/backgrounded**: The OS displays the `notification` object. When tapped, Android puts `mob_notification_json` in the launch intent's extras. `MainActivity` reads it and delivers it to BEAM once it's running.
- **Foreground**: `MobFirebaseService.onMessageReceived` fires. It reads `mob_notification_json` from the data payload and delivers it to BEAM directly, bypassing the system tray.
This means your Elixir screen always gets a `{:notification, notif}` regardless of the app state — you don't need to write separate code paths for foreground vs. background.
---
## Notification appearance
### Android
Android notification appearance is controlled via the `:android` key in the payload, which maps directly to the FCM [`AndroidConfig`](https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#androidconfig) and [`AndroidNotification`](https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages#androidnotification) objects.
```elixir
MobPush.send(token, :android, %{
title: "New message",
body: "Alice: Hey!",
data: %{screen: "chat"},
android: %{
"notification" => %{
"icon" => "ic_notification", # drawable resource name (no extension)
"color" => "#FF6200EE", # accent color in #RRGGBB or #AARRGGBB
"sound" => "default", # "default" or filename in res/raw/ (no extension)
"channel_id" => "messages", # notification channel (Android 8+)
"image" => "https://cdn.example.com/avatar.jpg", # BigPictureStyle
"tag" => "msg-thread-42" # replaces previous notification with same tag
},
"priority" => "high" # "high" = wakes the screen; "normal" = quiet delivery
}
})
```
#### Small icon
The small icon appears in the status bar and notification drawer. It **must be a white/transparent PNG** bundled as a drawable resource in your Android project — Android does not render colored icons in the status bar.
1. Create a white/transparent PNG at `android/app/src/main/res/drawable/ic_notification.png`
2. Reference it by name (without path or extension): `"icon" => "ic_notification"`
If no icon is specified, Android falls back to the app launcher icon, which is often rejected by newer Android versions with a grey box.
#### Accent color
Sets the circle background behind the small icon and the notification accent stripe. Hex string in `#RRGGBB` or `#AARRGGBB` format.
#### Notification channels (Android 8+)
Android 8 (API 26) introduced notification channels. Each channel has its own sound, vibration, and importance settings that the user can control in system settings. If you specify a `channel_id` that doesn't exist, the notification is silently dropped on Android 8+ devices.
Channels are created by your Android app at runtime — typically in `MainActivity.onCreate`. If you're using the Mob generator, add channel creation to your Kotlin setup code:
```kotlin
// In MainActivity.onCreate, before nativeStartBeam()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
"messages",
"Messages",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "New message notifications"
}
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
```
If you don't specify `channel_id`, FCM uses a default channel. The default channel uses the device's default sound and importance.
#### Large image (BigPictureStyle)
The `"image"` key displays a large image below the notification text. The URL must be publicly accessible over HTTPS. Android downloads it at display time.
#### Delivery priority
`"priority" => "high"` causes the notification to wake the screen and appear as a heads-up notification. `"normal"` (the default) delivers quietly. Note: this is the FCM *delivery* priority, separate from the notification display importance set on the channel.
### iOS
iOS notification appearance is controlled by the standard payload keys:
| Key | Type | Description |
|-----|------|-------------|
| `:title` | string | Bold first line |
| `:subtitle` | string | Lighter second line, below the title |
| `:body` | string | Main notification text |
| `:badge` | integer | Badge count on the app icon. Pass `0` to clear. |
| `:sound` | string | `"default"` for the system sound, or a filename (without extension) bundled in the app's main bundle |
#### Custom sounds (iOS)
Bundle an `.aiff`, `.wav`, or `.caf` file in your Xcode project (add it to the app target, not a folder reference). Pass the filename without extension as `:sound`. Sounds longer than 30 seconds play the default sound instead.
#### Images (iOS)
iOS requires a **Notification Service Extension** (NSE) to attach images. The NSE is a separate build target in Xcode that intercepts the notification before display, downloads the image from a URL you include in the `:data` map, and attaches it. This is an app-side build step and is not handled by `mob_push`. See [Apple's UNNotificationServiceExtension docs](https://developer.apple.com/documentation/usernotifications/unnotificationserviceextension) for setup.
---
## Token management
### Storing tokens
Persist tokens in your database or ETS. Each user may have multiple tokens (multiple devices, or the same device reinstalled). Always store the platform alongside the token:
```elixir
# Schema example
# push_tokens: user_id, platform (:ios | :android), token, inserted_at, updated_at
def handle_info({:push_token, platform, token}, socket) do
MyApp.PushTokens.upsert(%{
user_id: socket.assigns.user_id,
platform: platform,
token: token
})
{:noreply, socket}
end
```
### Token expiry and deregistration
Tokens become invalid when:
- The user uninstalls and reinstalls the app
- The user restores the device from backup
- APNs/FCM rotates the token (rare but possible)
When `MobPush.send/3` returns `:device_token_expired` or `:device_token_not_found`, delete the token immediately to avoid sending to it again:
```elixir
case MobPush.send(token, platform, payload) do
:ok ->
:ok
{:error, reason} when reason in [:device_token_expired, :device_token_not_found] ->
MyApp.PushTokens.delete(token)
{:error, reason} ->
Logger.warning("Push failed: #{inspect(reason)}")
end
```
### Fan-out to multiple devices
```elixir
def notify_user(user_id, payload) do
user_id
|> MyApp.PushTokens.list()
|> Enum.each(fn %{token: token, platform: platform} ->
case MobPush.send(token, platform, payload) do
:ok ->
:ok
{:error, reason} when reason in [:device_token_expired, :device_token_not_found] ->
MyApp.PushTokens.delete(token)
{:error, reason} ->
Logger.warning("Push to #{platform} failed for user #{user_id}: #{inspect(reason)}")
end
end)
end
```
For high-volume fan-out, run sends concurrently with `Task.async_stream/3` and set a reasonable concurrency limit.
---
## Token caching
APNs JWTs (valid 1 hour) and FCM OAuth2 tokens (valid 1 hour) are cached in ETS and refreshed automatically 5 minutes before expiry. The `MobPush.TokenCache` GenServer is started automatically by the `MobPush` application — no setup needed.
If a 401 or 403 is received from APNs or FCM, the cached token is evicted and a fresh one is fetched on the next call. This handles the rare case where a token is invalidated server-side before its expiry.
---
## Sandbox vs production (iOS)
APNs has two separate environments — sandbox and production — with different URLs and different device token namespaces:
- **Sandbox** (`api.sandbox.push.apple.com`): for apps installed via Xcode or TestFlight development builds
- **Production** (`api.push.apple.com`): for App Store builds and TestFlight production builds
A sandbox token sent to the production endpoint (or vice versa) returns `{:error, {:apns_error, "BadDeviceToken"}}`. The standard pattern is:
```elixir
env: if(config_env() == :prod, do: :production, else: :sandbox)
```
If you're testing TestFlight production builds, you need `:production` in your `:staging` / `:prod` environment.
---
## License
MIT