Skip to main content

Push Notifications System (Mobile App)

Overview

The ZÈYA mobile app uses Expo Push Notifications to receive real-time notifications on both iOS and Android devices. The system handles permission requests, token registration, notification display, and user interactions.

Architecture

Technology Stack

  • Framework: React Native with Expo
  • Notifications: expo-notifications
  • Device Info: expo-device
  • Navigation: expo-router
  • Badge Management: Firebase Realtime Database
  • Storage: Expo SecureStore

Key Components

┌─────────────────────────────────────────────────────────┐
│ Mobile App Notification Flow │
└─────────────────────────────────────────────────────────┘

1. App Launch

2. Request Notification Permissions

3. Get Expo Push Token

4. Register Token with Backend API

5. Set up Notification Listeners

6. Receive Push Notification from Expo

7. Display Notification (OS handles)

8. User Taps Notification (optional)

9. App Opens, Navigate to Content

10. Mark as Read via API

Implementation

Main Hook: usePushNotifications

Located at: src/share/useNotifications.js

This custom React hook manages the entire notification lifecycle.

Usage

import { usePushNotifications } from '../../share/useNotifications';

function MyComponent() {
const { expoPushToken, notification, response, badgeCount } = usePushNotifications();

return (
<View>
<Text>Token: {expoPushToken?.data}</Text>
<Text>Badge Count: {badgeCount}</Text>
</View>
);
}

Returned Values

{
expoPushToken: ExpoToken, // Expo push token object
notification: Notification, // Last received notification
response: Response, // Last notification response (tap)
badgeCount: number // Current badge count
}

Token Registration Flow

1. Request Permissions

async function registerForPushNotificationsAsync() {
// Check if running on physical device
if (!Device.isDevice) {
Alert.alert('Device not supported',
'Must be using a physical device for Push notifications');
return;
}

// Get current permission status
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;

// Request permissions if not granted
if (existingStatus !== "granted") {
const { status } = await Notifications.requestPermissionsAsync({
ios: {
allowAlert: true,
allowBadge: true,
allowSound: true,
allowDisplayInCarPlay: true,
},
android: {
allowAlert: true,
allowBadge: true,
allowSound: true,
allowAnnouncements: true,
}
});
finalStatus = status;
}

// Return if permission denied
if (finalStatus !== "granted") {
return;
}

// Get Expo push token
const token = await Notifications.getExpoPushTokenAsync({
projectId: Constants.expoConfig?.extra?.eas.projectId,
});

return token;
}

2. Configure Android Channel

if (Platform.OS === "android") {
// Delete existing channel (if any)
let channel = await Notifications.getNotificationChannelAsync("default");
if (channel) {
await Notifications.deleteNotificationChannelAsync("default");
}

// Create new channel with custom settings
Notifications.setNotificationChannelAsync("default", {
name: "default",
importance: Notifications.AndroidImportance.MAX,
sound: "notificationalert.wav",
vibrationPattern: [0, 250, 250, 250],
lightColor: "#FF231F7C",
showBadge: true,
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PUBLIC,
enableLights: true,
enableVibrate: true,
});
}

3. Register Token with Backend

// Inside useEffect
registerForPushNotificationsAsync().then((token) => {
setExpoPushToken(token);

// Send token to backend API
usePost('user/save-phone-token', {
token: token.data,
device_id: Constants.deviceId,
platform: Platform.OS
});
});

Notification Handler

Set up global notification handler to control how notifications are displayed:

Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldPlaySound: true, // Play sound
shouldShowBanner: true, // Show banner (iOS)
shouldShowList: true, // Show in notification list
shouldSetBadge: true, // Update badge count
}),
});

Notification Listeners

1. Notification Received Listener

Triggered when a notification is received while app is in foreground:

notificationListener.current = Notifications.addNotificationReceivedListener(
async (notification) => {
// Increment badge count
const current = await Notifications.getBadgeCountAsync();
const next = current + 1;
await Notifications.setBadgeCountAsync(next);
setBadgeCount(next);

// Update state
setNotification(notification);

// Emit event for other components
emit('notification.received', notification);
}
);

2. Notification Response Listener

Triggered when user taps on a notification:

responseListener.current = Notifications.addNotificationResponseReceivedListener(
(response) => {
const data = response.notification.request.content.data;

// Remove notification badge from Firebase
if (data.db_url) {
const db = database.getDatabase(app);
let dbref = database.ref(db, data.db_url);
database.remove(dbref);
}

// Store redirect URL for navigation
if (data?.link) {
Storage().storeToken(Cookies().REDIRECT_URL_KEY, data.link);
}

// Mark notification as read via API
if (data?.feedback && data?.notification_id) {
let notification_id = parseInt(data.notification_id);
useGet(`notification/${notification_id}/read`);
}

// Update state
setResponse(response);

// Emit event for navigation handling
emit('notification.response', response);
}
);

