androidnotificationsbubble-popup

Android Bubbles - Notification recognized as conversation, but won't bubble


I'm working on an app that I want to use the bubble feature to send notifications to the user.

I have a previous implementation where I used a custom bubble, but I believe it would be cleaner to use the builtin feature.

I have got it to where android will recognize the notification as a conversation. It prompts the user for notification privs and then after the first notification I can (usually) promote the notification to Priority and then the bubble toggle will show up in the settings.

But it won't actually bubble. The weirdest thing is that in a previous implementation it would bubble, but it doesn't with this new refactor. As a disclaimer, I used some AI (chatGPT 4o and Gemini 2.5) to assist with the code.

Any help would be greatly appreciated! Here's the code:

Bubble Service:

package cloud.trotter.dashbuddy.bubble

import android.app.ActivityOptions
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.util.Log
import androidx.core.app.Person
import androidx.core.content.ContextCompat
import androidx.core.content.LocusIdCompat
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import cloud.trotter.dashbuddy.BubbleActivity
import cloud.trotter.dashbuddy.DashBuddyApplication
import cloud.trotter.dashbuddy.R
import cloud.trotter.dashbuddy.bubble.Notification as BubbleNotification

class Service : Service() {
    private lateinit var notificationManager: NotificationManager
    private lateinit var dashBuddyPerson: Person
    private lateinit var bubbleShortcut: ShortcutInfoCompat
    private lateinit var dashBuddyIcon: IconCompat
    private lateinit var dashBuddyLocusId: LocusIdCompat 
    private var areComponentsInitialized = false


    companion object {
        const val CHANNEL_ID = "bubble_channel"
        const val NOTIFICATION_ID = 1
        private const val SHORTCUT_ID = "DashBuddy_Bubble_Shortcut"
        private const val TAG = "BubbleService"
        const val EXTRA_MESSAGE = "extra_message_to_show" // Key for intent extra
        private const val DASHBUDDY_PERSON_KEY = "dashbuddy_user_key" 

        @Volatile
        var isServiceRunningIntentional = false
            private set
    }

    override fun onCreate() {
        super.onCreate()
        Log.d(TAG, "onCreate: BubbleService creating...")
        notificationManager = DashBuddyApplication.notificationManager

        createNotificationChannel()

        dashBuddyIcon = IconCompat.createWithResource(DashBuddyApplication.context, R.drawable.bag_red_idle)
        dashBuddyPerson = Person.Builder()
            .setName("DashBuddy")
            .setIcon(dashBuddyIcon)
            .setKey(DASHBUDDY_PERSON_KEY) // Added a stable key for the Person
            .setImportant(true)
            .build()

        dashBuddyLocusId = LocusIdCompat("${SHORTCUT_ID}_Locus")
        Log.d(TAG, "Initialized LocusId: $dashBuddyLocusId")

        val shortcutIntent = Intent(DashBuddyApplication.context, BubbleActivity::class.java).apply {
            action = Intent.ACTION_VIEW
        }
        bubbleShortcut = ShortcutInfoCompat.Builder(DashBuddyApplication.context, SHORTCUT_ID)
            .setLongLived(true)
            .setIntent(shortcutIntent)
            .setShortLabel("DashBuddy")
            .setIcon(dashBuddyIcon)
            .setPerson(dashBuddyPerson)
            .setCategories(setOf("android.shortcut.conversation"))
            .setLocusId(dashBuddyLocusId) 
            .build()
        ShortcutManagerCompat.pushDynamicShortcut(DashBuddyApplication.context, bubbleShortcut)

        areComponentsInitialized = true 

        DashBuddyApplication.bubbleService = this
        Log.d(TAG, "onCreate: BubbleService created successfully.")
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        isServiceRunningIntentional = true 
        Log.d(TAG, "onStartCommand: BubbleService started.")

        val messageToShow = intent?.getStringExtra(EXTRA_MESSAGE) ?: "DashBuddy is active!"

        val bubbleContentPendingIntent = PendingIntent.getActivity(
            DashBuddyApplication.context,
            0,
            Intent(DashBuddyApplication.context, BubbleActivity::class.java),
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
        )

        val notification = BubbleNotification.create(
            context = DashBuddyApplication.context,
            channelId = CHANNEL_ID,
            senderPerson = dashBuddyPerson,
            shortcutId = bubbleShortcut.id,
            bubbleIcon = dashBuddyIcon,
            messageText = messageToShow, 
            contentIntent = bubbleContentPendingIntent,
            locusId = dashBuddyLocusId, 
            autoExpandBubble = false, 
            suppressNotification = false 
        )

        try {
            val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
                ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
            } else {
                // if the manifest has foregroundServiceType="specialUse"
                0 // No specific type, but the manifest declaration counts.
            }
            if (serviceType != 0 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
                startForeground(NOTIFICATION_ID, notification, serviceType)
            } else {
                startForeground(NOTIFICATION_ID, notification)
            }
            Log.d(TAG, "Service started in foreground with message: '$messageToShow'")
        } catch (e: Exception) {
            Log.e(TAG, "Error starting foreground service", e)
            isServiceRunningIntentional = false
            stopSelf() 
        }

