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
Navigation on Notification Tap
// 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();
}, []);
Link Examples
// 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
- Get your Expo push token from the app
- Visit: https://expo.dev/notifications
- Enter token and test message
- 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();
};
}, []);
5. Handle Deep Links Safely
// 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
-
Token not generated
- Ensure running on physical device
- Check internet connection
- Verify Expo project configuration
- Check
app.jsonhas correctprojectId
-
Notifications not appearing
- Check device notification settings
- Verify app has notification permission
- Check notification channel (Android)
- Test with local notification first
-
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)
-
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
-
Token Security
- Store tokens in secure storage
- Never expose tokens in logs
- Rotate tokens periodically
-
Data Validation
- Validate notification data before processing
- Sanitize deep link URLs
- Check data types and formats
-
Privacy
- Respect user notification preferences
- Don't show sensitive data in notification body
- Clear notification data after handling