# Android SDK


`openpush-android` is a receive-only Android library. It registers a device with OpenPush, carries the user's identity and tags, and posts delivery receipts back. It has no send surface at all — messages are created through the REST API or the console, never from the device.

## When to use

Add this SDK to any Android app that should receive push from OpenPush. If your app already owns a `FirebaseMessagingService`, keep it and forward payloads into the SDK. If it has no messaging service yet, the optional FCM module can own delivery and rendering for you.

## Requirements

| Requirement | Value |
|---|---|
| SDK version | `1.1.5` |
| Minimum Android | 7.0 (API 24) |
| Compile SDK | 36 |
| Java / Kotlin target | 17 |
| Runtime dependency | `kotlinx-coroutines-android` only — the core artifact does not depend on Firebase |
| Credentials | Your app id and the app's **SDK key** (`X-OP-SDK-Key`) |
| Server | Nothing to choose — the API lives at `https://app.openpush.ai`. You do need a working FCM credential uploaded under Android — see [platform-setup-fcm.md](platform-setup-fcm.md) |

The SDK key is public by design and is meant to ship inside the app binary. The REST API key must never appear in an app. See [security-and-limits.md](security-and-limits.md).

## Step 1 — install

The intended Maven coordinate is:

```kotlin
implementation("ai.openpush:openpush-android:1.1.5")
```

**The Maven artifact is not published yet.** Until it is, add the library as a composite build from a source checkout — this is the distribution channel that actually works today:

```kotlin
// settings.gradle.kts
includeBuild("../openpush-android") {
    dependencySubstitution {
        substitute(module("ai.openpush:openpush-android")).using(project(":openpush"))
    }
}
```

Optional modules, each versioned in step with the core:

