Skip to main content

Command Palette

Search for a command to run...

Implementing Android 16 Progress-Centric Notifications in React Native (Expo)

Updated
32 min read
Implementing Android 16 Progress-Centric Notifications in React Native (Expo)
A

Software Engineer

With Android 16, Google is introducing Progress-Centric Notifications a new system for building real-time, glanceable updates that stay pinned in the notification shade, on the lock screen, and even in the system status bar. Conceptually, they’re Android’s answer to iOS Live Activities.

Every time you order food or book a ride, you’ve seen this pattern in action: a small car icon moving from the restaurant to your home, colored segments showing how far along the delivery is, and milestone markers for key moments like pickup. Until now, Android developers didn’t have a dedicated system API for this kind of experience, and any similar behavior required extra work outside the standard notification tools.

Android 16 changes that. With the new Notification.ProgressStyle API, rich progress notifications are now a first-class citizen in the Android ecosystem. In this post, I’ll walk through building a complete Expo module that brings this API to React Native from designing the TypeScript interface to implementing the Kotlin side, including a config plugin that handles icon scaling correctly.

Progress-Centric Notifications across the lock screen, status chip, and notification shade on Android 16.

Understanding Notification Anatomy with ProgressStyle

Before writing any code, it helps to understand how a notification is structured and where ProgressStyle fits in. At a high level, it’s made up of a few core components that work together:

  1. Sub Text: Optional secondary text that provides additional context and is shown at the system’s discretion.

  2. When: Notification timestamp.

  3. Content Title: The primary title displayed in the notification.

  4. Content Text: The supporting message shown below the title.

  5. Progress Bar: A customizable progress bar with segments, points, and icons, designed for tracking real-time journeys like deliveries or rides.

  6. Start Icon: An optional icon displayed at the beginning of the progress bar.

  7. Tracker Icon: An optional icon that moves along the bar to indicate the current progress.

  8. End Icon: An optional icon displayed at the end of the progress bar.

  9. Large Icon: An optional, prominent icon shown alongside the notification content.

  10. Action Button: An optional button that lets the user take an action directly from the notification.

The Progress Bar (Notification.ProgressStyle)

The progress bar is where the real work happens. It’s the visual element that shows users exactly where their order, ride, or delivery is in its journey. You can think of it as a visual timeline that tells a story at a glance.

Here’s how it’s put together:

Segments

Segments are the colored “chapters” of the progress bar. They divide the bar into meaningful phases. In a food delivery flow, for example:

  • A cyan segment might represent order confirmed → pickup

  • A gray segment could represent pickup → delivery

Segments use relative lengths. If you define two segments with values of 60 and 40, the first occupies 60% of the bar and the second 40%. This makes it easy to represent different phases of a journey without worrying about exact pixel measurements.

Points

Points are milestone markers shown as dots along the bar. Unlike segments, which fill space, points highlight specific moments in the journey. For a delivery, you might add points at:

  • Position 30: restaurant pickup

  • Position 70: driver arriving at your building

Points use absolute positions on a 0–100 scale. A point at position 30 is always 30% along the bar, regardless of how the segments are defined.

Icons

ProgressStyle supports optional start, tracker, and end icons. The tracker icon moves along the bar based on the current progress value, while the start and end icons remain fixed. Together, they give immediate visual context for where the journey begins, where it is right now, and where it ends.

Progress value

This is a simple number from 0 to 100 that controls where the tracker icon sits on the bar. As the delivery advances, you update this value and the tracker smoothly moves to the new position.

Visual styling

You can customize the colors of both segments and points to match your app’s branding or to signal different states completed in green, in progress in blue, pending in gray.

Together, these pieces create a notification that goes far beyond a basic progress percentage. Instead of just showing progress, it tells a clear visual story of the user’s journey.

Now that we understand how ProgressStyle is structured, we can start building it starting with the Expo module setup and the JavaScript API that exposes this functionality to React Native.

Creating the Expo Module

Setup

Note: ProgressStyle shown only on Android 16+ (API 36). To see the progress bar during development, you’ll need Android Studio (Otter 2 or newer) and an Android 16 emulator or device.

On Android 15 and below, notifications work normally but fall back to a standard layout without the progress bar.

Let’s scaffold the Expo module. Run the following command from your Expo project root:

npx create-expo-module@latest progress-centric-notifications --local

This creates a modules/progress-centric-notifications directory with the standard Expo module structure. Here's the initial layout:

.
└── progress-centric-notifications
    ├── android                                              # Native android code
    │   ├── build.gradle
    │   └── src
    │       └── main
    │           ├── AndroidManifest.xml
    │           └── java
    │               └── expo
    │                   └── modules
    │                       └── progresscentricnotifications
    │                           ├── ProgressCentricNotificationsModule.kt
    │                           └── ProgressCentricNotificationsView.kt
    ├── expo-module.config.json                             # Module metadata
    ├── index.ts                                            # Public API
    ├── ios                                                 # iOS code (we'll delete this)
    │   ├── ProgressCentricNotifications.podspec
    │   ├── ProgressCentricNotificationsModule.swift
    │   └── ProgressCentricNotificationsView.swift
    └── src
        ├── ProgressCentricNotifications.types.ts           # TypeScript interfaces
        ├── ProgressCentricNotificationsModule.ts           # Native module bridge
        ├── ProgressCentricNotificationsModule.web.ts
        ├── ProgressCentricNotificationsView.tsx            # we'll delete this
        └── ProgressCentricNotificationsView.web.tsx        # we'll delete this

Since this module is Android-only, let's clean up the unnecessary files:

rm -rf modules/progress-centric-notifications/ios \
  modules/progress-centric-notifications/src/ProgressCentricNotificationsView.tsx \
  modules/progress-centric-notifications/src/ProgressCentricNotificationsView.web.tsx \
  modules/progress-centric-notifications/src/ProgressCentricNotificationsModule.web.ts \
  modules/progress-centric-notifications/android/src/main/java/expo/modules/progresscentricnotifications/ProgressCentricNotificationsView.kt

Much cleaner. Now let’s configure the module to explicitly declare it as Android-only.

Configuring the Module

Open modules/progress-centric-notifications/expo-module.config.json and update it to specify Android-only support:

{
  "platforms": ["android"],
  "android": {
    "modules": [
      "expo.modules.progresscentricnotifications.ProgressCentricNotificationsModule"
    ]
  }
}

This tells Expo to:

  • Only build the module for Android, skipping iOS entirely

  • Register the Kotlin module class under the correct package name

When you run expo prebuild, Expo will now generate Android-only configuration for this module no iOS setup and no web bundling. That keeps the build clean and avoids errors from missing iOS implementations.