Badge Management

Update Badge Count

// Get current badge count
const current = await Notifications.getBadgeCountAsync();

// Set badge count
await Notifications.setBadgeCountAsync(5);

// Clear badge
await Notifications.setBadgeCountAsync(0);

Firebase Integration

Notification badges are synced with Firebase Realtime Database:

// Firebase structure
{
"chatNotifications": {
"user_123": {
"TRNS-456": 3, // 3 unread messages in this chat
"USER-789": 1 // 1 unread message
}
}
}

Notification Data Structure

Received from Backend

{
title: "You got a match!",
body: "Someone wants to swap with you",
data: {
link: "/swap/123", // Deep link URL
transaction_id: 123, // Transaction ID
notification_id: 456, // Notification ID
feedback: true, // Enable read tracking
db_url: "chatNotifications/user_123" // Firebase badge path
}
}

Notification Object

notification = {
request: {
identifier: "unique-id",
content: {
title: "You got a match!",
body: "Someone wants to swap with you",
data: { ... },
sound: "notificationalert.wav"
}
},
date: 1234567890
}

Response Object

response = {
notification: { ... },
actionIdentifier: "expo.modules.notifications.actions.DEFAULT",
userText: null
}

Deep Linking

// Store redirect URL in secure storage
Storage().storeToken(Cookies().REDIRECT_URL_KEY, data.link);

// In your root layout or navigation component
useEffect(() => {
const checkRedirect = async () => {
const redirectUrl = await Storage().getToken(Cookies().REDIRECT_URL_KEY);
if (redirectUrl) {
// Clear the stored URL
await Storage().removeToken(Cookies().REDIRECT_URL_KEY);

// Navigate to the URL
router.push(redirectUrl);
}
};

checkRedirect();
}, []);
// Swap transaction
data.link = "/swap/123"

// Chat room
data.link = "/messages?chatRoom=TRNS-456"

// User profile
data.link = "/profile/789"

// Group page
data.link = "(community)/groups_index"

// Product details
data.link = "/item/101"

Permission Management

Check Current Permission Status

const { status } = await Notifications.getPermissionsAsync();

if (status === 'granted') {
// Notifications are enabled
} else if (status === 'denied') {
// User denied permissions
} else {
// Not yet requested
}

Request Permissions

const { status } = await Notifications.requestPermissionsAsync({
ios: {
allowAlert: true,
allowBadge: true,
allowSound: true,
allowDisplayInCarPlay: true,
},
android: {
allowAlert: true,
allowBadge: true,
allowSound: true,
allowAnnouncements: true,
}
});

Open Device Settings

If user denies permission, guide them to settings:

import * as Linking from 'expo-linking';
import * as IntentLauncher from 'expo-intent-launcher';
import * as Application from 'expo-application';

if (Platform.OS === 'ios') {
// Open iOS settings
Linking.openURL('app-settings:');
} else {
// Open Android app settings
IntentLauncher.startActivityAsync(
IntentLauncher.ActivityAction.APPLICATION_DETAILS_SETTINGS,
{ data: 'package:' + Application.applicationId }
);
}

Sound Configuration

Custom Notification Sound

Place your sound file in:

  • iOS: Project assets
  • Android: android/app/src/main/res/raw/notificationalert.wav
// In notification channel setup
sound: "notificationalert.wav"

// In notification handler
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldPlaySound: true,
// ... other settings
}),
});

Sound Requirements

  • Format: WAV or MP3
  • Duration: < 30 seconds
  • Size: < 5MB
  • Channels: Mono or Stereo
  • Sample Rate: 44.1kHz recommended

Event System

The app uses a custom event emitter for notification events:

Emit Events

import { emit } from './emitter';

// Notification received
emit('notification.received', notification);

// Notification tapped
emit('notification.response', response);

Listen to Events

import { on, off } from './emitter';

useEffect(() => {
const handleNotification = (notification) => {
console.log('Notification received:', notification);
// Update UI, refresh data, etc.
};

// Subscribe
on('notification.received', handleNotification);

// Cleanup
return () => off('notification.received', handleNotification);
}, []);

Testing

Test with Expo Push Tool

  1. Get your Expo push token from the app
  2. Visit: https://expo.dev/notifications
  3. Enter token and test message
  4. Send notification

Test with Backend API

