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

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:

Sub Text: Optional secondary text that provides additional context and is shown at the system’s discretion.
When: Notification timestamp.
Content Title: The primary title displayed in the notification.
Content Text: The supporting message shown below the title.
Progress Bar: A customizable progress bar with segments, points, and icons, designed for tracking real-time journeys like deliveries or rides.
Start Icon: An optional icon displayed at the beginning of the progress bar.
Tracker Icon: An optional icon that moves along the bar to indicate the current progress.
End Icon: An optional icon displayed at the end of the progress bar.
Large Icon: An optional, prominent icon shown alongside the notification content.
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
titleand aprogressStyle.progressvalue.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.,30is 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
whenis 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:
| Density | Scale | Small Icon | Large Icon | Tracker Icon | Start/End Icon |
| mdpi | 1.0x | 24px | 64px | 40px | 36px |
| hdpi | 1.5x | 36px | 96px | 60px | 54px |
| xhdpi | 2.0x | 48px | 128px | 80px | 72px |
| xxhdpi | 3.0x | 72px | 192px | 120px | 108px |
| xxxhdpi | 4.0x | 96px | 256px | 160px | 144px |
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:
Reads source icons from the folders you specify
Validates names — Android requires lowercase, starting with a letter
Generates 5 sizes for each icon using
@expo/image-utilsWrites 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.
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.
Deep links and action buttons
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