Designing the TypeScript Interface

Before diving into the native implementation, it helps to think through what the JavaScript API should look like when it’s used in a real app.

Here’s the API we’re aiming for two simple functions that handle channel setup and notification display:

import { present, setChannel } from '@/modules/progress-centric-notifications';

// Setup channel once
await setChannel("delivery", { 
  name: "Delivery Updates", 
  importance: "high" 
});

// Show notification
await present("delivery", "order-123", {
  title: "Out for Delivery",
  message: "Your feast is cruising through the streets.",
  smallIcon: "noti_icon",
  largeIcon: "order_enroute",
  progressStyle: {
    progress: 60,
    segments: [
      { length: 60, color: "#00BCD4" },
      { length: 40, color: "#E0E0E0" }
    ],
    trackerIcon: "tracker",
    startIcon: "cafe",
    endIcon: "home"
  },
  isOngoing: true
});

Two-step pattern

setChannel(): Registers a notification channel with Android (required on Android 8+). Channels let users control notification behavior such as sound, vibration, and importance per category in system settings. You typically call this once when your app starts.

present(): Shows or updates a notification on the specified channel. If you use the same notification ID, it updates in place; a different ID creates a new notification. This call is idempotent, so you can invoke it repeatedly as a delivery progresses and the UI updates smoothly.

This approach keeps the API simple while still mapping cleanly to Android’s notification system.

Next, we’ll define the TypeScript types behind this API in modules/progress-centric-notifications/src/ProgressCentricNotifications.types.ts

export { PermissionResponse, PermissionStatus } from "expo-modules-core";

// ProgressStyle configuration
export interface ProgressPoint {
  /** Position on bar (0 to max, default max is 100) */
  position: number;
  /** Hex color string (e.g., "#ECB7FF") */
  color: string;
}

export interface ProgressSegment {
  /** Relative length of this segment */
  length: number;
  /** Hex color string (e.g., "#86F7FA") */
  color: string;
}

export interface ProgressStyleConfig {
  /** Current progress value (0 to max) */
  progress?: number;
  /** Whether progress is indeterminate (unknown duration) */
  isIndeterminate?: boolean;
  /**
   * Whether segments and points are automatically styled based on progress.
   * When true, upcoming segments appear unfilled; when false, all segments look filled.
   * Defaults to true.
   */
  styledByProgress?: boolean;
  /** Drawable resource name for the tracker icon that moves along the bar */
  trackerIcon?: string;
  /** Drawable resource name for the icon at the start of the bar */
  startIcon?: string;
  /** Drawable resource name for the icon at the end of the bar */
  endIcon?: string;
  /** Milestone points on the progress bar */
  points?: ProgressPoint[];
  /** Colored segments of the progress bar */
  segments?: ProgressSegment[];
}

// Notification action button
export interface NotificationAction {
  /** Button title */
  title: string;
  /** URL to open when action is tapped (e.g., "myapp://orders/123", "tel:+1234567890") */
  url: string;
  /** Optional drawable resource name for action icon */
  icon?: string;
}

// Full notification configuration
export interface NotificationConfig {
  // Content
  /** Notification title */
  title: string;
  /** Notification body text */
  message?: string;
  /** Header subtext */
  subText?: string;
  /** Short critical text (shown prominently) */
  shortCriticalText?: string;

  // Time
  /** Timestamp in milliseconds (e.g., Date.now() for now, or future timestamp for ETA) */
  when?: number;
  /** Whether to show the timestamp. Defaults to true if `when` is set. */
  showWhen?: boolean;

  // Icons
  /** Drawable resource name for small icon */
  smallIcon?: string;
  /** Drawable resource name for large icon */
  largeIcon?: string;

  // Progress Style
  /** Progress bar configuration */
  progressStyle?: ProgressStyleConfig;

  // Actions
  /** Action buttons */
  actions?: NotificationAction[];

  // Behavior
  /** Whether notification is ongoing (cannot be dismissed). Defaults to false. */
  isOngoing?: boolean;
  /** Whether to only alert (sound/vibration) once. Subsequent updates won't trigger alerts. Defaults to true. */
  onlyAlertOnce?: boolean;
  /** Whether to auto-dismiss notification when tapped. Defaults to false. */
  autoCancel?: boolean;

  // Deep linking
  /** URL to open when notification is tapped (e.g., "myapp://orders/123"). Opens app if not provided. */
  url?: string;
}

// Channel configuration options
export interface ChannelOptions {
  /** Notification channel name (shown in settings) */
  name: string;
  /** Channel description */
  description?: string;
  /** Channel importance level */
  importance?: "default" | "high" | "low" | "min";
}

Design Notes

  • Icon names are strings, not require() imports. They reference Android drawable resources by name, which keeps the API simple and aligns with how Android handles icons.

  • Most fields are optional, with sensible defaults. A basic notification can be shown with just a title and a progressStyle.progress value.

  • Segment length vs. point position is an intentional distinction. Segments use relative lengths (e.g., [60, 40] takes 60% and 40% of the bar), while points use absolute positions on a 0–100 scale (e.g., 30 is always 30% along). This matches the underlying Android API and allows flexibility in how the progress bar is divided and marked.

Kotlin Data Classes (Records)

Now we can bridge these TypeScript types to Kotlin. Expo Modules provides the Record class, which represents a typed JavaScript object on the native side and handles serialization automatically.

Create the following files in modules/progress-centric-notifications/android/src/main/java/expo/modules/progresscentricnotifications

ChannelOptions.kt

package expo.modules.progresscentricnotifications

import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record
import expo.modules.kotlin.types.Enumerable

/**
 * Channel importance level.
 */
enum class ChannelImportance(val value: String) : Enumerable {
    DEFAULT("default"),
    HIGH("high"),
    LOW("low"),
    MIN("min")
}

/**
 * Channel configuration options.
 */
class ChannelOptions : Record {
    /** Notification channel name (shown in settings) */
    @Field
    val name: String = ""

    /** Channel description */
    @Field
    val description: String? = null

    /** Channel importance level */
    @Field
    val importance: ChannelImportance = ChannelImportance.DEFAULT
}

ProgressSegment.kt:

package expo.modules.progresscentricnotifications

import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record

/**
 * Represents a colored segment of the progress bar.
 */
class ProgressSegment : Record {
    /** Relative length of this segment */
    @Field
    val length: Int = 0

    /** Hex color string */
    @Field
    val color: String = "#FFFFFF"
}

ProgressPoint.kt

package expo.modules.progresscentricnotifications

import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record

/**
 * Represents a milestone point on the progress bar.
 */
