Waking Up the Dead

 

Waking Up the Dead: VoIP-Style Calling in React Native with Expo, FCM & Notifee

Build a notification style incoming-call experience that wakes a locked Android device, rings continuously, and works even when your app has been terminated.

Introduction

If you've ever built a real-time alert application—whether it's a smart doorbell, home security system, emergency notification service, or ride-sharing platform—you've probably discovered that standard push notifications aren't enough.

A typical notification banner can be missed, dismissed, buried beneath other alerts, or silently delayed by the operating system. When your use case requires immediate user attention, you need something closer to a phone call than a notification.

What you actually want is a VoIP-style incoming call experience:

  • The screen wakes up.

  • The device rings continuously.

  • The notification cannot be accidentally dismissed.

  • Everything works even if the app has been completely killed.

Achieving this in an Expo Router application with Firebase Cloud Messaging (FCM) isn't straightforward. Android background execution, Headless JS, lock screen restrictions, and notification permissions all introduce complications.

After spending some time fighting these systems, here are the lessons that finally produced a reliable solution.


Architecture Overview

The overall flow looks like this:


The key idea is that FCM wakes your JavaScript background handler, which then uses Notifee to create a call-style notification capable of waking the device and launching your application.


Lesson 1: The Headless JS Trap

The Problem

One of the first mistakes many developers make is registering Firebase background handlers inside React components.

For example:

  • Inside _layout.tsx

  • Inside useEffect

  • Inside providers or context components

This works while the application is running.

It completely fails when the application has been terminated.

When Android receives a background FCM message, it starts a Headless JS process. Expo Router's React tree is never mounted in that process, meaning your background handler doesn't exist.

As a result, the message arrives but nothing happens.

The Solution

Register your background handler in a custom root index.js file.

Instead of relying on Expo Router's default entry point, create your own entry file and make it the application's main entry.

This guarantees the handler is registered before React starts rendering and ensures Android can execute it even when the application isn't running.

The important rule:

Background handlers belong at the module level, not inside React components.

Once moved into index.js, Android can reliably invoke the handler from a terminated application state.

Why This Works

Headless JS executes JavaScript without rendering React.

Since index.js loads before React initializes, Android always has access to the background handler regardless of application state.

This single change is often the difference between a working and non-working implementation.


Lesson 2: Data-Only FCM Payloads

The Problem

A surprisingly common issue comes from the structure of the FCM payload itself.

Many developers send messages like this:

  • notification.title

  • notification.body

  • data

This seems harmless.

Unfortunately, Android sees the notification block and decides to display the notification itself.

When that happens:

  • Your JavaScript background handler doesn't run immediately.

  • Android creates its own notification.

  • Your custom logic is delayed until the user taps the notification.

For a doorbell, security alert, or incoming call experience, that's unacceptable.

The Solution

Send data-only payloads.

Remove the entire notification section and place everything inside data.

Your payload should contain only custom fields such as:

  • type

  • visitor_name

  • title

  • body

In addition, set Android delivery priority to "high".

This tells Firebase and Android that the message should be delivered immediately and is important enough to wake the device.

Important Distinction

Many developers confuse these two settings:

FCM Delivery Priority

Controls how urgently Android delivers the message.

Example:

priority: "high"

Notification Importance

Controls how aggressively Android displays the notification.

Example:

AndroidImportance.HIGH

Both are required.

High delivery priority gets your code running.

High notification importance gets the user's attention.


Lesson 3: Waking the Locked Screen

The Problem

Even after your background handler runs successfully, Android still prevents applications from freely appearing above the lock screen.

Without additional configuration:

  • The screen stays off.

  • Your activity remains hidden.

  • Full-screen notifications may never appear.

Editing native Android files manually seems tempting, but Expo regenerates those files whenever you run a prebuild.

Any manual changes eventually disappear.

The Solution

Use an Expo Config Plugin.

A custom plugin can inject the required Android manifest attributes automatically during prebuild.

The critical activity attributes are:

  • android:showWhenLocked="true"

  • android:turnScreenOn="true"

