# Capacitor recipe

Register a Capacitor app's APNs and FCM tokens with Native Notify, show pushes while the app is open, and show images.

## 1. Set up your Capacitor project

In your terminal, from the project folder:

```bash
npm install @capacitor/push-notifications @capacitor/device
npx cap sync
```

On iOS, run `npx cap open ios`. In Xcode, select the **App** target and add the **Push Notifications** capability under **Signing & Capabilities**. Native Notify sends straight to APNs with your `.p8` key, so Firebase needs no APNs key.

On Android, register the app in the [Firebase console](https://console.firebase.google.com/) under its exact `applicationId` and save `google-services.json` in `android/app/`. Use the same Firebase project whose service-account JSON you save in step 5, or sends fail with `SENDER_ID_MISMATCH`. Capacitor's template applies the Google services plugin once that file exists.

## 2. Register the device

```ts title="src/nativeNotifyPush.ts"
import { Capacitor } from "@capacitor/core";
import { Device } from "@capacitor/device";
import { PushNotifications } from "@capacitor/push-notifications";

const REGISTER_URL = "https://app.nativenotify.com/api/universal/device/register";

type Args = { appId: number; appToken: string; subscriberId?: string };

async function postRegistration({ appId, appToken, subscriberId }: Args, token: string) {
  const platform = Capacitor.getPlatform(); // "ios" or "android"
  const res = await fetch(REGISTER_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      appId,
      appToken,
      deviceId: (await Device.getId()).identifier, // one stable id per install
      platform,
      subscriberId, // your logged-in user's id; omit for an anonymous device
      // The plugin hands over the APNs token in uppercase hex on iOS, the FCM token on Android.
      tokens: platform === "ios" ? { apnsToken: token.toLowerCase() } : { fcmToken: token },
    }),
  });
  if (res.status !== 201) {
    // 400/403 answer { error: { code, message } }; a wrong appId/appToken answers 401 as plain text.
    throw new Error(`Native Notify register failed (${res.status}): ${await res.text()}`);
  }
}

// Call once on every launch. The plugin hands you the token, and hands you the next one whenever it changes.
export async function registerUniversalDevice(args: Args) {
  if (!Capacitor.isNativePlatform()) return; // the browser has no push plugin
  const permission = await PushNotifications.requestPermissions();
  if (permission.receive !== "granted") return;
  await PushNotifications.addListener("registration", (token) => {
    postRegistration(args, token.value).catch((err) => console.warn("Native Notify:", err));
  });
  await PushNotifications.addListener("registrationError", (error) => console.warn("Native Notify: no push token —", error.error));
  await PushNotifications.register();
}
```

Call `registerUniversalDevice({ appId, appToken, subscriberId })` on every launch. The plugin calls the `registration` listener again whenever the token changes, so a rotated token registers again.

## 3. Show pushes on iOS while the app is open

The plugin shows nothing on iOS while the app is open until you set `presentationOptions`. Add it to your Capacitor config, then run `npx cap sync` in your terminal:

<!-- tabs -->

#### capacitor.config.ts

```ts title="capacitor.config.ts"
import type { CapacitorConfig } from "@capacitor/cli";

const config: CapacitorConfig = {
  // keep your appId, appName and webDir
  plugins: {
    PushNotifications: {
      presentationOptions: ["badge", "sound", "banner", "list"],
    },
  },
};

export default config;
```

#### capacitor.config.json

```json title="capacitor.config.json"
{
  "plugins": {
    "PushNotifications": {
      "presentationOptions": ["badge", "sound", "banner", "list"]
    }
  }
}
```

<!-- /tabs -->

## 4. Show pushes on Android

Android pushes are FCM **data** messages: Firebase never draws them, so your app does. This service draws every push in every app state, and still hands each message and token to the Capacitor plugin, so its `registration`, `pushNotificationReceived` and `pushNotificationActionPerformed` listeners keep working.

In Android Studio, add three files in `android/app/src/main/java/com/example/app/`, the folder of your app's package (the `namespace` in `android/app/build.gradle`). Change the `package` line in each if yours differs.

```java title="android/app/src/main/java/com/example/app/NativeNotifyMessagingService.java"
// Native Notify — shows pushes on Android.

package com.example.app;

import com.google.firebase.messaging.FirebaseMessagingService;
import com.google.firebase.messaging.RemoteMessage;

import java.lang.reflect.Method;

/**
 * Receives every FCM message and token. It draws the notification (NativeNotifyDisplay) and hands the message and the token
 * to the Capacitor push plugin, which the plugin's own service would have done — through reflection, so this file compiles
 * whether or not @capacitor/push-notifications is installed yet.
 */
public class NativeNotifyMessagingService extends FirebaseMessagingService {
    private static final String PLUGIN = "com.capacitorjs.plugins.pushnotifications.PushNotificationsPlugin";

    @Override
    public void onNewToken(String token) {
        super.onNewToken(token);
        callPlugin("onNewToken", String.class, token);
    }

    @Override
    public void onMessageReceived(RemoteMessage message) {
        super.onMessageReceived(message);
        callPlugin("sendRemoteMessage", RemoteMessage.class, message);
        if (NativeNotifyDisplay.shouldDraw(this, message)) NativeNotifyDisplay.show(this, message);
    }

    private static void callPlugin(String method, Class<?> type, Object argument) {
        try {
            Method target = Class.forName(PLUGIN).getMethod(method, type);
            target.invoke(null, argument);
        } catch (Exception ignored) {
            // the plugin is not installed (yet), or its app is not running: nothing to hand over
        }
    }
}
```

```java title="android/app/src/main/java/com/example/app/NativeNotifyDisplay.java"
// Native Notify — shows pushes on Android.
//
// An Android push is an FCM *data* message: Firebase never draws it, the app must. NativeNotifyMessagingService
// hands every message it receives to show() — in every app state — and to the Capacitor push plugin,
// so that plugin's listeners (registration, pushNotificationReceived, pushNotificationActionPerformed)
// keep working exactly as before. A push with a picture is drawn with the picture.

package com.example.app;

import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.Build;
import android.os.Looper;
import android.util.Log;

import com.google.firebase.messaging.RemoteMessage;

import org.json.JSONArray;
import org.json.JSONObject;

import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.Locale;
import java.util.Map;

final class NativeNotifyDisplay {
    private static final String TAG = "NativeNotify";
    static final String DEFAULT_CHANNEL = "default";
    private static final int MAX_IMAGE_BYTES = 5 * 1024 * 1024;
    private static final int MAX_IMAGE_SIDE = 1024;

    private NativeNotifyDisplay() {}

    /**
     * Whether this service should draw the message. A push without a picture is a data message nobody else draws. A push with a
     * picture also has a notification block: Firebase draws it while the app is closed or in the background (this service is not
     * called then), and while the app is open the Capacitor push plugin draws it only when capacitor.config sets
     * plugins.PushNotifications.presentationOptions — otherwise nobody does, so this service does.
     */
    static boolean shouldDraw(Context context, RemoteMessage message) {
        return message.getNotification() == null || !pluginPresents(context);
    }

    private static boolean pluginPresents(Context context) {
        try (InputStream in = context.getAssets().open("capacitor.config.json")) {
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            byte[] buffer = new byte[4096];
            int read;
            while ((read = in.read(buffer)) >= 0) out.write(buffer, 0, read);
            JSONObject plugins = new JSONObject(out.toString("UTF-8")).optJSONObject("plugins");
            JSONObject push = plugins == null ? null : plugins.optJSONObject("PushNotifications");
            JSONArray options = push == null ? null : push.optJSONArray("presentationOptions");
            if (options == null) return false;
            for (int i = 0; i < options.length(); i++) {
                String option = options.optString(i);
                if ("alert".equals(option) || "banner".equals(option) || "list".equals(option)) return true;
            }
        } catch (Exception ignored) {
            // no readable capacitor.config.json: the plugin shows nothing, so this service does
        }
        return false;
    }

    /** The notification channel Firebase and this service use (created at app start by NativeNotifyInitProvider). */
    static void createDefaultChannel(Context context) {
        createChannel(context, DEFAULT_CHANNEL);
    }

    /** Draw a Native Notify message: its title and text and, when the push has one, its image. */
    static void show(Context context, RemoteMessage message) {
        final Map<String, String> data = message.getData();
        final String title = data.get("title");
        if (title == null) return;
        final Context app = context.getApplicationContext();
        final int id = (int) System.currentTimeMillis();
        RemoteMessage.Notification notification = message.getNotification();
        final String imageUrl = notification != null && notification.getImageUrl() != null ? notification.getImageUrl().toString() : null;
        final String messageId = message.getMessageId();
        if (imageUrl == null || imageUrl.isEmpty()) {
            post(app, id, messageId, data, title, null);
        } else if (Looper.myLooper() == Looper.getMainLooper()) {
            // Never download on the main thread: show the text now and swap in the picture when it arrives.
            post(app, id, messageId, data, title, null);
            new Thread(() -> {
                Bitmap picture = loadImage(imageUrl);
                if (picture != null) post(app, id, messageId, data, title, picture);
            }).start();
        } else {
            post(app, id, messageId, data, title, loadImage(imageUrl));
        }
    }

    private static void post(Context app, int id, String messageId, Map<String, String> data, String title, Bitmap picture) {
        String text = data.get("message") != null ? data.get("message") : "";
        String channelId = data.get("channelId") != null ? data.get("channelId") : DEFAULT_CHANNEL;
        NotificationManager manager = (NotificationManager) app.getSystemService(Context.NOTIFICATION_SERVICE);
        createChannel(app, channelId);

        // A tap opens the app. The push's data travels as plain extras, with the message id, exactly like a notification Firebase
        // drew itself — that is what makes the Capacitor push plugin report it as pushNotificationActionPerformed.
        Intent launch = app.getPackageManager().getLaunchIntentForPackage(app.getPackageName());
        PendingIntent tap = null;
        if (launch != null) {
            launch.putExtra("google.message_id", messageId != null ? messageId : "native-notify-" + id);
            for (Map.Entry<String, String> entry : data.entrySet()) launch.putExtra(entry.getKey(), entry.getValue());
            int flags = PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0);
            tap = PendingIntent.getActivity(app, id, launch, flags);
        }

        Notification.Builder builder = Build.VERSION.SDK_INT >= 26 ? new Notification.Builder(app, channelId) : new Notification.Builder(app);
        builder.setSmallIcon(app.getApplicationInfo().icon).setContentTitle(title).setContentText(text).setAutoCancel(true);
        if (tap != null) builder.setContentIntent(tap);
        if (picture != null) {
            builder.setLargeIcon(picture);
            builder.setStyle(new Notification.BigPictureStyle().bigPicture(picture).bigLargeIcon((Bitmap) null));
        }
        manager.notify(id, builder.build());
    }

    /** Download the push's image (https only, at most 5 MB, 8 seconds) and shrink it to a size a notification can show. null = no picture. */
    private static Bitmap loadImage(String url) {
        if (!url.toLowerCase(Locale.ROOT).startsWith("https://")) return null;
        HttpURLConnection connection = null;
        try {
            connection = (HttpURLConnection) new URL(url).openConnection();
            connection.setConnectTimeout(8000);
            connection.setReadTimeout(8000);
            int code = connection.getResponseCode();
            if (code < 200 || code > 299) return null;
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            byte[] buffer = new byte[8192];
            try (InputStream in = connection.getInputStream()) {
                int read;
                while ((read = in.read(buffer)) >= 0) {
                    if (out.size() + read > MAX_IMAGE_BYTES) return null;
                    out.write(buffer, 0, read);
                }
            }
            return decode(out.toByteArray());
        } catch (Exception e) {
            Log.w(TAG, "the notification image could not be loaded — " + e.getMessage());
            return null;
        } finally {
            if (connection != null) connection.disconnect();
        }
    }

    private static Bitmap decode(byte[] bytes) {
        BitmapFactory.Options bounds = new BitmapFactory.Options();
        bounds.inJustDecodeBounds = true;
        BitmapFactory.decodeByteArray(bytes, 0, bytes.length, bounds);
        if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null;
        int sample = 1;
        while (Math.max(bounds.outWidth, bounds.outHeight) / sample > MAX_IMAGE_SIDE) sample *= 2;
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inSampleSize = sample;
        return BitmapFactory.decodeByteArray(bytes, 0, bytes.length, options);
    }

    private static void createChannel(Context context, String id) {
        if (Build.VERSION.SDK_INT < 26) return;
        NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
        if (manager.getNotificationChannel(id) == null) {
            manager.createNotificationChannel(new NotificationChannel(id, "Notifications", NotificationManager.IMPORTANCE_HIGH));
        }
    }
}
```

```java title="android/app/src/main/java/com/example/app/NativeNotifyInitProvider.java"
// Native Notify — shows pushes on Android.

package com.example.app;

import android.content.ContentProvider;
import android.content.ContentValues;
import android.database.Cursor;
import android.net.Uri;

/** Starts with the app (the trick Firebase itself uses): creates the notification channel Firebase draws a closed-app push on. */
public class NativeNotifyInitProvider extends ContentProvider {
    @Override
    public boolean onCreate() {
        if (getContext() != null) NativeNotifyDisplay.createDefaultChannel(getContext());
        return true;
    }

    @Override public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { return null; }
    @Override public String getType(Uri uri) { return null; }
    @Override public Uri insert(Uri uri, ContentValues values) { return null; }
    @Override public int delete(Uri uri, String selection, String[] selectionArgs) { return 0; }
    @Override public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { return 0; }
}
```

Register the service, the provider and Firebase's default channel and icon inside `<application>`. The service's priority makes it win over the plugin's own:

```xml title="android/app/src/main/AndroidManifest.xml"
<service
    android:name=".NativeNotifyMessagingService"
    android:exported="false">
    <intent-filter android:priority="100">
        <action android:name="com.google.firebase.MESSAGING_EVENT" />
    </intent-filter>
</service>

<provider
    android:name=".NativeNotifyInitProvider"
    android:authorities="${applicationId}.native-notify-init"
    android:exported="false" />

<meta-data
    android:name="com.google.firebase.messaging.default_notification_channel_id"
    android:value="default" />
<meta-data
    android:name="com.google.firebase.messaging.default_notification_icon"
    android:resource="@mipmap/ic_launcher" />
```

Add Firebase Messaging to the app module, next to the Capacitor dependency:

```gradle title="android/app/build.gradle"
dependencies {
    implementation project(':capacitor-android')
    implementation "com.google.firebase:firebase-messaging:${rootProject.ext.has('firebaseMessagingVersion') ? rootProject.ext.firebaseMessagingVersion : '25.0.1'}"
}
```

If you turn on code shrinking (`minifyEnabled true`), keep the two plugin methods the service calls:

```text title="android/app/proguard-rules.pro"
-keepclassmembers class com.capacitorjs.plugins.pushnotifications.PushNotificationsPlugin {
    public static void sendRemoteMessage(com.google.firebase.messaging.RemoteMessage);
    public static void onNewToken(java.lang.String);
}
```

Run `npx cap sync android` in your terminal, then run the app again from Android Studio: native code needs a new build.

## 5. Save credentials in Native Notify

In [Native Notify](https://dashboard.nativenotify.com), open your project's **Settings → Push credentials** and save the same Firebase project's service-account JSON and your Apple `.p8` key. See [Push credentials](/docs/push/credentials).

## 6. Send a push with the API

Check one device first with a [test send](/docs/push/verification). For other audiences, see [Send notifications](/docs/push/sending). This call reaches every registered device:

```bash
curl -X POST https://app.nativenotify.com/api/universal/notifications/send \
  -H "Content-Type: application/json" \
  -d '{"appId":123,"appToken":"yourAppToken","title":"Hello Capacitor","message":"Sent through APNs/FCM.","audience":{"type":"all"}}'
```

## Show images

On Android a picture shows in every app state: Firebase draws it while the app is closed or in the background, on the `default` channel the manifest names, and the code in step 4 covers an open app.

On iPhone, add a notification service extension to the App target: run `npx cap open ios` in your terminal, then follow [Show images in the Swift recipe](/docs/push/recipes/swift#show-images). The iPhone Simulator never runs it, so test on an iPhone. [Send a push with a `bigPictureURL`](/docs/push/sending#images): an `https` JPEG or PNG under 1 MB.

## Next steps

- [Device registration](/docs/push/registration) — fields, deregister and errors.
- [Groups](/docs/push/groups) — let users subscribe from the app with `POST /api/universal/groups/subscribe`, then send to the group.
