Skip to main content

Using PushEngage Android SDK with Jetpack Compose

PushEngage Android SDK works with Jetpack Compose out of the box. This guide covers initialization, notification permissions, subscriber management, deep linking, and best practices for Compose-based apps.

Compose Compatibility

PushEngage Android SDK uses ComponentActivity as its base parameter type for activity-dependent methods. Since Compose activities extend ComponentActivity, the SDK works natively with Compose — no adapters or wrappers needed.

SDK MethodParameter TypeCompose Compatible
requestNotificationPermission()ComponentActivityYes
subscribe()No activity needed (or ComponentActivity with callback)Yes
All other methodsStatic (no activity needed)Yes

Setup

1. Add Dependencies

In your app-level build.gradle.kts:

plugins {
id("com.android.application")
id("com.google.gms.google-services")
id("org.jetbrains.kotlin.android")
}

dependencies {
// PushEngage SDK
implementation("com.github.awesomemotive:pushengage-android-sdk:<latest-version>")
implementation(platform("com.google.firebase:firebase-bom:<latest-version>"))

// Jetpack Compose (your existing Compose dependencies)
implementation("androidx.activity:activity-compose:<latest-version>")
implementation("androidx.compose.material3:material3:<latest-version>")
implementation("androidx.navigation:navigation-compose:<latest-version>")
}

In your settings.gradle.kts:

dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url = uri("https://jitpack.io") }
}
}

2. Initialize in Application Class

Initialize PushEngage in your Application class — this is the same regardless of whether you use Compose or traditional Views.

import android.app.Application
import com.pushengage.pushengage.PushEngage

class MyApp : Application() {
override fun onCreate() {
super.onCreate()

PushEngage.Builder()
.addContext(this)
.setAppId("YOUR_APP_ID")
.build()

// Optional: enable logging during development
PushEngage.enableLogging(true)
}
}

3. Set Up Your Compose Activity

Use ComponentActivity (or AppCompatActivity — both work) as your main activity:

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
AppContent()
}
}
}
}

Notification Permissions (Android 13+)

Starting with Android 13 (API 33), apps must request the POST_NOTIFICATIONS runtime permission. PushEngage SDK handles this for you — just pass your Compose activity.

The simplest approach. PushEngage handles the permission dialog and automatically subscribes the user when granted.

import androidx.activity.ComponentActivity
import androidx.compose.runtime.*
import androidx.compose.material3.*
import androidx.compose.ui.platform.LocalContext
import com.pushengage.pushengage.PushEngage
import com.pushengage.pushengage.Callbacks.PushEngagePermissionCallback

@Composable
fun NotificationPermissionScreen() {
val activity = LocalContext.current as ComponentActivity
var permissionGranted by remember { mutableStateOf(false) }
var permissionRequested by remember { mutableStateOf(false) }

if (!permissionRequested) {
Button(onClick = {
permissionRequested = true
PushEngage.requestNotificationPermission(activity,
object : PushEngagePermissionCallback {
override fun onPermissionResult(granted: Boolean, error: Error?) {
permissionGranted = granted
// SDK automatically calls subscribe() when granted
}
}
)
}) {
Text("Enable Push Notifications")
}
} else if (permissionGranted) {
Text("Notifications enabled!")
}
}
How it works under the hood

The SDK uses one of two mechanisms depending on your activity type:

  • Plain ComponentActivity (the default for Compose apps): the SDK launches a transparent helper activity to drive the system permission dialog.
  • FragmentActivity subclass (e.g., AppCompatActivity): the SDK attaches a headless Fragment to your activity instead.

Either way, the user sees only the standard Android permission prompt — the mechanism is invisible.

Option B: Handle Permission Yourself, Then Subscribe

If you want more control over the permission UX — for example, showing a rationale screen before the system dialog — handle the permission in Compose and then call subscribe().

import android.Manifest
import android.os.Build
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.*
import androidx.compose.material3.*
import androidx.compose.ui.platform.LocalContext
import com.pushengage.pushengage.PushEngage
import com.pushengage.pushengage.Callbacks.PushEngageResponseCallback

@Composable
fun CustomPermissionFlow() {
val activity = LocalContext.current as ComponentActivity
var showRationale by remember { mutableStateOf(true) }

fun subscribeToPushEngage() {
PushEngage.subscribe(activity, object : PushEngageResponseCallback {
override fun onSuccess(responseObject: Any?) {
// Subscription successful
}
override fun onFailure(errorCode: Int?, errorMessage: String?) {
// Handle subscription failure
}
})
}

val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
// Permission granted — now subscribe with PushEngage
subscribeToPushEngage()
}
}