class ProgressPoint : Record {
    /** Absolute position on the bar (0–100) */
    @Field
    val position: Int = 0

    /** Hex color string  */
    @Field
    val color: String = "#FFFFFF"
}

ProgressStyleConfig.kt

package expo.modules.progresscentricnotifications

import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record

/**
 * Configuration for the ProgressStyle of a notification.
 */
class ProgressStyleConfig : Record {
    /** Current progress value (0 to max) */
    @Field
    val progress: Int? = null

    /** Whether progress is indeterminate */
    @Field
    val isIndeterminate: Boolean = false

    /** Whether to style the notification based on progress */
    @Field
    val styledByProgress: Boolean = true

    /** Drawable resource name for the tracker icon */
    @Field
    val trackerIcon: String? = null

    /** Drawable resource name for the start icon */
    @Field
    val startIcon: String? = null

    /** Drawable resource name for the end icon */
    @Field
    val endIcon: String? = null

    /** Milestone points on the progress bar */
    @Field
    val points: List<ProgressPoint>? = null

    /** Colored segments of the progress bar */
    @Field
    val segments: List<ProgressSegment>? = null
}

The @Field annotation is required for every property, including primitive types like Int and Boolean. It tells Expo which fields should be populated when deserializing data from JavaScript.

NotificationConfig.kt

package expo.modules.progresscentricnotifications

import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record

/**
 * Notification action button configuration.
 */
class NotificationAction : Record {
    /** Button title */
    @Field
    val title: String = ""

    /** URL to open when action is tapped (e.g., "myapp://orders/123", "tel:+1234567890") */
    @Field
    val url: String = ""

    /** Optional drawable resource name for action icon */
    @Field
    val icon: String? = null
}

/**
 * Full notification configuration.
 */
class NotificationConfig : Record {
    // Content
    /** Notification title */
    @Field
    val title: String = ""

    /** Notification body text */
    @Field
    val message: String? = null

    /** Header subtext */
    @Field
    val subText: String? = null

    /** Short critical text */
    @Field
    val shortCriticalText: String? = null

    // Time
    /** Timestamp in milliseconds (e.g., System.currentTimeMillis() for now, or future time for ETA) */
    @Field
    val `when`: Long? = null

    /** Whether to show the timestamp. Defaults to true if `when` is set. */
    @Field
    val showWhen: Boolean? = null

    // Icons
    /** Drawable resource name for small icon */
    @Field
    val smallIcon: String? = null

    /** Drawable resource name for large icon */
    @Field
    val largeIcon: String? = null

    // Progress Style
    /** Progress bar configuration */
    @Field
    val progressStyle: ProgressStyleConfig? = null

    // Actions
    /** Action buttons */
    @Field
    val actions: List<NotificationAction>? = null

    // Behavior
    /** Whether notification is ongoing (cannot be dismissed). Defaults to false. */
    @Field
    val isOngoing: Boolean = false

    /** Whether to only alert (sound/vibration) once. Subsequent updates won't trigger alerts. Defaults to true. */
    @Field
    val onlyAlertOnce: Boolean = true

    /** Whether to auto-dismiss notification when tapped. Defaults to false. */
    @Field
    val autoCancel: Boolean = false

    // Deep linking
    /** URL to open when notification is tapped (e.g., "myapp://orders/123") */
    @Field
    val url: String? = null
}

Note that when is a Kotlin reserved keyword, so we use backticks: `when` . Expo handles this gracefully when deserializing from JavaScript.

At this point, we’ve defined a typed bridge from JavaScript to Kotlin. Next, we’ll use these records to build the actual Notification.ProgressStyle on Android.

Building the ProgressStyle

Using those records, we can now build the Android Notification.ProgressStyle itself.

In ProgressCentricNotificationsModule.kt

package expo.modules.progresscentricnotifications

import android.app.Notification
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.Icon
import androidx.annotation.RequiresApi
import androidx.core.graphics.toColorInt
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition


class ProgressCentricNotificationsModule : Module() {

    private val context: Context
        get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()

    override fun definition() = ModuleDefinition {
        //....
    }

    @RequiresApi(36)
    private fun buildProgressStyle(config: ProgressStyleConfig): Notification.ProgressStyle {
        val style = Notification.ProgressStyle()

        // Set current progress (clamped to 0–100)
        config.progress?.let { style.setProgress(it.coerceIn(0, 100)) }

        // Indeterminate state (no fixed progress)
        style.isProgressIndeterminate = config.isIndeterminate

        // Let the system style segments based on progress
        style.isStyledByProgress = config.styledByProgress

        // Tracker icon (moves with progress)
        config.trackerIcon?.let { iconName ->
            val resId = getDrawableResourceId(iconName)
            if (resId != 0) {
                style.progressTrackerIcon = Icon.createWithResource(context, resId)
            }
        }

        // Start icon (fixed at beginning)
        config.startIcon?.let { iconName ->
            val resId = getDrawableResourceId(iconName)
            if (resId != 0) {
                style.progressStartIcon = Icon.createWithResource(context, resId)
            }
        }

        // End icon (fixed at destination)
        config.endIcon?.let { iconName ->
            val resId = getDrawableResourceId(iconName)
            if (resId != 0) {
                style.progressEndIcon = Icon.createWithResource(context, resId)
            }
        }

        // Progress segments (relative lengths)
        config.segments?.let { segments ->
            val segmentList = segments.map { segment ->
                Notification.ProgressStyle.Segment(segment.length)
                    .setColor(parseColor(segment.color))
            }
            style.setProgressSegments(segmentList)
        }

        // Progress points (absolute positions)
        config.points?.let { points ->
            val pointList = points.map { point ->
                Notification.ProgressStyle.Point(point.position)
                    .setColor(parseColor(point.color))
            }
            style.setProgressPoints(pointList)
        }

        return style
    }

    private fun getDrawableResourceId(name: String): Int {
        return context.resources.getIdentifier(
            name,
            "drawable",
            context.packageName
        )
    }

    private fun parseColor(hexColor: String): Int {
        return try {
            hexColor.toColorInt()
        } catch (e: Exception) {
            Color.WHITE
        }
    }
}

Notes:

API level guard: @RequiresApi(36) ensures this code only runs on Android 16+.

Icon lookup by name: Drawable resources are resolved at runtime; missing icons fail silently instead of crashing.

Building the Notification

Now we can plug our ProgressStyle into the notification builder. Here’s the relevant part of buildNotification():

import android.app.Notification
import android.graphics.drawable.Icon
import android.os.Build
import android.os.Bundle

