Taking Back Control: How I Fixed Crisp FCM Notifications in a Flutter Plugin

Experienced Android Developer with a demonstrated history of working for the IT industry. Skilled in JAVA, Dart, Flutter, and Teamwork. Strong Application Development professional with a Bachelor's degree focused in Computer Science & Engineering from Daffodil International University-DIU.
f you’ve ever integrated Crisp Chat into a Flutter app, chances are you loved how easy it was to get real-time support chat up and running.
Until push notifications entered the picture.
For several developers using the crisp_chat Flutter plugin, the experience broke down in a very specific and very frustrating way:
Tapping a Crisp push notification while the app is terminated opens the Crisp ChatActivity directly, completely bypassing the app’s main screen.
No splash screen.
No routing logic.
No chance to prepare state.
Just boom — straight into chat.
This wasn’t just a personal frustration. It surfaced publicly as
Issue #79
and multiple developers asked the same question:
“Can we open the app first… and then decide whether to open the chat?”
At first glance, this looked like a limitation of the Crisp SDK itself. But after digging deeper, I realized something important:
This wasn’t a Flutter problem.
This wasn’t even really a Crisp problem.
It was a notification ownership problem.
Understanding the Root Cause
On Android, Crisp handles push notifications via its own FirebaseMessagingService:
CrispNotificationClient.handleNotification(context, message, true);
That last parameter — true — is the key.
It tells Crisp:
“When the user taps this notification, automatically open
ChatActivity.”
That behavior is perfectly reasonable for many apps.
But for others especially apps with authentication, routing, or deep navigation logic it’s too aggressive.
And here’s the real issue:
Flutter never gets a chance to intervene.
Once the intent launches ChatActivity, your app flow is already bypassed.
The Design Goal
Rather than forcing one behavior on everyone, I wanted something better:
Developers should be able to choose:
Crisp default behavior (auto-open chat)
App-first behavior (open app → decide later)
And ideally…
✔ No breaking changes
✔ No hacks
✔ No forks of the Crisp SDK
✔ Minimal configuration
The Solution: Two Explicit Notification Strategies
Instead of replacing Crisp’s behavior, I added an alternative path.
Option A: Crisp Default (Zero Code)
This keeps Crisp’s original behavior untouched.
<service
android:name="im.crisp.client.external.notification.CrispNotificationService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
Notification tap → Chat opens directly
No Flutter involvement
Works exactly like before
Option B: App-First Flow (Custom Handling)
This option hands control back to the app.
<service
android:name="com.alaminkarno.flutter_crisp_chat.CrispChatNotificationService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
With this single change:
Notification tap opens your app
Flutter decides if and when to open the chat
No runtime flags.
No complex setup.
Just one manifest swap.
How the Custom Flow Works (High Level)
1) Intercept the Notification (Android)
I introduced a custom FirebaseMessagingService:
if (CrispNotificationClient.isCrispNotification(message)) {
CrispNotificationClient.handleNotification(this, message, false);
}
That false is crucial.
It still:
Displays the notification
Preserves all Crisp payload data
But it does not auto-launch ChatActivity.
Now, tapping the notification simply launches MainActivity.
2) Detect the Notification Tap
When a notification is tapped, Android delivers a new Intent.
Inside the plugin, I listen for it:
channel.invokeMethod("onCrispNotificationTapped", null);
This bridges the event from native → Flutter.
3) Let Flutter Decide
From Dart, developers get two simple tools:
FlutterCrispChat.setOnNotificationTappedCallback(() {
FlutterCrispChat.openChatboxFromNotification();
});
And for terminated state:
FlutterCrispChat.openChatboxFromNotification();
That’s it.
No platform checks.
No intent parsing.
No native code in the app.
The Real Challenges (and Lessons Learned)
Firebase as a Compile-Time Dependency
Because the plugin extends FirebaseMessagingService, but doesn’t own Firebase itself, builds initially failed.
The fix was subtle but important:
compileOnly 'com.google.firebase:firebase-messaging:24.1.0'
This keeps the plugin lightweight and avoids version conflicts a pattern I now strongly recommend for Flutter plugins.
MethodChannel Timing Pitfall
Registering a method channel handler too early caused crashes in tests.
The solution?
Lazy initialization.
Only register the handler when it’s actually needed once Flutter bindings are guaranteed to exist.
A small change, but a big stability win.
iOS Compatibility (Without Extra Burden)
This feature is Android-only by nature.
But from Dart, the API remains cross-platform.
On iOS, the method simply returns false.
No crashes.
No Platform.isAndroid checks.
No special handling required by app developers.
The End Result
Scenario → Default Crisp → App-First Flow
App terminated → Opens chat directly → Opens app first
App background → Opens chat directly → Flutter callback
Flutter control → ❌ None → ✅ Full
Setup cost → Zero → Minimal
The best part?
Existing users don’t have to change anything.
Advanced users finally get control.
Final Thoughts
This wasn’t just about fixing a notification bug.
It was about:
Respecting app architecture
Giving developers choice
Designing plugin APIs that scale beyond “happy paths”
Sometimes, the most impactful improvements come from not forcing a solution, but making space for multiple workflows.
If you’re using crisp_chat, the feature is already available.
And if you’re building Flutter plugins yourself I hope this story saves you a few late nights.
📦 Package: crisp_chat
🐛 Fixes: Issue #79
Happy coding 🚀