if (showRationale) {
// Your custom rationale screen
AlertDialog(
onDismissRequest = { showRationale = false },
title = { Text("Stay in the loop") },
text = { Text("Enable notifications to get updates on your orders, exclusive deals, and important alerts.") },
confirmButton = {
Button(onClick = {
showRationale = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
// Pre-Android 13: permission granted by default
subscribeToPushEngage()
}
}) {
Text("Enable")
}
},
dismissButton = {
TextButton(onClick = { showRationale = false }) {
Text("Not now")
}
}
)
}
}

Checking Permission Status

getNotificationPermissionStatus() is synchronous, so it is safe to call directly inside remember. Note that this is a point-in-time snapshot — it reads the current OS state once at composition time and does not update if the user later changes notification settings in System Settings.

@Composable
fun NotificationPermissionGate() {
// Reads permission status once at composition time.
// Re-read on resume if you need up-to-date state after returning from Settings.
val status = remember { PushEngage.getNotificationPermissionStatus() }

when (status) {
"granted" -> Text("Notifications are enabled")
"denied" -> Text("Notifications are disabled. Enable them in Settings.")
}
}
note

getNotificationPermissionStatus() returns only "granted" or "denied". There is no "not yet requested" state — on Android 12 and below, it always returns "granted" since the permission is granted at install time.

Subscriber Management in Compose

All subscriber methods are static and don't require an Activity reference, so they work directly from any Composable.

Get Subscriber ID

PushEngage callbacks fire on a background thread. Use suspendCancellableCoroutine to bridge the callback back into the LaunchedEffect coroutine, which already runs on Dispatchers.Main. This ensures the state update happens on the main thread without any manual dispatcher switching.

import com.pushengage.pushengage.PushEngage
import com.pushengage.pushengage.Callbacks.PushEngageResponseCallback
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume

@Composable
fun SubscriberInfo() {
var subscriberId by remember { mutableStateOf<String?>(null) }

LaunchedEffect(Unit) {
// suspendCancellableCoroutine bridges the callback back into this coroutine.
// The resume runs on whatever thread the callback fires on, but the
// coroutine resumes on Dispatchers.Main (LaunchedEffect's default).
subscriberId = suspendCancellableCoroutine { cont ->
PushEngage.getSubscriberId(object : PushEngageResponseCallback {
override fun onSuccess(responseObject: Any?) {
cont.resume(responseObject as? String)
}
override fun onFailure(errorCode: Int?, errorMessage: String?) {
cont.resume(null)
}
})
}
}

subscriberId?.let { id ->
Text("Subscriber: $id")
}
}

Add Segments

import com.pushengage.pushengage.PushEngage
import com.pushengage.pushengage.Callbacks.PushEngageResponseCallback

fun addUserToSegment(segmentName: String) {
PushEngage.addSegment(listOf(segmentName),
object : PushEngageResponseCallback {
override fun onSuccess(responseObject: Any?) {
// Segment added
}
override fun onFailure(errorCode: Int?, errorMessage: String?) {
// Handle error
}
}
)
}

Add Subscriber Attributes

Use addSubscriberAttributes to merge new attributes with existing ones, or setSubscriberAttributes to replace all attributes entirely.

import org.json.JSONObject
import com.pushengage.pushengage.PushEngage
import com.pushengage.pushengage.Callbacks.PushEngageResponseCallback

fun updateUserAttributes(name: String, plan: String) {
val attributes = JSONObject().apply {
put("name", name)
put("plan", plan)
}
PushEngage.addSubscriberAttributes(attributes,
object : PushEngageResponseCallback {
override fun onSuccess(responseObject: Any?) {
// Attributes updated
}
override fun onFailure(errorCode: Int?, errorMessage: String?) {
// Handle error
}
}
)
}

Deep Linking with Compose Navigation

When a user taps a PushEngage notification, the SDK opens your app via an Intent. In a Compose app, you need to bridge Intent data to Compose Navigation.

import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navDeepLink

@Composable
fun AppNavigation() {
val navController = rememberNavController()

NavHost(navController = navController, startDestination = "home") {
composable("home") {
HomeScreen(navController)
}

composable(
route = "product/{productId}",
deepLinks = listOf(
navDeepLink { uriPattern = "myapp://product/{productId}" }
)
) { backStackEntry ->
val productId = backStackEntry.arguments?.getString("productId")
ProductScreen(productId = productId)
}

composable(
route = "orders",
deepLinks = listOf(
navDeepLink { uriPattern = "myapp://orders" }
)
) {
OrdersScreen()
}
}
}