private fun buildNotification(channelId: String, notificationId: String, config: NotificationConfig): Notification {
        // Notification.Builder(channelId) is required on Android 8+
        // Older versions fall back to the deprecated constructor
        @Suppress("DEPRECATION")
        val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            Notification.Builder(context, channelId)
        } else {
            Notification.Builder(context)
        }

        // Content
        builder.setContentTitle(config.title)
        config.message?.let { builder.setContentText(it) }
        config.subText?.let { builder.setSubText(it) }

        // Small icon (required for all notifications)
        val smallIconResId = config.smallIcon?.let { getDrawableResourceId(it) } ?: 0
        if (smallIconResId != 0) {
            builder.setSmallIcon(smallIconResId)
        } else {
            // Fallback to app icon
            builder.setSmallIcon(context.applicationInfo.icon)
        }

        // Large icon (optional)
        config.largeIcon?.let { iconName ->
            val resId = getDrawableResourceId(iconName)
            if (resId != 0) {
                builder.setLargeIcon(Icon.createWithResource(context, resId))
            }
        }

        // Time
        config.`when`?.let { builder.setWhen(it) }
        config.showWhen?.let { builder.setShowWhen(it) }

        // Short critical text (Android 16 / API 36+ only)
        if (Build.VERSION.SDK_INT >= 36) {
            config.shortCriticalText?.let {
                builder.setShortCriticalText(it)
            }
        }

        // Behavior flags
        builder.setOngoing(config.isOngoing)
        builder.setOnlyAlertOnce(config.onlyAlertOnce)
        builder.setAutoCancel(config.autoCancel)

        // ProgressStyle and promoted ongoing (Android 16 / API 36+ only)
        if (Build.VERSION.SDK_INT >= 36) {
            config.progressStyle?.let { styleConfig ->
                val progressStyle = buildProgressStyle(styleConfig)
                builder.setStyle(progressStyle)
            }

            // Request promoted ongoing to enable expanded lock screen UI and status chips
            val promotedExtras = Bundle()
            promotedExtras.putBoolean("android.requestPromotedOngoing", true)
            builder.addExtras(promotedExtras)
        }

        // Content intent (notification tap) - opens URL or app
        val contentIntent = createPendingIntent(config.url, notificationId, 0)
        builder.setContentIntent(contentIntent)

        // Action buttons (deep links or external intents)
        config.actions?.forEachIndexed { index, action ->
            if (action.title.isNotBlank() && action.url.isNotBlank()) {
                val actionIntent = createPendingIntent(action.url, notificationId, index + 1)
                val actionBuilder = Notification.Action.Builder(
                    action.icon?.let { iconName ->
                        val resId = getDrawableResourceId(iconName)
                        if (resId != 0) Icon.createWithResource(context, resId) else null
                    },
                    action.title,
                    actionIntent
                )
                builder.addAction(actionBuilder.build())
            }
        }

        return builder.build()
    }

The notification uses a small helper to create PendingIntents for taps and action buttons:

import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import androidx.core.net.toUri    

    /**
     * Creates a PendingIntent that opens a URL or falls back to the app's main activity.
     */
    private fun createPendingIntent(url: String?, notificationId: String, requestCode: Int): PendingIntent {
        val intent = if (!url.isNullOrBlank()) {
            // Open the specified URL (deep link, tel:, mailto:, etc.)
            Intent(Intent.ACTION_VIEW, url.toUri()).apply {
                flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
            }
        } else {
             // Fallback: open the app's main activity
            context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply {
                flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
            } ?: Intent()
        }

        // Attach notification ID for reference
        intent.putExtra("notificationId", notificationId)
        // Use immutable PendingIntent on newer Android versions
        val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
        } else {
            PendingIntent.FLAG_UPDATE_CURRENT
        }

        return PendingIntent.getActivity(
            context,
            notificationId.hashCode() + requestCode,
            intent,
            flags
        )
    }

At this point, we can construct and display a notification with ProgressStyle applied. The last remaining piece is ensuring the notification channel exists before posting it.

Creating the Notification Channel

On Android 8 and above, notifications must be posted to a channel. The setChannel() function exposed on the JavaScript side maps directly to Android’s NotificationChannel API and ensures the channel is created before any notifications are shown.

Channels can be created explicitly via setChannel(), but notifications may still be posted with a channel ID that hasn’t been registered yet. To handle this safely, the module includes a small fallback mechanism that ensures a valid channel always exists.

In ProgressCentricNotificationsModule.kt

package expo.modules.progresscentricnotifications


import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition

private const val FALLBACK_CHANNEL_ID = "progress_notifications_fallback"
private const val FALLBACK_CHANNEL_NAME = "Progress Notifications"

class ProgressCentricNotificationsModule : Module() {

    private val context: Context
        get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()

    // System notification manager
    private val notificationManager: NotificationManager
        get() = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

    override fun definition() = ModuleDefinition {

        Name("ProgressCentricNotifications")

        // Channel setup (creates or updates an Android notification channel)
        AsyncFunction("setChannel") { channelId: String, options: ChannelOptions ->
            if (channelId.isBlank()) {
                throw InvalidConfigurationException("channelId")
            }
            if (options.name.isBlank()) {
                throw InvalidConfigurationException("name")
            }
            createNotificationChannel(channelId, options)
        }

    }


    /**
     * Ensures a valid notification channel exists.
     * Falls back to a default channel if the requested one hasn't been created.
     */
    private fun ensureChannelExists(channelId: String): String {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            // Check if requested channel exists
            if (notificationManager.getNotificationChannel(channelId) != null) {
                return channelId
            }

            // Channel doesn't exist — create fallback
            if (notificationManager.getNotificationChannel(FALLBACK_CHANNEL_ID) == null) {
                val channel = NotificationChannel(
                    FALLBACK_CHANNEL_ID,
                    FALLBACK_CHANNEL_NAME,
                    NotificationManager.IMPORTANCE_HIGH
                )
                notificationManager.createNotificationChannel(channel)
            }
            return FALLBACK_CHANNEL_ID
        }
        return channelId
    }
    /**
     * Creates a notification channel using the provided options.
     */
    private fun createNotificationChannel(channelId: String, options: ChannelOptions) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val importance = when (options.importance) {
                ChannelImportance.HIGH -> NotificationManager.IMPORTANCE_HIGH
                ChannelImportance.LOW -> NotificationManager.IMPORTANCE_LOW
                ChannelImportance.MIN -> NotificationManager.IMPORTANCE_MIN
                else -> NotificationManager.IMPORTANCE_DEFAULT
            }

            val channel = NotificationChannel(
                channelId,
                options.name,
                importance
            ).apply {
                options.description?.let { description = it }
            }

            notificationManager.createNotificationChannel(channel)
        }
    }

}

