Push Notification & Deep Link #
In the modern mobile app ecosystem, push notifications and deep links are two crucial technology pillars for increasing user engagement and app retention. Push notifications act as external callers attracting users back to your app, while deep links act as navigation guides ensuring users are directly routed to specific relevant pages (like promo product detail pages or order delivery statuses) when they tap those notifications—not just opening the main home page.
Integrating both technologies requires deep understanding of native platform layers (FCM on Android, APNs on iOS), app lifecycle state synchronization, and declarative routing architecture on the Flutter side.
Notification Delivery & Deep Linking Architecture #
The workflow of delivering notifications from your backend server to becoming a navigation action on the user’s Flutter screen goes through the following architectural stages:
- Server Trigger (Backend): Your server sends a data payload (JSON payload) to the push gateway of the target platform (FCM for Android and APNs for iOS).
- Gateway Transmission: FCM/APNs deliver that notification to the user’s device through persistent socket connections managed by the background operating system.
- Operating System Response: The device receives the notification. If the app is in the background or terminated, the OS draws the notification in the status bar (system tray). If the app is in the foreground, the message is directly handed to the app.
- User Action & Routing: When users tap the notification, the OS launches or wakes your app including the data payload. Your app’s router (e.g., GoRouter) translates that data payload into page navigation parameters.
Firebase Cloud Messaging (FCM) & APNs Integration #
To enable push notification features in Flutter, you use the official firebase_messaging package combined with flutter_local_notifications to handle custom notification rendering when the app is actively in the foreground.
1. Dependency Installation #
Add the following libraries in your pubspec.yaml file:
dependencies:
firebase_core: ^3.8.1
firebase_messaging: ^15.1.6
flutter_local_notifications: ^17.2.4
2. Firebase Initialization & Background Scheduling #
Use the FlutterFire CLI to automatically configure the Firebase project on your Android and iOS target platforms:
# Run Firebase configuration via CLI
flutterfire configure --project=your-firebase-project-name
On iOS, make sure to enable the Push Notifications and Background Modes capabilities (check Remote notifications) in Xcode under Signing & Capabilities. You’re recommended to use the APNs authentication key (.p8) in the Firebase Console because this key has no expiration date and can be used for multiple apps at once.
Handling Notifications in Three App Lifecycle States #
Notification reception behavior varies depending on your app’s lifecycle state when the message arrives. Here’s a visualization of the notification data routing flow based on app status:
flowchart TD
Start["Notification Received by Device"] --> CheckState{"What's the App Status?"}
CheckState -- "1. Foreground (App Open)" --> FG_Receive["FCM Receives the Message (onMessage)"]
FG_Receive --> FG_Local["Show a Manual Notification via flutter_local_notifications"]
FG_Local --> FG_Tap["User Taps the Notification"]
FG_Tap --> FG_Route["Parse JSON Data & Route with GoRouter"]
CheckState -- "2. Background (App Minimized)" --> BG_Receive["OS Shows the Notification in the System Tray"]
BG_Receive --> BG_Tap["User Taps the Notification"]
BG_Tap --> BG_Handler["FCM Triggers the onMessageOpenedApp Callback"]
BG_Handler --> BG_Route["Extract Payload Data & Navigate Pages"]
CheckState -- "3. Terminated (App Dead)" --> TM_Receive["OS Shows the Notification in the System Tray"]
TM_Receive --> TM_Tap["User Taps the Notification"]
TM_Tap --> TM_Launch["OS Launches the App (Cold Start)"]
TM_Launch --> TM_Check["Call getInitialMessage() after the Router is Ready"]
TM_Check --> TM_Route["Extract Payload Data & Redirect to the Specific Route"]Here’s the complete NotificationService service class implementation handling all three lifecycle status scenarios safely and modularly:
// lib/core/notifications/notification_service.dart
import 'dart:convert';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../router/app_router.dart';
/// The background message handler must be placed as a top-level function
/// and annotated with @pragma('vm:entry-point') so the compiler doesn't remove it.
@pragma('vm:entry-point')
Future<void> _fcmBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
debugPrint('[FCM Background] Received message: ${message.messageId}');
}
class NotificationService {
static final FirebaseMessaging _messaging = FirebaseMessaging.instance;
static final FlutterLocalNotificationsPlugin _localNotifications =
FlutterLocalNotificationsPlugin();
static const AndroidNotificationChannel _importantChannel = AndroidNotificationChannel(
'important_channel_id',
'Important Notifications',
description: 'This channel is used for important transaction & promo notifications.',
importance: Importance.max,
playSound: true,
);
static Future<void> initialize() async {
// 1. Register the Background Handler on the first line
FirebaseMessaging.onBackgroundMessage(_fcmBackgroundHandler);
// 2. Request Notification Permission (especially important for iOS and Android 13+)
final NotificationSettings settings = await _messaging.requestPermission(
alert: true,
badge: true,
sound: true,
);
debugPrint('Notification Permission Status: ${settings.authorizationStatus}');
// 3. Configure Local Notifications for the Foreground
const AndroidInitializationSettings androidInitialization =
AndroidInitializationSettings('@mipmap/ic_launcher');
const DarwinInitializationSettings iOSInitialization = DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
);
await _localNotifications.initialize(
const InitializationSettings(android: androidInitialization, iOS: iOSInitialization),
onDidReceiveNotificationResponse: _onLocalNotificationTap,
);
// 4. Create the Android Notification Channel
await _localNotifications
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(_importantChannel);
// 5. Register Event Handlers
_setupEventHandlers();
// 6. Get the FCM Token for Backend Delivery
final token = await _messaging.getToken();
debugPrint('Device FCM Token: $token');
if (token != null) {
await _sendTokenToServer(token);
}
// Update the token on the server if token rotation occurs
_messaging.onTokenRefresh.listen(_sendTokenToServer);
}
static void _setupEventHandlers() {
// CONDITION 1: FOREGROUND (App Actively Open)
// The OS won't automatically show a banner. We must trigger a local notification.
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
debugPrint('[FCM Foreground] Incoming message: ${message.notification?.title}');
_showLocalNotification(message);
});
// CONDITION 2: BACKGROUND (App Open but Minimized)
// The OS shows a banner in the tray. This callback only triggers when the user taps the banner.
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
debugPrint('[FCM Background Tap] The user tapped the notification.');
_routePage(message.data);
});
// CONDITION 3: TERMINATED (App in a Totally Dead State)
// We call this during initialization to check whether the app was launched from a notification tap.
_checkColdStartNotification();
}
static Future<void> _checkColdStartNotification() async {
final RemoteMessage? initialMessage = await _messaging.getInitialMessage();
if (initialMessage != null) {
debugPrint('[FCM Cold Start Tap] The app was launched from a notification.');
// Give a small delay so the router structure and element tree are perfectly mounted
await Future.delayed(const Duration(milliseconds: 800));
_routePage(initialMessage.data);
}
}
static Future<void> _showLocalNotification(RemoteMessage message) async {
final RemoteNotification? notification = message.notification;
if (notification == null) return;
await _localNotifications.show(
notification.hashCode,
notification.title,
notification.body,
NotificationDetails(
android: AndroidNotificationDetails(
_importantChannel.id,
_importantChannel.name,
channelDescription: _importantChannel.description,
importance: Importance.max,
priority: Priority.high,
icon: '@mipmap/ic_launcher',
),
iOS: const DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
),
payload: jsonEncode(message.data), // Include the data payload JSON as a string
);
}
static void _onLocalNotificationTap(NotificationResponse response) {
final String? payload = response.payload;
if (payload != null && payload.isNotEmpty) {
final Map<String, dynamic> data = jsonDecode(payload) as Map<String, dynamic>;
_routePage(data);
}
}
static void _routePage(Map<String, dynamic> data) {
final String? pageType = data['type'] as String?;
final String? contentId = data['id'] as String?;
if (pageType == null) return;
// Redirect routes using our app's router
switch (pageType) {
case 'promo':
AppRouter.router.push('/promo');
break;
case 'product':
if (contentId != null) AppRouter.router.push('/product/$contentId');
break;
case 'transaction':
if (contentId != null) AppRouter.router.push('/transaction/$contentId');
break;
default:
AppRouter.router.go('/');
}
}
static Future<void> _sendTokenToServer(String token) async {
// Token delivery logic to your backend so the server knows this device's address
debugPrint('Sending the FCM token to the backend server database...');
}
}
Deep Linking: Custom URL Schemes vs App/Universal Links #
Deep linking allows the operating system to capture external links (like link clicks from browsers, SMS, or email) and redirect them to directly open your app on the appropriate page. There are two types of deep links:
- Custom URL Schemes (e.g.,
your-scheme://product/123):- Advantages: Very easy to configure in native files.
- Disadvantages: Don’t have an ownership verification system. If another app uses the same scheme name, the operating system shows an app chooser dialog, or your link can be hijacked by other apps.
- App Links (Android) & Universal Links (iOS) (e.g.,
https://yourdomain.com/product/123):- Advantages: Use the HTTPS security protocol verified directly against your official web domain name. They can’t be hijacked because they require validation certificate files on your web server side. If the app isn’t installed, the link opens smoothly in a regular web browser (graceful fallback).
- Disadvantages: Require web server configuration and domain name ownership.
1. Android Side Configuration (Custom & App Links) #
Modify the android/app/src/main/AndroidManifest.xml file inside your main <activity> tag:
<activity
android:name=".MainActivity"
...>
<!-- 1. Custom URL Scheme Configuration (your-scheme://) -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="your-scheme" />
</intent-filter>
<!-- 2. Android App Links Configuration (HTTPS Domain) -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<!-- Enter your official web domain -->
<data android:scheme="https" android:host="unisbadri.com" />
<data android:scheme="http" android:host="unisbadri.com" />
</intent-filter>
</activity>
The android:autoVerify="true" attribute tells Android to automatically verify your HTTPS domain links against your web server right after the app is installed by users.
2. iOS Side Configuration (Custom & Universal Links) #
- Custom URL Schemes: Open Xcode, go to the Info tab $\rightarrow$ URL Types. Add a new item with a unique Identifier and fill the URL Schemes section with the text
your-scheme. - Universal Links: Open Xcode, go to the Signing & Capabilities tab $\rightarrow$ click + Capability $\rightarrow$ select Associated Domains. Enter your domain with the
applinks:prefix format:applinks:unisbadri.com
Web Server Configuration for Domain Validation #
For the Android and iOS operating systems to trust that your app is the legitimate owner of the unisbadri.com domain, you must upload digital signature configuration files (digital signatures) to your web server. These files must be publicly accessible using the HTTPS protocol without redirects on the special .well-known/ folder path.
1. Android App Links Configuration (assetlinks.json) #
Create a JSON text file named assetlinks.json and place it at:
https://unisbadri.com/.well-known/assetlinks.json
[
{
"relation": [
"delegate_permission/common.handle_all_urls"
],
"target": {
"namespace": "android_app",
"package_name": "com.unisbadri.app",
"sha256_cert_fingerprints": [
"14:6D:E9:83:C5:E0:14:10:BC:82:13:90:85:12:20:AA:BB:CC:DD:EE:FF:GG:HH:II:JJ:KK:LL:MM:NN"
]
}
}
]
[!NOTE] The
sha256_cert_fingerprintsvalue is the SHA-256 fingerprint of your release app signing certificate (release signing certificate). You can get this value from the Google Play Console in the App Integrity menu or extract it manually from the release keystore using thekeytoolcommand.
2. iOS Universal Links Configuration (apple-app-site-association) #
Create a JSON text file without a file extension named apple-app-site-association and place it at:
https://unisbadri.com/.well-known/apple-app-site-association
{
"applinks": {
"apps": [],
"details": [
{
"appID": "AB12345XYZ.com.unisbadri.app",
"paths": [
"/product/*",
"/promo",
"/transaction/*"
]
}
]
}
}
The appID value format is a combination of your Apple Developer Team ID (consisting of 10 alphanumeric characters) and your iOS App Bundle Identifier, separated by a dot. The paths property limits which URL patterns are allowed to directly open your app.
Deep Link Handling Integration with GoRouter #
To capture and route deep links consistently across various app status conditions in Flutter, you use the app_links helper package. This package is combined with the GoRouter routing library to handle declarative navigation smoothly.
1. Dependency Installation #
dependencies:
go_router: ^14.2.0
app_links: ^6.1.1
2. Route & Deep Link Listener Configuration #
// lib/core/router/app_router.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:app_links/app_links.dart';
class AppRouter {
static final AppLinks _appLinks = AppLinks();
static final GoRouter router = GoRouter(
initialLocation: '/',
routes: [
GoRoute(
path: '/',
builder: (context, state) => const Scaffold(
body: Center(child: Text('Home Page')),
),
),
GoRoute(
path: '/promo',
builder: (context, state) => const Scaffold(
body: Center(child: Text('Special Promo Page')),
),
),
GoRoute(
path: '/product/:id',
builder: (context, state) {
final String id = state.pathParameters['id'] ?? '';
return Scaffold(
appBar: AppBar(title: const Text('Product Detail')),
body: Center(child: Text('Showing Product ID: $id')),
);
},
),
GoRoute(
path: '/transaction/:id',
builder: (context, state) {
final String id = state.pathParameters['id'] ?? '';
return Scaffold(
appBar: AppBar(title: const Text('Transaction Detail')),
body: Center(child: Text('Showing Transaction Detail ID: $id')),
);
},
),
],
);
/// Initializes the deep link listener to monitor incoming links
static Future<void> initializeDeepLinking() async {
// A. COLD START CONDITION (App Dead Then Opened from a Link)
try {
final Uri? initialLink = await _appLinks.getInitialLink();
if (initialLink != null) {
debugPrint('[DeepLink Cold Start] Initial link detected: $initialLink');
_processDeepLinkNavigation(initialLink);
}
} catch (e) {
debugPrint('Failed to read the initial deep link: $e');
}
// B. RUNTIME CONDITION (App Active in Foreground / Background)
_appLinks.uriLinkStream.listen((Uri uri) {
debugPrint('[DeepLink Runtime] Incoming link: $uri');
_processDeepLinkNavigation(uri);
}, onError: (err) {
debugPrint('Error on the deep link data stream: $err');
});
}
static void _processDeepLinkNavigation(Uri uri) {
// We take the path and query parameters precisely
final String path = uri.path;
if (path.isNotEmpty && path != '/') {
// Redirect the router page focus to the deep link path
router.push(path);
}
}
}
Connect this initialization in your main.dart file:
// main.dart
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'core/notifications/notification_service.dart';
import 'core/router/app_router.dart';
import 'firebase_options.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// 1. Initialize Firebase & Notifications
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
await NotificationService.initialize();
// 2. Initialize the Deep Linking Listener
await AppRouter.initializeDeepLinking();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Integration App',
routerConfig: AppRouter.router,
);
}
}
FCM Message Payload Structure Design from the Backend #
To ensure your push notifications can automatically trigger deep link navigation when tapped, your backend server must send a structured JSON payload data format separating the visual notification block from the logic data block.
Here’s the standard production JSON payload schema for delivery through the FCM HTTP v1 API:
{
"message": {
"token": "TARGET_DEVICE_FCM_TOKEN",
"notification": {
"title": "Limited Promo! 🚀",
"body": "Get a 50% discount on the latest Flutter book products."
},
"data": {
"type": "product",
"id": "flutter-book-advanced",
"click_action": "FLUTTER_NOTIFICATION_CLICK"
},
"android": {
"notification": {
"channel_id": "important_channel_id",
"click_action": "FLUTTER_NOTIFICATION_CLICK"
}
},
"apns": {
"payload": {
"aps": {
"sound": "default",
"badge": 1
}
}
}
}
}
The click_action property with the FLUTTER_NOTIFICATION_CLICK value is very important for old Android systems so the operating system knows that taps on notification banners must be handed over to trigger your app intent, not just discard that notification.
Testing & Notification Security Best Practices #
After completing all the implementation steps, you must do thorough testing.
1. How to Test Deep Links Through the Command Line Terminal #
To test whether your physical device or emulator operating system correctly recognizes your app route links without having to create manual HTML links, run the following terminal commands:
- Testing on Android (via adb):
# Testing the Custom URL Scheme adb shell am start -W -a android.intent.action.VIEW -d "your-scheme://product/flutter-book-123" com.unisbadri.app # Testing the HTTPS Domain App Links adb shell am start -W -a android.intent.action.VIEW -d "https://unisbadri.com/promo" com.unisbadri.app - Testing on the iOS Simulator:
# Testing the Custom URL Scheme on the active iOS simulator xcrun simctl openurl booted "your-scheme://transaction/tr-9999" # Testing the HTTPS Domain Universal Links xcrun simctl openurl booted "https://unisbadri.com/product/flutter-book-123"
2. Notification Payload Security Best Practices #
- Avoid Sending Sensitive Data: Never include sensitive user data (like passwords, email addresses, or account mutation details) inside push notification payloads. Third parties or operating system log systems can peek at those payloads.
- Use the “Ping to Pull” Pattern: Instead of sending complete transaction data inside notification payloads for display, just send the transaction ID in the data payload, then let your Flutter app make secure HTTPS API calls to pull the latest data from the server when the notification is opened.
Summary #
- Three App States: Notification navigation must be handled in three app lifecycle status conditions: foreground (manually via local notifications), background (using
onMessageOpenedApp), and terminated (usinggetInitialMessage()).- Entry-Point Annotations: Annotate the FCM background handler function with
@pragma('vm:entry-point')to protect it from automatic code removal by the Dart release compiler.- Domain Validation: App Links and Universal Links are much safer than Custom URL Schemes because they legitimately validate your HTTPS domain through
assetlinks.jsonandapple-app-site-associationfiles on the web server.- Unified Routing: Combine the
app_linkspackage withGoRouterto centralize external URL link capture and redirect page focus instantly using declarative.push().- Physical Verification: Always test remote push notifications using release physical devices because emulators often don’t accurately simulate APNs/FCM background system behavior.