Developers · API v1.0.0

Android Integration (Java)

This guide is for native Android apps built with Android Studio using Java. If you use Kotlin, see the Kotlin guide. For the general API reference, see the Overview.

Supported versions

Component Minimum Recommended
Android 6.0 (API 23, minSdk 23) targetSdk / compileSdk 35+
Java 11 (source compatibility) 17
Android Gradle Plugin 8.0 latest
Firebase BoM 33.x latest

The minSdk 23 requirement comes from current Firebase SDKs. On Android 13 (API 33) and later, the POST_NOTIFICATIONS runtime permission is required to display notifications.

How does it work?

NotiPilot 1.0.0 delivers notifications through the Expo Push Service. Your app:

  1. Gets an FCM token from Firebase,
  2. Exchanges this token for an Expo Push Token via the Expo token service,
  3. Registers the Expo Push Token with NotiPilot using register-device.

Notifications still reach the device through FCM, so a standard FirebaseMessagingService in your app is all you need.

1. Prerequisites (one-time)

  1. Add your Android app in the Firebase console and place the google-services.json file in the app/ folder.
  2. Create a project on expo.dev and note its project ID (UUID). (Expo is used only as the delivery infrastructure; your app doesn't need to be built with Expo.)
  3. In the Firebase console → Project Settings → Service Accounts, click Generate new private key to download the FCM V1 service account JSON, then upload it on expo.dev → Project → Credentials → Android → FCM V1 service account key.
  4. Add your app in the NotiPilot dashboard: Expo Project ID = your Expo project ID, Expo Access Token = expo.dev → Access Tokens, Android Package = applicationId.

2. Gradle

Project-level build.gradle:

Gradle
plugins {
    id 'com.google.gms.google-services' version '4.4.2' apply false
}

app/build.gradle:

Gradle
plugins {
    id 'com.android.application'
    id 'com.google.gms.google-services'
}

android {
    defaultConfig {
        minSdk 23
        buildConfigField "String", "NOTIPILOT_BASE_URL", "\"https://app.notipilot.com/api/v1\""
        buildConfigField "String", "NOTIPILOT_APP_ID", "\"YOUR-EXPO-PROJECT-ID\""
    }
    buildFeatures { buildConfig true }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_17
        targetCompatibility JavaVersion.VERSION_17
    }
}

dependencies {
    implementation platform('com.google.firebase:firebase-bom:33.7.0')
    implementation 'com.google.firebase:firebase-messaging'
    implementation 'com.squareup.okhttp3:okhttp:4.12.0'
}

Version numbers are examples; use the latest versions in your project.

3. AndroidManifest.xml

XML
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

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

    <application ...>
        <service
            android:name=".push.NotiPilotMessagingService"
            android:exported="false">
            <intent-filter>
                <action android:name="com.google.firebase.MESSAGING_EVENT" />
            </intent-filter>
        </service>

        <meta-data
            android:name="com.google.firebase.messaging.default_notification_channel_id"
            android:value="default" />
    </application>
</manifest>

NotiPilot sends notifications to the default channel.

4. NotiPilot.java

Java
package com.example.app.push;

import android.content.Context;
import android.content.SharedPreferences;
import android.os.Build;

import com.example.app.BuildConfig;
import com.google.android.gms.tasks.Tasks;
import com.google.firebase.messaging.FirebaseMessaging;

import org.json.JSONObject;

import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;

public final class NotiPilot {
    private static final OkHttpClient HTTP = new OkHttpClient();
    private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
    private static final ExecutorService IO = Executors.newSingleThreadExecutor();

    private NotiPilot() {}

    public interface Callback { void onResult(JSONObject response, Exception error); }

    public static String deviceUid(Context context) {
        SharedPreferences prefs = context.getSharedPreferences("notipilot", Context.MODE_PRIVATE);
        String uid = prefs.getString("device_uid", null);
        if (uid == null) {
            uid = UUID.randomUUID().toString();
            prefs.edit().putString("device_uid", uid).apply();
        }
        return uid;
    }