A small helper exception is used for configuration validation errors:

In ProgressCentricNotificationsExceptions.kt

package expo.modules.progresscentricnotifications

import expo.modules.kotlin.exception.CodedException

class InvalidConfigurationException(field: String) : CodedException(
    "INVALID_CONFIGURATION",
    "$field cannot be empty.",
    null
)

With channels in place, the last remaining step before posting notifications is handling user permission.

Requesting Notification Permissions

Starting with Android 13 (API 33), apps must request permission before showing notifications. This module exposes small helpers that wrap Android’s notification permission APIs and handle older Android versions automatically.

Runtime permission (POST_NOTIFICATIONS)

The module provides requestPermissionsAsync() and getPermissionsAsync() functions that map directly to Android’s notification permission behavior. On Android 13 and above, these request and query the POST_NOTIFICATIONS permission. On older versions, permission is implicitly granted.

package expo.modules.progresscentricnotifications

import android.Manifest
import android.os.Build
import expo.modules.interfaces.permissions.Permissions
import expo.modules.interfaces.permissions.PermissionsResponse
import expo.modules.interfaces.permissions.PermissionsStatus
import expo.modules.kotlin.Promise
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition


class ProgressCentricNotificationsModule : Module() {

    private val permissionsManager: Permissions
        get() = appContext.permissions ?: throw Exceptions.PermissionsModuleNotFound()

    override fun definition() = ModuleDefinition {

        Name("ProgressCentricNotifications")

        AsyncFunction("requestPermissionsAsync") { promise: Promise ->
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                Permissions.askForPermissionsWithPermissionsManager(
                    permissionsManager,
                    promise,
                    Manifest.permission.POST_NOTIFICATIONS
                )
            } else {
                promise.resolve(
                    mapOf(
                        PermissionsResponse.STATUS_KEY to PermissionsStatus.GRANTED.status,
                        PermissionsResponse.GRANTED_KEY to true,
                        PermissionsResponse.CAN_ASK_AGAIN_KEY to true,
                        PermissionsResponse.EXPIRES_KEY to PermissionsResponse.PERMISSION_EXPIRES_NEVER
                    )
                )
            }
        }

        AsyncFunction("getPermissionsAsync") { promise: Promise ->
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                Permissions.getPermissionsWithPermissionsManager(
                    permissionsManager,
                    promise,
                    Manifest.permission.POST_NOTIFICATIONS
                )
            } else {
                promise.resolve(
                    mapOf(
                        PermissionsResponse.STATUS_KEY to PermissionsStatus.GRANTED.status,
                        PermissionsResponse.GRANTED_KEY to true,
                        PermissionsResponse.CAN_ASK_AGAIN_KEY to true,
                        PermissionsResponse.EXPIRES_KEY to PermissionsResponse.PERMISSION_EXPIRES_NEVER
                    )
                )
            }
        }

    }

}

Before posting a notification, the module performs a lightweight permission check to avoid silently failing on Android 13+:

 private fun ensureNotificationPermission() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            val hasPermission = permissionsManager.hasGrantedPermissions(
                Manifest.permission.POST_NOTIFICATIONS
            )
            if (!hasPermission) {
                throw PermissionDeniedException()
            }
        }
    }

This keeps permission handling explicit and surfaces errors clearly to JavaScript.

Promoted notifications permission (manifest-only)

Because this module requests promoted ongoing notifications (used for expanded lock screen views and system status chips), Android also requires declaring the POST_PROMOTED_NOTIFICATIONS permission in the app manifest.

This is a normal permission granted at install time, but users can revoke it later in system settings. Promoted notifications should be reserved for high-value, time-sensitive updates.

Declare both permissions in app.json so they’re added to the Android manifest at build time.

{
  "android": {
    "permissions": [
      "android.permission.POST_NOTIFICATIONS",
      "android.permission.POST_PROMOTED_NOTIFICATIONS"
    ]
  }
}

With permissions handled, the native side is complete. The remaining work is exposing these capabilities as a clean JavaScript API.

Exposing the JavaScript API

Native module functions

At this point, the native building blocks are in place: channels, permissions, and notification construction. The final step on the Android side is exposing a small set of functions that JavaScript can call to interact with the notification system.

These functions live in ProgressCentricNotificationsModule.kt and are registered using Expo Modules AsyncFunction API.

Presenting and updating notifications

The primary entry point is present(). This function is responsible for building a notification and posting it to the system. If a notification with the same ID already exists, it is updated in place rather than creating a new one.


AsyncFunction("present") { channelId: String, notificationId: String, config: NotificationConfig ->
     // Ensure the app has permission to post notifications            
    ensureNotificationPermission()

    // Ensure a valid channel exists and resolve the channel to use
    val actualChannelId = ensureChannelExists(channelId)

    val notification = buildNotification(actualChannelId, notificationId, config)
    // Use tag + fixed ID to avoid hash collisions
    notificationManager.notify(notificationId, 0, notification)
}
  • Permission-first: ensureNotificationPermission() prevents silent failures on Android 13+ by surfacing a clear error when permission hasn’t been granted.

  • Channel safety: ensureChannelExists() guarantees that a valid channel is always used, falling back to a default channel if needed.

  • Stateless updates: Calling present() again with the same notificationId updates the existing notification instead of creating a new one.

This design makes it easy to drive progress updates from JavaScript by repeatedly calling present() as the underlying state changes.

Dismissing notifications

To remove a notification, the module exposes a simple dismiss() and dismissAll() functions:

AsyncFunction("dismiss") { notificationId: String ->
    // Use tag + fixed ID (same as present)
    notificationManager.cancel(notificationId, 0)
}

AsyncFunction("dismissAll") {
    notificationManager.cancelAll()
}

This mirrors Android’s native behavior and allows JavaScript to explicitly remove a notification when a task is complete.

These functions are everything the JavaScript side needs to interact with the native notification system.

JavaScript module wrapper

With the native functions exposed, the next step is wiring them into a JavaScript module that can be used directly from an app.

Platform-specific entry points

The module uses platform-specific files to ensure it’s safe to import in a cross-platform app:

  • ProgressCentricNotificationsModule.android.ts contains the Android implementation.

  • ProgressCentricNotificationsModule.ts acts as a fallback for iOS and web.

This allows the module to be imported unconditionally in a cross-platform app.. On iOS and web, the functions exist but do nothing (or throw clear errors), so the app won’t crash even though ProgressStyle is Android-only.

ProgressCentricNotificationsModule.android.ts

import { requireNativeModule } from "expo";
import { PermissionResponse } from "expo-modules-core";
import type {
  ChannelOptions,
  NotificationConfig,
} from "./ProgressCentricNotifications.types";

