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