    /** Call on app launch (after permission is granted) and in onNewToken. fcmToken may be null. */
    public static void registerDevice(Context context, String fcmToken,
                                      Map<String, Object> extraAttributes, Callback cb) {
        Context app = context.getApplicationContext();
        IO.execute(() -> {
            try {
                String uid = deviceUid(app);
                String token = fcmToken != null ? fcmToken
                        : Tasks.await(FirebaseMessaging.getInstance().getToken());
                String expoToken = exchangeForExpoToken(app, uid, token);

                JSONObject attributes = new JSONObject();
                attributes.put("locale", Locale.getDefault().getLanguage());   // "tr"
                attributes.put("country", Locale.getDefault().getCountry());   // "TR"
                attributes.put("app_version",
                        app.getPackageManager().getPackageInfo(app.getPackageName(), 0).versionName);
                attributes.put("os_version", Build.VERSION.RELEASE);
                if (extraAttributes != null) {
                    for (Map.Entry<String, Object> e : extraAttributes.entrySet()) {
                        attributes.put(e.getKey(), e.getValue() == null ? JSONObject.NULL : e.getValue());
                    }
                }

                JSONObject body = new JSONObject();
                body.put("app_id", BuildConfig.NOTIPILOT_APP_ID);
                body.put("device_uid", uid);
                body.put("token", expoToken);
                body.put("platform", "android");
                body.put("provider", "expo");
                body.put("attributes", attributes);

                deliver(cb, post("/register-device", body, 0), null);
            } catch (Exception e) {
                deliver(cb, null, e);
            }
        });
    }

    /** Call when the user signs in. */
    public static void identify(Context context, String externalId, Callback cb) {
        Context app = context.getApplicationContext();
        IO.execute(() -> {
            try {
                JSONObject body = new JSONObject();
                body.put("app_id", BuildConfig.NOTIPILOT_APP_ID);
                body.put("device_uid", deviceUid(app));
                body.put("external_id", externalId);
                deliver(cb, post("/identify-device", body, 0), null);
            } catch (Exception e) {
                deliver(cb, null, e);
            }
        });
    }

    /** Exchanges the FCM token for an Expo Push Token. */
    private static String exchangeForExpoToken(Context context, String uid, String fcmToken) throws Exception {
        JSONObject body = new JSONObject();
        body.put("type", "fcm");
        body.put("deviceId", uid.toLowerCase(Locale.ROOT));
        body.put("development", false);
        body.put("appId", context.getPackageName());
        body.put("deviceToken", fcmToken);
        body.put("projectId", BuildConfig.NOTIPILOT_APP_ID);

        Request request = new Request.Builder()
                .url("https://exp.host/--/api/v2/push/getExpoPushToken")
                .post(RequestBody.create(body.toString(), JSON))
                .build();
        try (Response res = HTTP.newCall(request).execute()) {
            String raw = res.body() != null ? res.body().string() : "{}";
            if (!res.isSuccessful()) {
                throw new IllegalStateException("Expo token exchange failed: " + res.code() + " " + raw);
            }
            return new JSONObject(raw).getJSONObject("data").getString("expoPushToken");
        }
    }

    private static JSONObject post(String path, JSONObject body, int attempt) throws Exception {
        Request request = new Request.Builder()
                .url(BuildConfig.NOTIPILOT_BASE_URL + path)
                .post(RequestBody.create(body.toString(), JSON))
                .build();
        int code;
        JSONObject json;
        try (Response res = HTTP.newCall(request).execute()) {
            code = res.code();
            String raw = res.body() != null ? res.body().string() : "";
            json = new JSONObject(raw.isEmpty() ? "{}" : raw);
        }
        if ((code == 429 || code >= 500) && attempt < 3) {
            Thread.sleep(json.optLong("retry_after", 1L << attempt) * 1000L);
            return post(path, body, attempt + 1);
        }
        return json;
    }

    private static void deliver(Callback cb, JSONObject res, Exception err) {
        if (cb != null) cb.onResult(res, err);
    }
}

5. NotiPilotMessagingService.java

Java
package com.example.app.push;

import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.os.Build;

import androidx.annotation.NonNull;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;

import com.example.app.MainActivity;
import com.example.app.R;
import com.google.firebase.messaging.FirebaseMessagingService;
import com.google.firebase.messaging.RemoteMessage;

