# Push Setup — Firebase Cloud Messaging on iOS and Android

> Everything between "the app has no push" and "a notification lands on a real
> handset": Firebase project registration, the platform config each OS needs, the
> APNs key iOS cannot work without, getting a token, and handling a message in all
> four app states.
>
> The **backend is done and live** — FCM credentials verified, project
> `adolescent-social`, `GET /api/notifications/health` reports `channels.push: true`.
> Nothing on this page needs a backend change. What the server still has is
> `devices.total: 0` — no token has ever been registered, so no push has ever been
> deliverable to anyone.
>
> The API call itself is documented in the
> [notifications guide §0](./mobile-notifications-guide.md#0-register-the-device-for-push);
> the payload you receive is §5.1 and the deep links you route on are §5.2. This
> page is the platform work around them.

## What's new

<!-- Newest first, plain language, max 5 entries. Every session that changes this
     guide adds one line here and drops the oldest past 5 — see CLAUDE.md rule 8. -->

- **2026-09-24** — New page. Push had a documented API call and no documented client setup, so nothing said how `getToken()` is supposed to return anything. Covers Firebase console registration, `google-services.json` / `GoogleService-Info.plist`, the **APNs auth key** iOS is blocked on, Android 13's `POST_NOTIFICATIONS`, the notification channel and icon Android needs to render properly, the four app states a message can arrive in, and a verification checklist with the symptom of each failure.

---

## 0. What you need before you start

| Thing | Who has it | Needed for |
| --- | --- | --- |
| Access to the Firebase project **`adolescent-social`** | backend owner | Registering the apps, downloading config files, uploading the APNs key |
| **iOS bundle id** and **Android package name** | **TBC — confirm before you register anything** | They must match what you ship; a mismatch mints tokens the server will immediately disable |
| Apple Developer account with admin rights | **TBC — name the owner** | Minting the APNs auth key (`.p8`). This is the long-pole item for iOS |
| A **physical iOS device** | you | Push on the iOS Simulator is unreliable for FCM; treat "works on device" as the bar |

> **One project, one app per platform.** A token minted against a different
> Firebase project is rejected by our sends, and the server then marks the device
> `disabledAt` — so the symptom of "wrong project" looks like the device silently
> unregistering itself minutes after you registered it.

---

## 1. Install the SDK

```bash
npm i @react-native-firebase/app @react-native-firebase/messaging
cd ios && pod install
```

`@react-native-firebase/app` is required even though you only use `messaging` —
it is what reads the config files below. Pin both to the same version.

---

## 2. Android

### 2.1 Register the app and drop in the config

1. Firebase console → project `adolescent-social` → Add app → Android.
2. Package name must be exactly the one you ship (see §0).
3. Download `google-services.json` → `android/app/google-services.json`.

### 2.2 Gradle

`android/build.gradle`:

```gradle
buildscript {
  dependencies {
    classpath("com.google.gms:google-services:4.4.2")
  }
}
```

`android/app/build.gradle`:

```gradle
apply plugin: "com.google.gms.google-services"
```

### 2.3 The runtime permission (Android 13+)

On API 33+ a user who has not granted `POST_NOTIFICATIONS` sees **no**
notification, with no error anywhere — the pushes simply go nowhere. Whether
`messaging().requestPermission()` covers this for you depends on your
`@react-native-firebase` version, so request it explicitly: the call is
idempotent, and doing it yourself is correct on every version.

`android/app/src/main/AndroidManifest.xml`:

```xml
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
```

and at runtime, before registering the token:

```ts
import { PermissionsAndroid, Platform } from "react-native";

if (Platform.OS === "android" && Platform.Version >= 33) {
  const granted = await PermissionsAndroid.request(
    PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS,
  );
  if (granted !== PermissionsAndroid.RESULTS.GRANTED) return; // nothing to register
}
```

### 2.4 Channel and icon — do not skip these

Our server does **not** set a notification channel id on the message, so Android
falls back to whatever you declare as the default. Without it your notifications
land in an unnamed low-importance channel and never appear as heads-up banners.
Same story for the icon: without a declared one, Android uses the app icon and
renders it as a white square.

`AndroidManifest.xml`, inside `<application>`:

```xml
<meta-data
  android:name="com.google.firebase.messaging.default_notification_channel_id"
  android:value="clustr_default" />
<meta-data
  android:name="com.google.firebase.messaging.default_notification_icon"
  android:resource="@drawable/ic_notification" />
<meta-data
  android:name="com.google.firebase.messaging.default_notification_color"
  android:resource="@color/notification_color" />
```

Create the channel once at startup (importance HIGH, so it can interrupt):

```ts
// e.g. with notifee
await notifee.createChannel({
  id: "clustr_default",
  name: "Notifications",
  importance: AndroidImportance.HIGH,
});
```

`ic_notification` must be a white-on-transparent silhouette — Android tints it.

---

## 3. iOS

### 3.1 Register the app and drop in the config

1. Firebase console → Add app → iOS, with the bundle id you ship.
2. Download `GoogleService-Info.plist` and add it **to the Xcode target** (drag
   into Xcode, tick the target — copying the file into the folder is not enough).

### 3.2 The APNs auth key — the thing iOS is actually blocked on

Firebase cannot talk to Apple on your behalf without it. **Every iOS push fails
until this is uploaded, no matter how correct everything else is, and our server
logs will say the send succeeded.**

1. Apple Developer → Certificates, Identifiers & Profiles → **Keys** → `+`.
2. Enable **Apple Push Notifications service (APNs)**, create, download the
   `.p8` — **it can be downloaded exactly once**. Note the **Key ID** and your
   **Team ID**.
3. Firebase console → Project settings → **Cloud Messaging** → iOS app → **APNs
   Authentication Key** → upload the `.p8` with its Key ID and Team ID.

One key covers development and production, and all apps on the team. Prefer it
over APNs certificates, which expire annually.

### 3.3 Capabilities

In Xcode, on the app target → Signing & Capabilities:

- **Push Notifications**
- **Background Modes** → tick **Remote notifications**

Both are required; the second is what lets a notification wake the app enough to
be delivered while backgrounded.

### 3.4 Registration order

`@react-native-firebase/messaging` handles the APNs token → FCM token exchange
for you. You only need to ask for permission before requesting the token:

```ts
const status = await messaging().requestPermission();
const ok =
  status === messaging.AuthorizationStatus.AUTHORIZED ||
  status === messaging.AuthorizationStatus.PROVISIONAL;
```

If `getToken()` rejects or hangs on iOS, it is almost always §3.2 or §3.3, not
your code.

---

## 4. Get the token to us

Covered in full in the
[notifications guide §0](./mobile-notifications-guide.md#0-register-the-device-for-push)
— the body, the limits, and the four moments to call it (after login, after the
permission grant, on every launch, and **on every `onTokenRefresh`**, which is the
one that gets missed). In short:

```ts
const token = await messaging().getToken();
await api.post("/api/devices", {
  token,
  platform: Platform.OS === "ios" ? "IOS" : "ANDROID",
  appVersion: DeviceInfo.getVersion(),
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
});

messaging().onTokenRefresh(registerDevice);   // reinstall, restore, app-data clear, FCM rotation
```

Idempotent on the token, so calling it unconditionally is safe. `DELETE /api/devices/:id`
on logout.

> A user's pushes fan out to at most **20 active devices**, most-recently-seen
> first. Dead tokens are disabled server-side on the next send, so you never have
> to clean up after an uninstall.

---

## 5. Receiving a message

### 5.1 What arrives

Our messages carry **both** a `notification` block and a `data` block — see
[§5.1 of the notifications guide](./mobile-notifications-guide.md#51-the-push-payload)
for every key. The practical consequence of carrying both:

- **Background / quit: the OS draws the notification itself.** You do nothing.
- **Foreground: the OS draws nothing, on either platform.** If you want a banner
  while the app is open, you render it (notifee or equivalent) from `onMessage`.

Everything you route on is in `data`, and **FCM delivers every `data` value as a
string** — `data.type` tells you what happened, `data.deepLink` where to go.

### 5.2 The four states, all of which you must handle

| App state | Handler | Notes |
| --- | --- | --- |
| **Foreground** | `messaging().onMessage(cb)` | Nothing is displayed for you. Render it or drop it. |
| **Background, data processing** | `messaging().setBackgroundMessageHandler(cb)` | **Android only in practice** — our payload does not set `content-available`, so iOS does not wake your JS for it. Don't put anything load-bearing here. |
| **Background → tapped** | `messaging().onNotificationOpenedApp(cb)` | Route on `data.deepLink`. |
| **Quit → tapped (cold start)** | `messaging().getInitialNotification()` | Call once on boot. Miss this and taps from a killed app open the home screen. |

```ts
// register at module scope, outside any component
messaging().setBackgroundMessageHandler(async () => {});

messaging().onNotificationOpenedApp((m) => route(m.data));
messaging().getInitialNotification().then((m) => m && route(m.data));
messaging().onMessage((m) => showLocalNotification(m));   // foreground only

function route(data) {
  if (data?.deepLink) openDeepLink(data.deepLink);   // same parser as the deep-links guide
}
```

`data.deepLink` is always present and always correct — the full table of shapes is
[§5.2 of the notifications guide](./mobile-notifications-guide.md#52-deep-links--the-full-list),
and registering the `clustr://` scheme is
[§3 of the deep-links guide](../deep-links/mobile-deep-links-guide.md).

### 5.3 Badges and collapsing, for free

- **iOS badge** is set by the server (Activity unread + chat unread). Do not also
  increment it yourself or it double-counts. **Android gets no badge** — do it
  from `GET /api/notifications/unread-count` if you show one.
- **Tray entries collapse**: repeat events on one thing replace each other instead
  of stacking, and within an hour the title aggregates ("Alice and 4 others liked
  your post."). Chat collapses per conversation. Nothing to implement.

---

## 6. Verify it actually works

In order. Each step has one failure symptom worth recognising.

1. **Token minted.** Log `getToken()`. Empty or throwing on iOS → §3.2 / §3.3.
2. **Server has it.** `GET /api/devices` → your row, with the `id` you keep for
   logout. Empty → the call never fired (check it runs *after* login).
3. **Server is healthy.** `GET /api/notifications/health` → `channels.push: true`
   and `devices.total ≥ 1`. `push: false` is **our** problem, not yours — tell the
   backend; it means the FCM credential does not resolve.
4. **Trigger one.** From another account, like one of your posts (or follow you).
   Background the app first.
5. **It lands.** Tray entry within a second or two. Tap it → the right screen.

| Symptom | Cause |
| --- | --- |
| Nothing on Android 13+, no error anywhere | `POST_NOTIFICATIONS` never granted (§2.3) |
| Android notification is silent / no banner, white square icon | No default channel or icon meta-data (§2.4) |
| Android fine, iOS silent | APNs auth key not uploaded (§3.2) — the single most common cause |
| Your device shows `disabledAt` set within seconds of registering | Token minted against a **different Firebase project**, or the app was uninstalled |
| `getToken()` never resolves on iOS | Simulator, or missing Push Notifications / Background Modes capability |
| Nothing while the app is open, fine when backgrounded | Working as designed — foreground display is yours to render (§5.1) |
| Notification shows, tap opens the home screen | `getInitialNotification` / `onNotificationOpenedApp` not wired, or the `clustr://` scheme not registered |
| Push stops for one user after a reinstall | `onTokenRefresh` not registered (§4) — the classic |

---

## Related

- [Notifications guide](./mobile-notifications-guide.md) — the Activity tab, the push payload (§5.1), deep-link shapes (§5.2), preferences
- [Deep links guide](../deep-links/mobile-deep-links-guide.md) — registering `clustr://` and parsing it