        return START_STICKY
    }

    override fun onDestroy() {
        super.onDestroy()
        isServiceRunningIntentional = false 
        areComponentsInitialized = false
        if (DashBuddyApplication.bubbleService == this) {
            DashBuddyApplication.bubbleService = null
        }
        Log.d(TAG, "onDestroy: BubbleService destroyed.")
    }

    override fun onBind(intent: Intent?): IBinder? {
        return null
    }

    private fun createNotificationChannel() {
        val channel = NotificationChannel(
            CHANNEL_ID,
            "DashBuddy Bubbles",
            NotificationManager.IMPORTANCE_HIGH
        ).apply {
            description = "Channel for DashBuddy bubble notifications."
            setAllowBubbles(true)
        }
        notificationManager.createNotificationChannel(channel)
        Log.d(TAG, "Notification channel created/updated.")
    }

    /**
    * Public method to show a new message in the bubble.
    * If the service is not running, it will attempt to start it with this message.
    */
    fun showMessageInBubble(message: String) {
        Log.d(TAG, "showMessageInBubble called with message: '$message'")

        if (!isServiceRunningIntentional || !areComponentsInitialized) {
            Log.w(TAG, "Service not running or not initialized. Attempting to start service with this message.")
            val startIntent = Intent(DashBuddyApplication.context,
                cloud.trotter.dashbuddy.bubble.Service::class.java).apply {
                putExtra(EXTRA_MESSAGE, message) // Pass the message to onStartCommand
            }
            ContextCompat.startForegroundService(DashBuddyApplication.context, startIntent)
            return
        }

        Log.d(TAG, "Service is running. Updating bubble notification with message: '$message'")

        val bubbleContentPendingIntent = PendingIntent.getActivity(
            DashBuddyApplication.context,
            0,
            Intent(DashBuddyApplication.context, BubbleActivity::class.java),
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
        )

        val newNotification = BubbleNotification.create(
            context = DashBuddyApplication.context,
            channelId = CHANNEL_ID,
            senderPerson = dashBuddyPerson,
            shortcutId = bubbleShortcut.id,
            bubbleIcon = dashBuddyIcon,
            messageText = message,
            contentIntent = bubbleContentPendingIntent,
            locusId = dashBuddyLocusId, // Use the member variable
            autoExpandBubble = true // Usually false for subsequent messages
        )
        notificationManager.notify(NOTIFICATION_ID, newNotification)
        Log.d(TAG, "Updated bubble notification posted.")
    }
}

The notification helper:

package cloud.trotter.dashbuddy.bubble

import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.content.LocusIdCompat
import androidx.core.graphics.drawable.IconCompat
import java.util.Date

/**
* Helper object to create bubble-specific notifications.
*/
object Notification {

    /**
    * Creates a notification configured for display as a bubble.
    *
    * @param context The application context.
    * @param channelId The ID of the notification channel to use.
    * @param senderPerson The Person object representing the sender of the message (e.g., "DashBuddy").
    * @param shortcutId The ID of the shortcut this bubble is associated with.
    * @param bubbleIcon The icon to display for the bubble and notification.
    * @param messageText The text content of the message.
    * @param contentIntent The PendingIntent to launch when the bubble is tapped.
    * @param locusId Optional LocusIdCompat to link the notification to app state.
    * @param desiredHeight The desired height of the bubble's expanded view.
    * @param suppressNotification True to suppress the fly-out notification and only show the bubble.
    * @param autoExpandBubble True to have the bubble auto-expand when it first appears.
    * @return A configured Notification object.
    */

    fun create(
        context: Context,
        channelId: String,
        senderPerson: Person,
        shortcutId: String,
        bubbleIcon: IconCompat,
        messageText: String,
        contentIntent: PendingIntent,
        locusId: LocusIdCompat? = null, 
        desiredHeight: Int = 600, 
        suppressNotification: Boolean = true,
        autoExpandBubble: Boolean = true
    ): Notification {
        Log.d("BubbleNotificationHelper", "Creating messaging bubble notification with message: '$messageText', Locus ID: $locusId")

        val bubbleMetadataBuilder = NotificationCompat.BubbleMetadata.Builder(contentIntent, bubbleIcon)
            .setDesiredHeight(desiredHeight)
            .setSuppressNotification(suppressNotification)
            .setAutoExpandBubble(autoExpandBubble)

        val bubbleMetadata = bubbleMetadataBuilder.build()

        val messagingStyle = NotificationCompat.MessagingStyle(senderPerson)
            .addMessage(
                NotificationCompat.MessagingStyle.Message(
                    messageText,
                    Date().time,
                    senderPerson
                )
            )

        val builder = NotificationCompat.Builder(context, channelId)
            .setSmallIcon(bubbleIcon)
            .setContentTitle(senderPerson.name)
            .setContentText(messageText)
            .setShortcutId(shortcutId)
            .setCategory(NotificationCompat.CATEGORY_MESSAGE)
            .addPerson(senderPerson)
            .setStyle(messagingStyle)
            .setBubbleMetadata(bubbleMetadata)
            .setContentIntent(contentIntent)
            .setShowWhen(true)

        if (locusId != null) {
            builder.setLocusId(locusId)
        }

        return builder.build()
    }
}