# Send test notification
curl -X GET "https://api.zeya.app/api/send-test-notification?user_id=123&title=Test&text=Hello" \
-H "Authorization: Bearer YOUR_TOKEN"

Local Testing

// Trigger local notification for testing
await Notifications.scheduleNotificationAsync({
content: {
title: "Test Notification",
body: "This is a test",
data: { link: "/test" }
},
trigger: { seconds: 2 }
});

Platform-Specific Behavior

iOS

  • Notifications shown as banners or alerts
  • Badge automatically updated
  • Requires explicit user permission
  • Sounds must be in specific formats
  • Background fetch available

Android

  • Notifications shown in status bar
  • Notification channels required (API 26+)
  • More granular permission control
  • Vibration patterns supported
  • Priority levels affect display

Best Practices

1. Request Permission at Right Time

// Bad ❌ - Request immediately on app launch
useEffect(() => {
Notifications.requestPermissionsAsync();
}, []);

// Good ✅ - Request when user needs it
function handleEnableNotifications() {
Alert.alert(
"Enable Notifications",
"Get notified about matches and messages",
[
{ text: "Cancel", style: "cancel" },
{
text: "Enable",
onPress: () => Notifications.requestPermissionsAsync()
}
]
);
}

2. Handle Permission Denial

const { status } = await Notifications.requestPermissionsAsync();

if (status !== 'granted') {
Alert.alert(
'Notifications Disabled',
'Enable notifications in settings to receive updates',
[
{ text: 'Later', style: 'cancel' },
{ text: 'Open Settings', onPress: openDeviceSettings }
]
);
}

3. Update Token on App Launch

useEffect(() => {
// Always re-register token on app launch
registerForPushNotificationsAsync().then((token) => {
if (token) {
// Update backend
usePost('user/save-phone-token', {
token: token.data,
device_id: Constants.deviceId,
platform: Platform.OS
});
}
});
}, []);

4. Clean Up Listeners

useEffect(() => {
// Set up listeners
const receivedListener = Notifications.addNotificationReceivedListener(...);
const responseListener = Notifications.addNotificationResponseReceivedListener(...);

// Clean up
return () => {
receivedListener.remove();
responseListener.remove();
};
}, []);
// Validate link before navigation
if (data?.link && typeof data.link === 'string') {
// Only allow app routes, not external URLs
if (!data.link.startsWith('http')) {
router.push(data.link);
}
}

Troubleshooting

Common Issues

  1. Token not generated

    • Ensure running on physical device
    • Check internet connection
    • Verify Expo project configuration
    • Check app.json has correct projectId
  2. Notifications not appearing

    • Check device notification settings
    • Verify app has notification permission
    • Check notification channel (Android)
    • Test with local notification first
  3. Sound not playing

    • Verify sound file exists in correct location
    • Check file format (WAV preferred)
    • Test device volume and silent mode
    • Verify notification channel settings (Android)
  4. Badge not updating

    • Check Firebase database connection
    • Verify badge count API
    • Test with setBadgeCountAsync()

Debug Mode

Enable detailed logging:

// Log all notification events
notificationListener.current = Notifications.addNotificationReceivedListener(
(notification) => {
console.log('=== NOTIFICATION RECEIVED ===');
console.log('Title:', notification.request.content.title);
console.log('Body:', notification.request.content.body);
console.log('Data:', notification.request.content.data);
console.log('============================');

// ... rest of handler
}
);

Performance Optimization

1. Debounce Badge Updates

import { debounce } from 'lodash';

const updateBadge = debounce(async (count) => {
await Notifications.setBadgeCountAsync(count);
}, 500);

2. Batch Token Updates

Don't update token on every app launch, only when it changes:

const previousToken = await Storage().getToken('push_token');
if (token.data !== previousToken) {
await Storage().storeToken('push_token', token.data);
// Update backend
await usePost('user/save-phone-token', { ... });
}

3. Lazy Load Notification Data

// Don't fetch full notification data immediately
// Wait until user opens the notification
responseListener.current = Notifications.addNotificationResponseReceivedListener(
async (response) => {
const { transaction_id } = response.notification.request.content.data;

// Fetch full data only when needed
const transactionData = await fetchTransaction(transaction_id);

// Navigate with data
router.push(`/swap/${transaction_id}`);
}
);

Security Considerations

  1. Token Security

    • Store tokens in secure storage
    • Never expose tokens in logs
    • Rotate tokens periodically
  2. Data Validation

    • Validate notification data before processing
    • Sanitize deep link URLs
    • Check data types and formats
  3. Privacy

    • Respect user notification preferences
    • Don't show sensitive data in notification body
    • Clear notification data after handling