These tell Android that your activity is allowed to appear on top of the lock screen and wake the display when launched.

Required Permissions

You should also declare:

  • android.permission.USE_FULL_SCREEN_INTENT

  • android.permission.VIBRATE

These permissions are necessary for a proper call-style experience.

Android Version Considerations

Recent Android versions are increasingly restrictive.

On newer devices, full-screen intents may require additional user approval depending on OS version and manufacturer customizations.

Always test on real devices rather than relying solely on emulators.


Lesson 4: Creating a Real VoIP Experience with Notifee

The Problem

Expo Notifications is excellent for general notification use cases.

It is not designed to replicate an incoming phone call.

Missing capabilities include:

  • Continuous ringing

  • Call category notifications

  • Full-screen intents

  • Ongoing non-dismissible notifications

Without these features, your alert behaves like a normal notification instead of a call.

The Solution

Use Notifee.

Notifee exposes Android's advanced notification APIs and provides everything needed for a VoIP-style experience.

loopSound

When enabled, Android continuously repeats the notification sound.

Instead of a single alert tone, the device behaves like an incoming phone call.

ongoing

Prevents the notification from being swiped away.

The alert remains active until your application explicitly cancels it.

AndroidCategory.CALL

Marks the notification as a call-related event.

This significantly improves Android's willingness to display heads-up and full-screen UI.

fullScreenAction

Launches your activity immediately when the notification arrives.

Combined with lock-screen permissions, this creates the incoming-call effect users expect.


Lesson 5: Building the Incoming Call Screen

Once the notification launches your application, the final step is presenting a convincing call interface.

A simple strategy works well:

  1. Detect whether the application was launched from a notification.

  2. Extract notification data.

  3. Display a full-screen overlay.

  4. Start visual animations.

  5. Stop ringing when the user accepts or declines.

Detecting Notification Launches

When the application starts, check for an initial notification.

If the notification contains your custom call data:

  • Open the call screen.

  • Populate visitor information.

  • Cancel the persistent notification.

Failing to cancel the notification often results in continuous ringing even after the UI appears.

Creating the Call Animation

A subtle pulsing animation dramatically improves the experience.

A looping scale animation on an avatar or icon provides visual feedback that the incoming call is still active.

This small detail makes the interface feel much more polished and familiar.

Designing the Overlay

A typical incoming call screen includes:

  • Caller or visitor name

  • Large central avatar

  • Pulsing animation

  • Accept button

  • Decline button

The layout should occupy the full screen and remain visually simple.

The goal isn't complexity.

The goal is immediacy.

Users should instantly understand that someone is trying to reach them.


Key Takeaways

1. Register Background Handlers in index.js

Never place Firebase background handlers inside React components or Expo Router layouts.

Headless JS cannot access them there.

2. Use Data-Only FCM Messages

Remove the notification block entirely.

Let your JavaScript code decide how notifications are displayed.

3. Configure Lock-Screen Behavior Through Expo Config Plugins

Use config plugins to inject Android activity attributes during prebuild rather than editing generated files manually.

4. Use Notifee Instead of Expo Notifications

For VoIP-style experiences, Notifee provides the Android APIs that Expo Notifications does not expose.

5. Cancel Notifications After Launch

Once your incoming call screen is displayed, cancel the persistent notification immediately to stop ringing and avoid duplicate UI.


Final Thoughts

The combination of Expo Router, Firebase Cloud Messaging, and Notifee is powerful enough to create a genuine incoming-call experience on Android.

The biggest challenge isn't writing the notification itself. It's understanding how Android behaves when your application isn't running.

Once you solve the three major pieces—

  • Headless JS execution

  • Data-only FCM delivery

  • Full-screen notification permissions

—you can build experiences that feel much closer to a native phone call than a traditional push notification.

For doorbells, security systems, dispatch applications, emergency alerts, and real-time communication tools, that difference matters.

Comments

Popular posts from this blog

Serve Customers Faster with a Smart QR Menu System for Your Restaurant