2. Configure AndroidManifest.xml

<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>

<!-- Deep link intent filter for PushEngage notifications -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" />
</intent-filter>
</activity>

3. Handle Intent Data in Your Activity

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

setContent {
MaterialTheme {
AppNavigation()
}
}
}
}

When a notification with deep link URL myapp://product/12345 is tapped, Compose Navigation automatically routes to the ProductScreen composable with productId = "12345".

In the PushEngage dashboard, when creating a notification campaign:

  • Set the Deep Link field to your deep link URI (e.g., myapp://product/12345)
  • The SDK will open this URL when the notification is tapped
  • Compose Navigation picks it up via the intent filter and routes accordingly

Goal & Event Tracking

Track conversion events from anywhere in your Compose UI:

import com.pushengage.pushengage.PushEngage
import com.pushengage.pushengage.model.request.Goal
import com.pushengage.pushengage.model.request.TriggerCampaign

// Track a purchase goal
fun trackPurchase(amount: Double) {
val goal = Goal(
name = "purchase_complete",
count = 1,
value = amount
)
PushEngage.sendGoal(goal)
}

// Send a trigger event
fun sendCartAbandonTrigger(cartValue: String) {
val trigger = TriggerCampaign(
campaignName = "cart_abandon",
eventName = "cart_abandoned",
data = mapOf("cart_value" to cartValue)
)
PushEngage.sendTriggerEvent(trigger)
}

Use these in a Composable:

@Composable
fun CheckoutScreen(cartTotal: Double) {
Button(onClick = {
// Process payment...
trackPurchase(cartTotal)
}) {
Text("Complete Purchase")
}
}

Price Drop & Inventory Alerts

For e-commerce apps, trigger price drop and inventory alerts:

import com.pushengage.pushengage.PushEngage
import com.pushengage.pushengage.model.request.TriggerAlert

fun addPriceDropAlert(productId: String, price: Double) {
val alert = TriggerAlert(
type = PushEngage.TriggerAlertType.priceDrop,
productId = productId,
link = "myapp://product/$productId",
price = price
)
PushEngage.addAlert(alert)
}

fun addInventoryAlert(productId: String) {
val alert = TriggerAlert(
type = PushEngage.TriggerAlertType.inventory,
productId = productId,
link = "myapp://product/$productId",
price = 0.0,
availability = PushEngage.TriggerAlertAvailabilityType.outOfStock
)
PushEngage.addAlert(alert)
}

Best Practices

Do

  • Initialize in Application class, not in your Compose Activity. The SDK should be ready before any UI renders.
  • Request permission after value demonstration. Show the user what notifications will help with before asking. Don't prompt on the very first screen.
  • Use LaunchedEffect for one-time SDK calls (like getting subscriber ID) to avoid re-calling on recomposition.
  • Handle the Android version check — always check Build.VERSION.SDK_INT >= TIRAMISU before requesting POST_NOTIFICATIONS.
  • Use deep link URIs that map cleanly to your Compose Navigation routes.

Don't

  • Don't call async SDK methods (those with callbacks) inside remember blocks — use LaunchedEffect for one-time async calls and event handlers (like onClick) for user-triggered actions. Synchronous methods like getNotificationPermissionStatus() are fine inside remember.
  • Don't block the UI thread with SDK callbacks — all PushEngage callbacks already run asynchronously.
  • Don't request permission in LaunchedEffect — permission requests must be triggered by user action (button tap), not automatically on composition.
  • Don't forget to add POST_NOTIFICATIONS to AndroidManifest.xml — Android 13+ silently denies the runtime permission request if this declaration is missing, regardless of what the SDK does.

Migration Notes

Coming from XML-based Activities

If you're converting an existing PushEngage integration from XML Views to Compose:

  1. No SDK changes needed — the same PushEngage API works in both
  2. Replace AppCompatActivity with ComponentActivity (or keep AppCompatActivity — both work)
  3. Move permission request logic into Composable event handlers
  4. Replace Intent-based navigation with Compose Navigation deep links
  5. Static SDK methods (subscribe, addSegment, etc.) work identically

Coming from OneSignal

If migrating from OneSignal to PushEngage in a Compose app:

  1. Replace OneSignal.initWithContext(this) with PushEngage.Builder().addContext(this).setAppId("...").build()
  2. Replace OneSignal.promptForPushNotifications() with PushEngage.requestNotificationPermission(activity, callback)
  3. Replace OneSignal's setNotificationOpenedHandler with PushEngage deep links via Compose Navigation
  4. OneSignal's tag system maps to PushEngage segments and attributes

Next Steps