| Module | What it adds |
|---|---|
| `openpush-android-fcm` | A `FirebaseMessagingService`, a default notification renderer, and automatic receipts |
| `openpush-android-live-updates` | The Android ongoing-notification renderer (Java-only, no Firebase dependency) |
| `openpush-android-in-app-messages` | The in-app message presenter, trigger state and receipt path — see [in-app-messages.md](in-app-messages.md) |
| The compatibility facade module | A compile-time alias layer for migrating hosts — see [Compatibility facade](#compatibility-facade) |

### Compatibility facade

The compatibility facade is an opt-in module for migrating hosts. It accepts the API spelling of the previous provider's SDK, so existing call sites compile against OpenPush unchanged. It is a source-compatibility layer only. It pulls `openpush-android-in-app-messages` in as an `api` dependency, because the facade's `InAppMessages` surface resolves onto it. See the [migration guide](migrate-from-onesignal.md).

Do **not** add `openpush-android-fcm` if your app already declares a `FirebaseMessagingService`. Two services filtering the same FCM intent means delivery to either one is unspecified. If it arrives transitively, remove its service during manifest merge:

```xml
<service
    xmlns:tools="http://schemas.android.com/tools"
    android:name="ai.openpush.android.fcm.OpenPushMessagingService"
    tools:node="remove" />
```

## Step 2 — initialize

Put the SDK key in the manifest:

```xml
<meta-data android:name="ai.openpush.sdk_key" android:value="YOUR_OPENPUSH_SDK_KEY" />
```

Then initialize once, from `Application.onCreate`:

```kotlin
OpenPush.initialize(
    applicationContext,
    appId = "app_3f9c",
    serverURL = "https://app.openpush.ai",
    credential = { deviceCredentialStore.load() },
)
```

`serverURL` already defaults to `https://app.openpush.ai`, so the argument above is optional and shown only to make the target explicit. To keep the key out of the manifest, use the overload that takes `sdkKey` explicitly — an explicit argument always beats the manifest value. Recognised manifest keys are `ai.openpush.sdk_key` and `ai.openpush.app_id`.

With no key in either place, `initialize` **refuses** rather than half-configuring: it reports through `onLog` and `onRegistrationFailed` and does nothing else.

`OpenPush.attach(context, OpenPushConfiguration(appId, sdkKey), credential)` is the lower-level form; `initialize` wraps it.

**Verify:** `OpenPush.isInitialized` is `true` and `OpenPush.onLog` reports no refusal.

### Initialize from the Application, not an Activity

The SDK reads foregroundedness from the `ActivityLifecycleCallbacks` it registers at `initialize`. Initializing with an `Application` context installs that observation automatically. If you pass some other context, drive it yourself with `OpenPush.onAppForegrounded()` and `OpenPush.onAppBackgrounded()`.

There is one sharp edge: attaching from an `Activity` adopts that Activity as visible, because the lifecycle callbacks register too late to have seen its `onStart`. If that Activity had *already* stopped, Android offers no way to discover it, so the process looks foregrounded until the Activity is destroyed. Initialize from `Application.onCreate`, or pass `applicationContext`.

## Step 3 — request notification permission

```kotlin
OpenPush.Notifications.requestPermission(activity) { granted -> /* … */ }
OpenPush.Notifications.requestPermission(fallbackToSettings = true) { granted -> /* … */ }
```

There is also a `suspend` overload. The overload without an `Activity` uses the currently resumed activity; with none resumed it logs the limitation and reports the cached permission immediately.

Forward the OS result from your Activity:

```kotlin
override fun onRequestPermissionsResult(
    requestCode: Int,
    permissions: Array<String>,
    grantResults: IntArray,
) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)
    OpenPush.Notifications.onPermissionResult(requestCode, grantResults)
}
```

The request code is `OpenPush.Notifications.PERMISSION_REQUEST_CODE` (`9110`). `fallbackToSettings` opens the system notification settings once the OS stops showing the prompt, which is the only path a denied user has left.

`OpenPush.Notifications.permission` is a cache, refreshed at initialization, on foregrounding, around permission calls, and while processing a foreground message. Watch changes with `addPermissionObserver` / `removePermissionObserver`.

Android has **no provisional (quiet) authorization** — that is an iOS-only concept, and the Android SDK deliberately offers no analogue.

Every registration reports the resolved status: `1` with permission, `0` without, `-2` while explicitly opted out. The SDK recomputes it at launch and on foreground and re-registers only when the accepted value changed.

## Step 4 — hand over the push token

OpenPush never asks FCM for a token. Your app owns provider integration and forwards the result:

```kotlin
OpenPush.register(fcmToken, externalId = optionalExternalId)
```

Call it from `onNewToken` too, so a rotated token reaches the server.

Transient registration failures retry at +2 s and +8 s, three attempts total. A 4xx is final. `onRegistrationFailed` fires only after the last attempt, so it can arrive roughly ten seconds after the call. A newer registration cancels an older pending retry.

**Verify:** `OpenPush.onRegistered` fires, and the device appears in `GET /v1/apps/{app_id}/subscriptions`.

## Step 5 — identity

```kotlin
OpenPush.login("user-7", authHash = serverComputedHash) { result ->
    when (result) {
        is IdentityResult.IdentityRejected -> Log.w(TAG, "anonymous: ${result.note}")
        is IdentityResult.Failed -> Log.w(TAG, result.message)
        IdentityResult.Success -> Unit
    }
}

OpenPush.logout()
```

`login` before a token exists stores the identity and applies it to every later registration, including token refreshes. `logout` re-registers with a present-and-empty `external_id`, which is how the server is told to detach.

A rejected identity is reported as `IdentityRejected`, **never as success** — the device is registered anonymously in that case. Compute `authHash` on your server; the SDK keeps it in memory only and never persists it, so call `login` again at launch. The full model is in [users-and-subscriptions.md](users-and-subscriptions.md).

## Step 6 — tags and aliases

```kotlin
OpenPush.User.addTag("plan", "pro")
OpenPush.User.addTags(mapOf("plan" to "pro", "seats" to "3"))   // one request
OpenPush.User.removeTag("plan")                                  // sends ""
OpenPush.User.removeTags(listOf("plan", "seats"))
val localTags = OpenPush.User.getTags()                          // a copy
```

Aliases use the same shape: `addAlias`, `addAliases`, `removeAlias`, `removeAliases`.

Rules that matter:

- **Tag values are strings only.** There are no numeric, boolean or date overloads on any OpenPush SDK.
- **Deleting is an empty-string value.** A removed tag keeps riding as `""` for the rest of the process session so a token-refresh re-registration cannot resurrect it.
- Writes join a fixed **300 ms** window measured from the first pending write; the window does not restart. `register`, `login`, `logout`, `optIn` and `optOut` absorb pending writes; backgrounding flushes immediately.
- Without a token, writes simply accumulate and ride the first registration.
- The server caps aliases at **10 per user** and refuses reserved labels such as `external_id`. It names refused labels on a successful response rather than failing the request, and the SDK logs them.
- With identity verification enabled, a tag or alias write without a valid in-memory auth hash comes back rejected and is logged.

## Step 7 — notification handlers and receipts

```kotlin
val clicks = OpenPush.Notifications.addClickListener { event -> route(event.data) }
val foreground = OpenPush.Notifications.addForegroundLifecycleListener { event ->
    event.preventDefault()   // your app will present its own UI instead
}

// In FirebaseMessagingService.onMessageReceived — for EVERY delivery:
if (OpenPush.Notifications.processForegroundMessage(message.data)) return

// From the notification-tap trampoline:
OpenPush.Notifications.processClick(mapOf("op_message_id" to messageId))
```

Forward **every** message through `processForegroundMessage`, backgrounded or not. `onMessageReceived` fires with the app backgrounded or killed too, and the receipt has to be posted either way. The foreground listeners run — and `preventDefault()` can return `true` — only while the app actually has a visible Activity. That is what stops a `preventDefault()` from suppressing a notification the user never saw: a background delivery still posts its receipt, still returns `false`, and still renders.

Keep the returned `ListenerHandle` to remove a listener. Listeners are not cleared by `initialize`.

### Receipts

`processForegroundMessage` posts `received`; `processClick` posts `clicked`. Post `confirmed` yourself, at the moment you actually display the notification:

```kotlin
val messageId = OpenPush.messageId(message.data)
OpenPush.receipt("received", messageId)
OpenPush.receipt("confirmed", messageId)
OpenPush.receipt("clicked", messageId)
```

The three stages are deliberately distinct and are not interchangeable:

| Stage | Meaning |
|---|---|
| Provider Accepted | FCM took the message from OpenPush |
| Device Received | the SDK got the data payload |
| Confirmed Receipt | a notification was actually displayed |
| Clicked | the user opened it |

Failed receipt requests stay retryable; only successful receipts are deduplicated, and only for the current process session.

### Action buttons

The bundled FCM renderer displays buttons from the message's top-level `actions`
field and passes the selected ID to the click callback. If your app supplies its
own renderer, parse the delivered button data with:

```kotlin
val actions = OpenPush.actions(message.data)   // id, label, icon, systemIcon
```

`OpenPush.actions` parses the internal `op_actions` payload key, capped at **3**.
Attach the parsed buttons to your own notification builder. The public send API
uses `actions`; see [Sending messages](sending-messages.md#action-buttons).

## Android rendering options in the payload

An OpenPush Android push is a **data-only** FCM message. There is no `android.notification` block, so FCM never draws anything — your app does. The sender therefore ships the composer's Android options as plain keys inside the data map:

| Data key | Composer option | Typical use |
|---|---|---|
| `op_android_channel` | Channel | the `NotificationChannel` id to post into |
| `op_android_accent` | Accent | `Notification.Builder.setColor` |
| `op_android_large_icon` | Large icon | image URL for `setLargeIcon` |
| `op_android_big_picture` | Big picture | image URL for `BigPictureStyle` |
| `op_android_group` | Group | `setGroup` key for bundling |

The bundled FCM renderer uses these options: it selects the requested channel,
applies the accent and group, and loads big-picture and large-icon HTTPS URLs.
It preserves a channel your app already created; if the channel ID is unknown,
it creates one with default importance and the ID as its name. A custom renderer
can read the same payload keys and apply its own display policy.

Other keys that arrive in the same map: `op_message_id` (the id you pass to `receipt`), `title`, `body`, `image_url` and `deep_link` when set, plus any custom `data` from the message. `op_render_truncated` is `"1"` when the server had to trim the payload to fit FCM's 4 KB data envelope.

Images must be `https://` URLs, or a media path served by OpenPush — see [sending-messages.md](sending-messages.md).

## The optional FCM module

If you add `openpush-android-fcm`, it registers refreshed tokens, renders payloads that carry a title or body, creates a notification channel when one is missing, posts `received` and `clicked` receipts, and forwards notification extras to your launch activity. Configure its display defaults in the manifest:

```xml
<meta-data android:name="ai.openpush.notification_channel_id" android:value="messages" />
<meta-data android:name="ai.openpush.notification_icon" android:resource="@drawable/ic_notification" />
```

Without the channel meta-data it posts to a channel named `openpush_default`.

## Custom events

```kotlin
OpenPush.trackEvent("onboarding_started", mapOf("source" to "welcome"))
```

Names are 1–128 characters matching `[a-zA-Z0-9_\-. ]+`. Properties must be JSON-safe and at most 2048 bytes. Events are validated locally, persisted while offline, and sent in batches of up to 50 after the same 300 ms window. Switching workspaces clears the queue so one account's activity is never reported under another. See [events.md](events.md).

## Subscription state and opt-out

```kotlin
val state = OpenPush.User.pushSubscription   // state.id, state.token, state.optedIn
state.optOut()                               // status -2 — the server stops sending here
state.optIn()                                // restores the device status, prompting if needed
OpenPush.PushSubscription.optIn(activity)    // explicit prompt source
```

`optOut()` does not delete the subscription or the provider token; it is durable across process death and reversible. `clearToken()` wipes the local token but leaves `state.id` in place, because the server-side subscription still exists.

The Android subscription state carries `id`, `token` and `optedIn` — there is deliberately **no `status` field** on Android. Only the iOS SDK exposes the raw status integer.

## App sessions

When you initialize with an `Application` context, cold launches and foreground transitions send a throttled app-session ping, at most one per **30 minutes**, persisted across process restarts. It powers session counts and the per-user activity histogram behind [best-hour delivery](best-hour-delivery.md). With any other context, call `OpenPush.onAppForegrounded()` yourself.

## Limits

- No send surface, and none is planned for the SDK. Sending is REST or console only.
- No swizzling and no delegate installation — every callback is forwarded by your app explicitly.
- No badge API, no notification-category API, and no consent gate. The compatibility facade's `consentRequired` / `consentGiven` are compile-time errors, not silent no-ops.
- No notification inbox: no inbox API exists on the SDK and the compatibility facade declares none.
- **In-app messages are supported.** `openpush-android-in-app-messages` carries the presenter, the trigger controller and the impression/click receipt path, and the compatibility facade's `InAppMessages` resolves onto `OpenPushInAppMessages` rather than failing to compile. See [in-app-messages.md](in-app-messages.md) and the [in-app messages API](../api-handbook/10-in-app-messages.md).
- No ActivityKit-style Live Activities — Android has none. The equivalent is Live Updates, an ongoing notification driven by a `live_notification` data value. See [live-activities.md](live-activities.md).
- Tag values are strings, always.
- `DeviceCredentialStore` is not constructible from a host app on Android; its constructor is internal.
- Not on Maven Central yet — see Step 1.

## Troubleshooting

| Symptom | Cause | Fix |
|---|---|---|
| `initialize` logs a refusal and nothing registers | No SDK key in the manifest and none passed in code | Add `ai.openpush.sdk_key` meta-data or pass `sdkKey` explicitly |
| `onRegistrationFailed` about ten seconds after a call | Transport or 5xx retries exhausted (+2 s, +8 s) | Check the device's network reachability to `app.openpush.ai`; a 4xx is final and reported immediately |
| Device registered but never receives anything | FCM service account uploaded under **Web** instead of **Android** | Upload it under Android — see [platform-setup-fcm.md](platform-setup-fcm.md) |
| Notifications arrive twice | Both your own `FirebaseMessagingService` and `openpush-android-fcm` are present | Remove the module, or strip its service with `tools:node="remove"` |
| Receipts never appear in message stats | `processForegroundMessage` only called on foreground deliveries, or no `op_message_id` in the map | Forward every delivery; confirm `OpenPush.messageId(data)` is non-null |
| `preventDefault()` has no effect | The delivery arrived with no visible Activity | Expected — background deliveries always render |
| Identity shows as anonymous in the console | Identity verification is on and the auth hash was missing or wrong | Compute `HMAC-SHA256(external_id)` with an active REST key on your server and pass it as `authHash` |
| Permission prompt never appears | No resumed Activity, or the OS has stopped offering the prompt | Pass an `Activity`, or use `fallbackToSettings = true` |

## FAQ

**Do I need Firebase for the core library?**
No. The core artifact has one runtime dependency and it is not Firebase. Only `openpush-android-fcm` pulls in `firebase-messaging`.

**Can the SDK send a message?**
No. It registers, identifies, tags, and reports receipts. Sending lives behind the REST API key, which does not belong in an app binary.

**Where does the notification actually get drawn?**
In your app. OpenPush sends data-only FCM messages so that channel, colour, style and grouping stay under your control.

**What happens to tags if the FCM token rotates?**
Nothing is lost. The whole tag map rides every registration, and deleted tags keep riding as `""` for the session.

**Which version is current?** `1.1.5`.

## Related

- [quickstart.md](quickstart.md)
- [platform-setup-fcm.md](platform-setup-fcm.md)
- [users-and-subscriptions.md](users-and-subscriptions.md)
- [sending-messages.md](sending-messages.md)
- [events.md](events.md)
- [Migration guide](migrate-from-onesignal.md)
- [in-app-messages.md](in-app-messages.md)
- [../api-handbook/03-subscriptions-users.md](../api-handbook/03-subscriptions-users.md)
- [../api-handbook/06-events-ingest.md](../api-handbook/06-events-ingest.md)
- [../api-handbook/10-in-app-messages.md](../api-handbook/10-in-app-messages.md)