Those are the main bits. If needed I can edit to include the MainActivity and the Application class that they are called from, let me know if needed, but these are where the notification is built.

for reference only: this is the previous implementation which would bubble:

package cloud.trotter.dashbuddy.ui

import android.app.ActivityOptions
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import cloud.trotter.dashbuddy.BubbleActivity
import cloud.trotter.dashbuddy.DashBuddyApplication
import cloud.trotter.dashbuddy.R
import java.util.Date

class Bubble : Service() {

    private lateinit var icon: IconCompat
    private lateinit var person: Person
    private lateinit var shortcut: ShortcutInfoCompat

    companion object {
        const val CHANNEL_ID = "bubble_channel"
        const val NOTIFICATION_ID = 1
        private const val TAG = "BubbleService"
    }

    override fun onCreate() {
        super.onCreate()
        Log.d(TAG, "onCreate: BubbleService created")
        icon = IconCompat.createWithResource(
            DashBuddyApplication.context, R.drawable.bag_red_idle)
        person = Person.Builder()
            .setName("DashBuddy")
            .setIcon(icon)
            .setImportant(true)
            .build()
        shortcut = ShortcutInfoCompat.Builder(DashBuddyApplication.context, "DashBuddy_Shortcut")
            .setLongLived(true)
            .setIntent(Intent(DashBuddyApplication.context, BubbleActivity::class.java).apply {
                action = Intent.ACTION_VIEW
            })
            .setShortLabel("DashBuddy")
            .setIcon(icon)
            .setPerson(person)
            .build()
        ShortcutManagerCompat.pushDynamicShortcut(DashBuddyApplication.context, shortcut)
        DashBuddyApplication.bubbleService = this
        createNotificationChannel()
    }

    @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        Log.d(TAG, "onStartCommand: BubbleService started")
        val notification = create("Started!")
        post(notification)
        startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
        return START_STICKY
    }

    override fun onBind(intent: Intent?): IBinder? {
        return null
    }

    private fun createNotificationChannel() {
        val channel = NotificationChannel(
            CHANNEL_ID,
            "Bubble Channel",
            NotificationManager.IMPORTANCE_HIGH
        ).apply {
            description = "Channel for Bubble Notifications"
        }
        val notificationManager =
            getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        notificationManager.createNotificationChannel(channel)
    }

    fun post(notification: Notification) {
        DashBuddyApplication.notificationManager.notify(NOTIFICATION_ID, notification)
    }

    private fun getActivityOptionsBundle(): Bundle {
        val activityOptions = ActivityOptions.makeBasic()

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
            activityOptions.setPendingIntentBackgroundActivityStartMode(ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED)
        }
        return activityOptions.toBundle()
    }

    fun create(message: String): Notification {
        Log.d(TAG, "createNotification: Creating notification")

        val builder = NotificationCompat.Builder(DashBuddyApplication.context, CHANNEL_ID)

        val target = Intent(DashBuddyApplication.context, BubbleActivity::class.java)
//        target.putExtra("ActivityOptions",getActivityOptionsBundle())
        val bubbleIntent = PendingIntent.getActivity(
            DashBuddyApplication.context, 0, target,
            PendingIntent.FLAG_UPDATE_CURRENT
                    or PendingIntent.FLAG_MUTABLE
//                    or getActivityOptionsBundle()
//                    or PendingIntent.FLAG_ALLOW_BACKGROUND_ACTIVITY_STARTS
        )

        val bubbleMetadata = NotificationCompat.BubbleMetadata.Builder(bubbleIntent, icon)
            .setDesiredHeight(400)
            .setSuppressNotification(true)
            .setAutoExpandBubble(true)
            .build()

        with(builder) {
            setBubbleMetadata(bubbleMetadata)
            setStyle(
                NotificationCompat.MessagingStyle(person).addMessage(
                    NotificationCompat.MessagingStyle.Message(
                        message,
                        Date().time,
                        person
                    )
                )
            )
            setShortcutId(shortcut.id)
            addPerson(person)
        }

        with(builder) {
            setContentTitle("DashBuddy")
            setSmallIcon(
                icon
//                IconCompat.createWithResource(DashBuddyApplication.context, R.drawable.dashly)
            )
            setCategory(NotificationCompat.CATEGORY_MESSAGE)
            setContentIntent(bubbleIntent)
            setShowWhen(true)
        }

        return builder.build()
    }
}

Solution

  • The issue was that I was using the same notification ID as the bubbles for the service.

    Once I separated the service notification from the bubbles notifications, everything works as intended.