declare class ProgressCentricNotificationsModuleType {
  requestPermissionsAsync(): Promise<PermissionResponse>;
  getPermissionsAsync(): Promise<PermissionResponse>;
  setChannel(channelId: string, options: ChannelOptions): Promise<void>;
  present(
    channelId: string,
    notificationId: string,
    config: NotificationConfig,
  ): Promise<void>;
  dismiss(notificationId: string): Promise<void>;
  dismissAll(): Promise<void>;
}

// loads the native module object from the JSI.
export default requireNativeModule<ProgressCentricNotificationsModuleType>(
  "ProgressCentricNotifications",
);

ProgressCentricNotificationsModule.ts

import { PermissionResponse, PermissionStatus } from "expo-modules-core";
import type {
  ChannelOptions,
  NotificationConfig,
} from "./ProgressCentricNotifications.types";

// Stub implementation for non-Android platforms
export default {
  async requestPermissionsAsync(): Promise<PermissionResponse> {
    return {
      status: PermissionStatus.UNDETERMINED,
      expires: "never",
      granted: false,
      canAskAgain: false,
    };
  },
  async getPermissionsAsync(): Promise<PermissionResponse> {
    return {
      status: PermissionStatus.UNDETERMINED,
      expires: "never",
      granted: false,
      canAskAgain: false,
    };
  },
  async setChannel(
    _channelId: string,
    _options: ChannelOptions,
  ): Promise<void> {
    // No-op on non-Android platforms
  },
  async present(
    _channelId: string,
    _notificationId: string,
    _config: NotificationConfig,
  ): Promise<void> {
    // No-op on non-Android platforms
  },
  async dismiss(_notificationId: string): Promise<void> {
    // No-op on non-Android platforms
  },
  async dismissAll(): Promise<void> {
    // No-op on non-Android platforms
  },
};

Module exports

import { createPermissionHook, PermissionResponse } from "expo-modules-core";
import type {
  ChannelOptions,
  NotificationConfig,
} from "./src/ProgressCentricNotifications.types";
import ProgressCentricNotificationsModule from "./src/ProgressCentricNotificationsModule";

// Re-export all types
export * from "./src/ProgressCentricNotifications.types";

// Permission functions
export async function requestPermissionsAsync(): Promise<PermissionResponse> {
  return ProgressCentricNotificationsModule.requestPermissionsAsync();
}

export async function getPermissionsAsync(): Promise<PermissionResponse> {
  return ProgressCentricNotificationsModule.getPermissionsAsync();
}

// React hook for permissions
export const useNotificationPermissions = createPermissionHook({
  requestMethod: requestPermissionsAsync,
  getMethod: getPermissionsAsync,
});

// Channel configuration (stateless - creates in Android, no storage)
export async function setChannel(
  channelId: string,
  options: ChannelOptions,
): Promise<void> {
  return ProgressCentricNotificationsModule.setChannel(channelId, options);
}

/**
 * Present a notification. If a notification with the same ID exists, updates it.
 * @param channelId - The notification channel ID
 * @param notificationId - Unique identifier (e.g., order ID)
 * @param config - Notification content configuration
 */
export async function present(
  channelId: string,
  notificationId: string,
  config: NotificationConfig,
): Promise<void> {
  return ProgressCentricNotificationsModule.present(
    channelId,
    notificationId,
    config,
  );
}

/**
 * Dismiss a notification by its ID.
 * @param notificationId - The notification ID to dismiss
 */
export async function dismiss(notificationId: string): Promise<void> {
  return ProgressCentricNotificationsModule.dismiss(notificationId);
}

/**
 * Dismiss all notifications from this app.
 */
export async function dismissAll(): Promise<void> {
  return ProgressCentricNotificationsModule.dismissAll();
}


export { default } from "./src/ProgressCentricNotificationsModule";

With the JavaScript API in place, we can now focus on icon setup, which is required to show icons correctly in ProgressStyle notifications.

The Icon Pipeline (Config Plugin)

Android notifications require drawable resources at specific DPI scales. If you drop a single PNG into your assets, it may look sharp on one device and noticeably blurry on another.

Android expects notification icons to be provided in five density buckets:

DensityScaleSmall IconLarge IconTracker IconStart/End Icon
mdpi1.0x24px64px40px36px
hdpi1.5x36px96px60px54px
xhdpi2.0x48px128px80px72px
xxhdpi3.0x72px192px120px108px
xxxhdpi4.0x96px256px160px144px

Manually creating five versions of every icon quickly becomes tedious, especially when working with multiple icon types. To avoid that, we’ll automate the process using an Expo config plugin.

Plugin structure

The icon pipeline is implemented as an Expo config plugin that lives inside the module. Expo executes a single JavaScript entry at build time; the remaining files exist to support that entry.

.
└── progress-centric-notifications
    ├── README.md
    ├── android
    ├── app.plugin.js                    # Compiled JS entry used by Expo
    ├── expo-module.config.json
    ├── index.ts
    ├── plugin
    │   ├── src
    │   │   ├── index.ts                 # Plugin entry
    │   │   ├── types.ts                 # Plugin types
    │   │   └── withNotificationIcons.ts # Plugin implementation
    │   └── tsconfig.json
    └── src

The important detail is that app.plugin.js is the build-time entry. This is the file Expo and EAS load during expo prebuild. Everything under plugin/src feeds into that entry.

Plugin Implementation

This is the config plugin that generates Android drawable resources from your source icons at build time. It reads the configured icon folders, creates DPI-scaled variants, and writes them into res/drawable-* so they can be referenced by name from native code.

types.ts

This defines the icon folders passed from app.config.ts into the plugin.

import { ConfigPlugin } from '@expo/config-plugins';

export interface DpiValue {
  folder: string;
  scale: number;
}

export type DpiMap = {
  [key: string]: DpiValue;
};

export interface IconSizeConfig {
  baseSize: number;
  description: string;
}

export interface PluginConfig {
  smallIcons: string;
  largeIcons?: string;
  trackerIcons?: string;
  startIcons?: string;
  endIcons?: string;
}

export type ProgressCentricNotificationsPlugin = ConfigPlugin<PluginConfig>;

withNotificationIcons.ts

/**
 * Icon processing for Android notification icons with DPI scaling.
 *
 * Inspired by expo-notifications icon handling approach:
 * @see https://github.com/expo/expo/blob/main/packages/expo-notifications/plugin/src/withNotificationsAndroid.ts
 * https://github.com/expo/expo/tree/main/packages/%40expo/image-utils
 */

