Rich notification images
FCM notification vs data-only messages, BigPictureStyle, and the MotiSig image URL contract on Android.
Android handles notification banner images differently from iOS. There is no Notification Service Extension: either FCM renders the image for you (system message), or your host app renders its own notification with NotificationCompat.BigPictureStyle (data-only message).
This page covers both paths and the payload contract MotiSig uses to make them interchangeable.
Two delivery modes
| Mode | What FCM does | What your app must do |
|---|---|---|
notification message with notification.image | FCM auto-renders the banner with the image when the app is in the background. onMessageReceived is not called. | Nothing for the banner. For tap → MotiSig.handleNotificationIntent(intent) from your launcher activity. |
| Data-only message (or notification message in foreground) | Calls MotiSigFirebaseMessagingService.onMessageReceived with data only. No automatic banner. | Build a notification yourself with BigPictureStyle to show the image. |
You can pick either mode on the server side; the MotiSig backend defaults to the first form so the platform handles rendering when possible.
What the SDK does for you
MotiSigFirebaseMessagingService reads RemoteMessage.notification.imageUrl and lifts it into data["imageUrl"] (when no image* key is already present), then dispatches to your MotiSigNotificationListener with userInfo containing the image URL — regardless of which delivery mode was used. So your in-app handlers always see a single, predictable key.
Image URL resolution order (host app helper)
For data-only messages, your app builds the notification. The example app ships PushImageUrl.kt, which extracts the first usable HTTPS URL from these keys, in order:
_motisig.imageUrl/_motisig.image_url/_motisig.image(MotiSig canonical, supports JSON-string_motisigblob)_richContent.image(Expo push relay)fcm_options.image(FCM relay)- Top-level
image/imageUrl/image_url - Sorted
ios_attachment_*_url(cross-platform payload compatibility)
This matches the iOS and Expo SDKs so a single server payload lights up images on all platforms.
Copy PushImageUrl.kt into your project (or write your own resolver) and use it from a NotificationCompat builder.
Building a notification with BigPictureStyle
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.os.Build
import androidx.core.app.NotificationCompat
class MyMessagingService : MotiSigFirebaseMessagingService() {
override fun onMessageReceived(message: com.google.firebase.messaging.RemoteMessage) {
super.onMessageReceived(message) // hand off to the SDK first
val data = message.data
val imageUrl = extractPushImageUrl(data.mapValues { it.value as Any })
val title = message.notification?.title ?: data["title"] ?: return
val body = message.notification?.body ?: data["body"] ?: ""
showRichNotification(applicationContext, title, body, imageUrl, data)
}
}
private fun showRichNotification(
context: Context,
title: String,
body: String,
imageUrl: String?,
data: Map<String, String>,
) {
val channelId = "motisig_default"
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && nm.getNotificationChannel(channelId) == null) {
nm.createNotificationChannel(
NotificationChannel(channelId, "Default", NotificationManager.IMPORTANCE_DEFAULT)
)
}
val tapIntent = Intent(context, YourLauncherActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
for ((k, v) in data) putExtra(k, v)
}
val pi = PendingIntent.getActivity(
context, 0, tapIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val builder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body)
.setAutoCancel(true)
.setContentIntent(pi)
if (!imageUrl.isNullOrBlank()) {
// Off the main thread; use your image loader of choice (Coil, Glide, OkHttp, etc.)
try {
val bytes = java.net.URL(imageUrl).readBytes()
val bmp = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
builder.setLargeIcon(bmp)
.setStyle(NotificationCompat.BigPictureStyle().bigPicture(bmp).bigLargeIcon(null as android.graphics.Bitmap?))
} catch (_: Throwable) {
// fall back to text-only
}
}
nm.notify(System.currentTimeMillis().toInt(), builder.build())
}Notes:
- Run the URL fetch off the main thread.
FirebaseMessagingService.onMessageReceivedruns on a worker thread, so a synchronousURL.readBytes()is safe inside it (the example above is intentionally minimal — production apps should use a real image loader with caching). - Forward the FCM data as
Intentextras so your launcher activity'sonCreate/onNewIntentcan callMotiSig.getInstance().handleNotificationIntent(intent)and trigger click tracking. - The MotiSig SDK never posts notifications on your behalf; it only forwards events to listeners. Posting is fully under your control.
Expected payload
For a data-only delivery, the recommended canonical payload is:
{
"data": {
"messageId": "f082aa55-6eed-407f-b819-36e858ed7d0a",
"title": "Hello",
"body": "world",
"_motisig": "{\"imageUrl\":\"https://your-cdn.example.com/path/push.jpg\"}"
}
}For an FCM notification message that auto-renders, set notification.image directly on the FCM payload — the SDK still copies the URL into data["imageUrl"] for in-app reads.
Foreground
When your app is in the foreground, FCM does not auto-render notifications: every push hits onMessageReceived and your code decides whether to show a notification, an in-app banner, both, or nothing. Use MotiSigNotificationListener (inForeground == true) to update in-app state, and call NotificationManagerCompat.notify(...) if you also want a tray banner.
Troubleshooting
| Symptom | What to check |
|---|---|
| Banner missing image when app is in background | The push was sent as a data-only message. Either send it as a notification message with notification.image, or build the notification yourself from onMessageReceived (BigPictureStyle). |
onMessageReceived never called | Your app is in the background and the push is a notification message — that's normal. Test with the app in the foreground to verify onMessageReceived. |
| Image URL returns 403 / 401 | The CDN requires auth headers your app cannot inject from Android's image loader. Sign the URL itself (query-string token) so it's publicly fetchable. |
_motisig arrives as a string, not a map | FCM coerces all data values to strings on the wire. The example helper handles this — see appendMotisigImageCandidates in PushImageUrl.kt. |
| Image loaded but banner still small | Use both setLargeIcon(...) and BigPictureStyle().bigPicture(...) so the expanded view shows the full image. Without BigPictureStyle, only the thumbnail appears. |
For broader debugging, see Troubleshooting.