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 23requirement comes from current Firebase SDKs. On Android 13 (API 33) and later, thePOST_NOTIFICATIONSruntime permission is required to display notifications.
How does it work?
NotiPilot 1.0.0 delivers notifications through the Expo Push Service. Your app:
- Gets an FCM token from Firebase,
- Exchanges this token for an Expo Push Token via the Expo token service,
- 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)
- Add your Android app in the Firebase console and place the
google-services.jsonfile in theapp/folder. - 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.)
- 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.
- 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:
plugins {
id 'com.google.gms.google-services' version '4.4.2' apply false
}
app/build.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
<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
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
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
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.jsonadded, 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(theappIdused in the token exchange) -
POST_NOTIFICATIONSpermission is requested on Android 13+ -
defaultnotification channel is created -
registerDeviceis called inonNewToken -
countryand, if possible,cityare 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. |