import { ConfigPlugin, withDangerousMod } from "@expo/config-plugins";
import { generateImageAsync } from "@expo/image-utils";
import fs from "node:fs";
import path from "node:path";
import type { DpiMap, IconSizeConfig, PluginConfig } from "./types";

/**
 * Android res path relative to project root
 */
const ANDROID_RES_PATH = "android/app/src/main/res";

/**
 * DPI values for Android drawable folders
 * Based on Android's density buckets
 */
const DPI_VALUES: DpiMap = {
  mdpi: { folder: "drawable-mdpi", scale: 1 },
  hdpi: { folder: "drawable-hdpi", scale: 1.5 },
  xhdpi: { folder: "drawable-xhdpi", scale: 2 },
  xxhdpi: { folder: "drawable-xxhdpi", scale: 3 },
  xxxhdpi: { folder: "drawable-xxxhdpi", scale: 4 },
};

/**
 * Icon size configurations for different icon types
 */
// Android ProgressStyle icon sizes (dp)
// Source images should have NO padding - content must fill entire image
// Official Android docs: start/end = 20dp, tracker = 40x20dp (2:1 ratio)
const ICON_SIZES: Record<string, IconSizeConfig> = {
  small: { baseSize: 24, description: "Small notification icons (status bar)" },
  large: { baseSize: 64, description: "Large notification icons" },
  tracker: { baseSize: 40, description: "Tracker icons (moving indicator)" },
  start: { baseSize: 36, description: "Start/origin icons" },
  end: { baseSize: 36, description: "End/destination icons" },
};

/**
 * Get all PNG files from a directory
 */
async function getPngFiles(folderPath: string): Promise<string[]> {
  if (!fs.existsSync(folderPath)) {
    return [];
  }

  const files = await fs.promises.readdir(folderPath);
  return files.filter((file) => file.toLowerCase().endsWith(".png"));
}

/**
 * Validate that a name is a valid Android resource name.
 * Android resource names must:
 * - Contain only lowercase a-z, 0-9, or underscore
 * - Start with a lowercase letter
 *
 * Throws an error if invalid - does NOT auto-fix.
 */
function validateResourceName(name: string): string {
  if (!name) {
    throw new Error(
      `[progress-centric-notifications] Icon filename cannot be empty`,
    );
  }

  if (!/^[a-z]/.test(name)) {
    throw new Error(
      `[progress-centric-notifications] Invalid icon filename "${name}": must start with a lowercase letter (a-z)`,
    );
  }

  if (!/^[a-z][a-z0-9_]*$/.test(name)) {
    throw new Error(
      `[progress-centric-notifications] Invalid icon filename "${name}": can only contain lowercase a-z, 0-9, and underscore`,
    );
  }

  return name;
}

/**
 * Get the icon name from filename (without extension)
 * Validates that the name is valid for Android resources
 */
function getIconName(filename: string): string {
  const baseName = path.basename(filename, path.extname(filename));
  return validateResourceName(baseName);
}

/**
 * Write a single icon to all drawable DPI folders
 */
async function writeIconToDrawables(
  iconPath: string,
  projectRoot: string,
  iconName: string,
  baseSize: number,
): Promise<void> {
  await Promise.all(
    Object.values(DPI_VALUES).map(async ({ folder, scale }) => {
      const dpiFolderPath = path.resolve(projectRoot, ANDROID_RES_PATH, folder);

      // Create the folder if it doesn't exist
      await fs.promises.mkdir(dpiFolderPath, { recursive: true });

      const iconSize = Math.round(baseSize * scale);

      // Generate resized image using expo's image utils
      const { source } = await generateImageAsync(
        { projectRoot, cacheType: "progress-centric-notification-icons" },
        {
          src: iconPath,
          width: iconSize,
          height: iconSize,
          resizeMode: "cover",
          backgroundColor: "transparent",
        },
      );

      const outputPath = path.resolve(dpiFolderPath, `${iconName}.png`);
      await fs.promises.writeFile(outputPath, source);
    }),
  );
}

/**
 * Process all icons from a folder with the given base size
 */
async function processIconFolder(
  folderPath: string | undefined,
  projectRoot: string,
  iconType: string,
  required: boolean = false,
): Promise<number> {
  if (!folderPath) {
    if (required) {
      throw new Error(
        `[progress-centric-notifications] ${iconType} icons folder is required but not provided`,
      );
    }
    return 0;
  }

  const absoluteFolderPath = path.resolve(projectRoot, folderPath);

  if (!fs.existsSync(absoluteFolderPath)) {
    if (required) {
      throw new Error(
        `[progress-centric-notifications] ${iconType} icons folder not found: ${absoluteFolderPath}`,
      );
    }
    return 0;
  }

  const pngFiles = await getPngFiles(absoluteFolderPath);

  if (pngFiles.length === 0) {
    if (required) {
      throw new Error(
        `[progress-centric-notifications] No PNG files found in ${iconType} icons folder: ${absoluteFolderPath}`,
      );
    }
    return 0;
  }

  const sizeConfig = ICON_SIZES[iconType];

  for (const filename of pngFiles) {
    const iconPath = path.join(absoluteFolderPath, filename);
    const iconName = getIconName(filename);

    await writeIconToDrawables(
      iconPath,
      projectRoot,
      iconName,
      sizeConfig.baseSize,
    );
  }

  return pngFiles.length;
}

/**
 * Main plugin function that processes all icon folders
 */
export const withNotificationIcons: ConfigPlugin<PluginConfig> = (
  config,
  pluginConfig,
) => {
  return withDangerousMod(config, [
    "android",
    async (config) => {
      const projectRoot = config.modRequest.projectRoot;

      let totalIcons = 0;

      // Process small icons (REQUIRED)
      totalIcons += await processIconFolder(
        pluginConfig.smallIcons,
        projectRoot,
        "small",
        true, // required
      );

      // Process optional icon folders
      totalIcons += await processIconFolder(
        pluginConfig.largeIcons,
        projectRoot,
        "large",
        false,
      );

      totalIcons += await processIconFolder(
        pluginConfig.trackerIcons,
        projectRoot,
        "tracker",
        false,
      );

      totalIcons += await processIconFolder(
        pluginConfig.startIcons,
        projectRoot,
        "start",
        false,
      );

      totalIcons += await processIconFolder(
        pluginConfig.endIcons,
        projectRoot,
        "end",
        false,
      );

      console.log("\n========================================");
      console.log(
        `[progress-centric-notifications] Completed! Processed ${totalIcons} icon(s)`,
      );
      console.log("========================================\n");

      return config;
    },
  ]);
};