import org.json.JSONException;
import org.json.JSONObject;

import java.util.Iterator;
import java.util.Map;

public class NotiPilotMessagingService extends FirebaseMessagingService {
    public static final String CHANNEL_ID = "default";

    @Override
    public void onNewToken(@NonNull String token) {
        NotiPilot.registerDevice(getApplicationContext(), token, null, null);
    }

    @Override
    public void onMessageReceived(@NonNull RemoteMessage message) {
        // Expo may send the content in the `notification` block or inside `data` (title / message / body).
        Map<String, String> data = message.getData();
        String title = message.getNotification() != null ? message.getNotification().getTitle() : data.get("title");
        String body = message.getNotification() != null ? message.getNotification().getBody() : data.get("message");
        if (title == null) return;

        // Custom data sent from the dashboard arrives as a JSON string in `data["body"]`.
        JSONObject custom = null;
        try {
            if (data.get("body") != null) custom = new JSONObject(data.get("body"));
        } catch (JSONException ignored) { }

        show(this, title, body != null ? body : "", custom);
    }

    public static void createChannel(Context context) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            NotificationChannel channel =
                    new NotificationChannel(CHANNEL_ID, "General", NotificationManager.IMPORTANCE_HIGH);
            context.getSystemService(NotificationManager.class).createNotificationChannel(channel);
        }
    }

    public static void show(Context context, String title, String body, JSONObject data) {
        createChannel(context);

        Intent intent = new Intent(context, MainActivity.class);
        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
        if (data != null) {
            for (Iterator<String> it = data.keys(); it.hasNext(); ) {
                String key = it.next();
                intent.putExtra(key, data.optString(key));
            }
        }
        int id = (int) System.currentTimeMillis();
        PendingIntent pending = PendingIntent.getActivity(context, id, intent,
                PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);

        NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID)
                .setSmallIcon(R.drawable.ic_notification)
                .setContentTitle(title)
                .setContentText(body)
                .setStyle(new NotificationCompat.BigTextStyle().bigText(body))
                .setPriority(NotificationCompat.PRIORITY_HIGH)
                .setAutoCancel(true)
                .setContentIntent(pending);

        NotificationManagerCompat manager = NotificationManagerCompat.from(context);
        if (manager.areNotificationsEnabled()) {
            manager.notify(id, builder.build());
        }
    }
}

6. MainActivity.java — permission and registration

Java
public class MainActivity extends AppCompatActivity {

    private final ActivityResultLauncher<String> permissionLauncher =
            registerForActivityResult(new ActivityResultContracts.RequestPermission(), granted -> {
                if (granted) register();
            });

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        NotiPilotMessagingService.createChannel(this);

        if (Build.VERSION.SDK_INT >= 33 &&
                checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
            permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS);
        } else {
            register();
        }

        // If the app was opened by tapping a notification, custom data is in the intent extras
        String screen = getIntent().getStringExtra("screen");
    }

    private void register() {
        Map<String, Object> extra = new HashMap<>();
        extra.put("city", "Istanbul");
        NotiPilot.registerDevice(this, null, extra, (res, err) -> {
            if (err != null) Log.w("NotiPilot", "register failed", err);
        });
    }
}

When the user signs in: NotiPilot.identify(context, user.getId(), null);

Checklist

  • google-services.json added, FCM V1 service account key uploaded to the Expo project
  • Expo Project ID in the dashboard = NOTIPILOT_APP_ID
  • Android Package in the dashboard = applicationId (the appId used in the token exchange)
  • POST_NOTIFICATIONS permission is requested on Android 13+
  • default notification channel is created
  • registerDevice is called in onNewToken
  • country and, if possible, city are sent as attributes

Common issues

Symptom Solution
Token exchange returns 4xx Is projectId your Expo project ID? Does appId match the app's package name?
Registration succeeds but no notifications arrive FCM V1 credentials are not uploaded to the Expo project. Check expo.dev → Credentials → Android.
No notifications while the app is in the background Check battery optimization, manufacturer restrictions (Xiaomi, Huawei, etc.) and notification permission.
404 unknown_app The app_id is not registered in the dashboard.