How It Works:

  1. Reads source icons from the folders you specify

  2. Validates names — Android requires lowercase, starting with a letter

  3. Generates 5 sizes for each icon using @expo/image-utils

  4. Writes to res/drawable-{dpi}/ so Android can pick the right one

index.ts

import type { ProgressCentricNotificationsPlugin } from './types';
import { withNotificationIcons } from './withNotificationIcons';

const withProgressCentricNotifications: ProgressCentricNotificationsPlugin = (
  config,
  pluginConfig
) => {
  // Validate required config
  if (!pluginConfig) {
    throw new Error(
      '[progress-centric-notifications] Plugin config is required. ' +
        'Please provide at least "smallIcons" folder path.'
    );
  }

  if (!pluginConfig.smallIcons) {
    throw new Error(
      '[progress-centric-notifications] "smallIcons" is required. ' +
        'Please provide a path to a folder containing small notification icons.'
    );
  }

  // Apply the notification icons plugin
  config = withNotificationIcons(config, pluginConfig);

  return config;
};

export default withProgressCentricNotifications;

app.plugin.js

This file is what Expo executes at build time; it points to the compiled plugin output.

module.exports = require("./plugin/build/index.js");

Using the plugin with app.config.ts

At this point, we need to make one small change to the app setup.

The icon pipeline is implemented as a config plugin that runs at build time and accepts parameters (icon folder paths). Because this code is executed by Node during expo prebuild, the app configuration needs to be able to execute JavaScript. A static app.json can’t do that.

For this reason, we switch to a dynamic app config: app.config.ts.

This allows Expo to evaluate the plugin, pass in the icon paths, and generate the Android drawable resources before the native project is compiled.

If your project currently uses app.json, converting it is straightforward: rename it to app.config.ts and export the same configuration.

Enabling TypeScript evaluation

Because the app config itself is written in TypeScript, Node needs to understand how to execute it. Install tsx as a dev dependency:

pnpm add -D tsx

Then add a single import at the top of your config file:

import "tsx/cjs";

Registering the plugin

Here’s a minimal example of app.config.ts with the icon plugin enabled:

import type { ConfigContext, ExpoConfig } from "expo/config";

import "tsx/cjs";

export default ({ config }: ConfigContext): ExpoConfig => ({
  ...config,
  name: "Orderly",
  slug: "orderly",
  version: "1.0.0",
  ...
  ..
  ios: {
    ...
    ..
  },
  android: {
    ...
    ..
  },
  web: {
    ...
    ..
  },
  plugins: [
    [
      "./modules/progress-centric-notifications/plugin/src/index.ts",
      {
        smallIcons: "./assets/notification-icons/small/",
        largeIcons: "./assets/notification-icons/large/",
        trackerIcons: "./assets/notification-icons/tracker/",
        startIcons: "./assets/notification-icons/start/",
        endIcons: "./assets/notification-icons/end/",
      },
    ],
  ],
});

That’s all that’s required. When expo prebuild runs (locally or on EAS), Expo evaluates this config, executes the plugin, and generates all required Android drawable resources automatically.

Using ProgressStyle Notifications in an App

Now that the native module, JavaScript wrapper, and icon pipeline are in place, let’s look at how this is used from an app.

This example models a real delivery flow: a notification is created once and then updated in place as the order progresses. The app code only interacts with the JavaScript API; everything else (ProgressStyle rendering, icon resolution, Android version handling) happens natively.

💡
This is a simplified demo. In a real app, these updates would typically come from your backend via push notifications or background work, not from UI button presses.

Request permission and create a channel

import {
  requestPermissionsAsync,
  setChannel,
} from "@/modules/progress-centric-notifications";

await requestPermissionsAsync();

await setChannel("delivery", {
  name: "Delivery Updates",
  description: "Live order tracking",
  importance: "high",
});

This is usually done once at app startup. Channels are required on Android 8+, and notification permission is required on Android 13+.

Show the initial notification

import { present } from "@/modules/progress-centric-notifications";

await present("delivery", "order-123", {
  title: "Order confirmed",
  message: "Preparing your food",
  smallIcon: "noti_icon",
  isOngoing: true,
  progressStyle: {
    progress: 10,
    segments: [
      { length: 10, color: "#00BCD4" },
      { length: 90, color: "#E0E0E0" },
    ],
    startIcon: "cafe",
    endIcon: "home",
  },
});

Update progress

await present("delivery", "order-123", {
  title: "Out for delivery",
  shortCriticalText: "On the way",
  progressStyle: {
    progress: 60,
    trackerIcon: "tracker",
    segments: [
      { length: 60, color: "#00BCD4" },
      { length: 40, color: "#E0E0E0" },
    ],
  },
});

Because the notification ID stays the same, Android updates the existing notification instead of creating a new one. There’s no flicker, no repeated sound, and no new entry in the notification shade.

This is the key pattern behind smooth, real-time progress notifications.

Notifications can also carry a deep link that opens a specific screen in your app when the user taps the notification or an action button.

await present("delivery", "order-123", {
  title: "Out for delivery",
  message: "Your order is on the way",
  url: "androidprogresscentricnotificationsexpo://orders/order-123",

  progressStyle: {
    progress: 60,
    trackerIcon: "tracker",
  },

  actions: [
    {
      title: "Track",
      url: "androidprogresscentricnotificationsexpo://orders/order-123/track",
    },
    {
      title: "Call",
      url: "tel:+1234567890",
    },
  ],
});

Tapping the notification opens the order screen, while action buttons can deep link to other app routes or trigger system intents (such as a phone call).

That’s all that’s required. How the app handles these URLs depends on your routing setup (for example, Expo Router).

Dismiss Notification

import { dismiss } from "@/modules/progress-centric-notifications";

await dismiss("order-123");

This allows the notification to be dismissed programmatically.

How this works in real apps

In this demo, notification updates are triggered from UI actions for clarity. In production apps, the same present() calls are typically driven by:

  • Push notifications (FCM)

  • Background workers

  • Server-side state changes

  • Foreground services (when appropriate)

Because notifications are owned by the system, they continue to update even if the app is backgrounded or killed, as long as updates are delivered (for example, via push).

The JavaScript API stays the same only the trigger changes.

Conclusion

Android 16’s ProgressStyle finally gives developers a first-class way to build rich, persistent progress notifications. With Expo Modules, it’s possible to expose this API cleanly to React Native without compromising on native behavior or developer experience.

From the app’s point of view, updating a live notification comes down to making repeated present() calls with the same ID. Everything else Android version checks, icon resolution, ProgressStyle construction, and system integration is handled on the native side.

The result is a simple JavaScript API on top of a powerful new Android capability.

Full source code: GitHub