We recently rolled out support for Conversation Bubbles for DMs and Group DMs on Android 11.

In case you’re not familiar with Conversation Bubbles, take a look at the video below. Basically, they are a way to pop out a conversation from a notification into a Bubble that will draw over other apps, making multitasking while chatting easy and comfortable.

This post will describe how we built support for these bubbles, the challenges we faced along the way, and how we overcame them. Some details were simplified where it made sense to, but stayed relatively close to our actual implementation overall.

Prerequisites

According to the official Android documentation, there are three basic requirements for supporting Bubbles from a notification:

  1. The notification needs to be built using MessagingStyle, the native Android API for messaging notifications.
  2. The notification needs to have an associated Sharing Shortcut.
  3. There needs to be a BubbleMetadata object set on the notification, pointing to a resizable, embedded Activity.

MessagingStyle

MessagingStyle was introduced in Android 7 and gives developers a way of building notifications for messaging apps using structured data. This was a huge step forward from the previous approach where developers had to style their own messaging notifications using spans and string concatenation. Luckily, we had recently rewritten our notification code and migrated it to MessagingStyle.

Sharing Shortcuts

After we had MessagingStyle in place, the next step was to add support for Sharing Shortcuts. Supporting this feature was a high priority for our Notification team because they are not just a requirement for Bubbles, but also for Notifications showing up in the Conversations space of the notification shade.

Our basic flow is to create a sharing shortcut for every notification pushed and every conversation opened in the app. However, there’s some tricky details that warrant further discussion:

Icons

Shortcuts in Android come with icons, which are displayed in three main places: On top of a notification, in the launcher when long-pressing the app icon, and in the sharing sheet. Ideally, those icons should clearly represent the conversation at hand.

For Direct Messages, finding the right icon was trivial — we just use the user’s avatar! For Group DMs, however, it was a bit trickier. We decided to go with what seems to have established itself as a bit of a platform convention: Two circular avatars on a white background.

To achieve this icon, we fetch avatars from two users in the conversation and then draw them on a bitmap canvas. We then save that bitmap as a png in our app’s cache so we can access it next time we need to create a shortcut for that conversation.

Sample Group DM Icon
Sample Group DM Icon

Asynchronous icon updates

Since we are creating these shortcuts from our notification codepath, execution time is a major concern. Not only do we want to minimize latency so our users will get messages as quickly as possible, but we also only have limited time to process the push before Android’s Background Execution Limits kick in.

To solve for this, we don’t set Sharing Shortcut icons from the notification code path unless we have them cached already. Instead, we create a Sharing Shortcut with a placeholder icon and then create a JobScheduler job to asynchronously download the avatars, create the shortcut icon, and update the shortcut with the new icon.

Sharing Shortcut Icon Flowchart
Sharing Shortcut Icon Flow

Different Activities for the same Shortcut

Officially, the Shortcut API only supports setting one Activity as a Shortcut’s target, which was an issue for us as we have three different UIs that we want to display based on how the Shortcut was launched:

  1. Compose for when the user uses the Android share sheet to share a file or some text into Slack
  2. Messages to display a conversation when the user opens a shortcut from the launcher by long-pressing the app icon.
  3. Bubbles for when the user presses the “Bubble” icon on a notification.
The Three Entry points for Sharing Shortcuts
The three entry points for Sharing Shortcuts

To achieve this, we had to resort to a Trampoline Activity: The activity we set as the target for the shortcut only has one job: Determine which of these three cases is happening, and then open the corresponding UI before closing itself.

To determine whether the user is sharing into Slack is simple: we can just check if intent.action is either SEND or SEND_MULTIPLE.

Determining if we’re launching into a Bubble is a bit trickier: Android 11 does not provide an official way to tell your shortcut is being opened by a Bubble.

However, we found a solution to this issue that, while hacky, works really well! Digging through the Android source code for Bubble implementation details we might be able to use, we found that they are implemented by rendering on a virtual display, with the hardcoded name prefix of TaskVirtualDisplay.  This was all we needed to write some code that tells us whether or not we’re opening into a Bubble:

private const val BUBBLE_DISPLAY_NAME_PREFIX = "TaskVirtualDisplay"

private fun isOpeningBubble(): Boolean {
  return Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
    display?.displayId != Display.DEFAULT_DISPLAY &&
    display?.name?.startsWith(BUBBLE_DISPLAY_NAME_PREFIX) == true
}

Starting with Android 12, our friends at Google added a new API that we can use instead!

Sharing Shortcut Flow
Sharing Shortcut Flow

Bubbles

Now that we have all the prerequisites out of the way, we can finally look at implementing the Bubble itself.

The basics

Once you have a share shortcut and your notifications are using MessagingStyle, adding basic Bubbles support is pretty straightforward.

All you need to do is set BubbleMetadata on your notification to let Android know it can render the notification as Bubble:

val bubbleMetadata = NotificationCompat.BubbleMetadata.Builder(shortcutId)
    // Android automatically picks the minimum between available space and desired height.
    .setDesiredHeight(Integer.MAX_VALUE)
    .build()
notificationBuilder.setBubbleMetadata(bubbleMetadata)

That’s it! Well, kind of. Despite having a basic Bubble working now, there were still quite a few steps we had to take until this was a finished feature:

Making sure the Bubble displays the right content

What we display in a Bubble is basically the same thing we display when you view a conversation in the main app. However, there is one key difference: In the main app, the user can freely navigate wherever they want.

This behavior was not desirable for the bubble, which is a representation of one specific conversation. Imagine how confusing the experience would be if we let you navigate freely and you ended up in a state where the Bubble with one coworker’s avatar on it actually contained your conversation with another coworker!

To prevent this from happening, we did two things:

  1. The easiest thing to do in most cases was to just disable navigation in the Bubble wherever we could. Since Bubbles are lightweight representations of a conversation, we simply disabled a lot of click listeners that would let the user navigate to other parts of the app.
  2. However, there’s some flows that we still wanted to support from within the Bubble, such as sharing a message. To support these while making sure the Bubble doesn’t get stuck in a broken state, we decided to open those flows outside the Bubble, in the main app.
    New intents opened from a Bubble open in the Bubble by default. However, we found out that an Intent with Flag.ACTIVITY_NEW_TASK set opens outside the Bubble.

With these two measures in place, we can ensure a Bubble always displays the conversation it is linked to.

Bubbles and dismissing notifications

Since nobody wants to see their notification drawer full of notifications for Slack messages they’ve already read, we built a mechanism to cancel notifications as soon as their corresponding messages are read on any device.

Unfortunately, there is an implicit behavior with Bubbles and notifications we didn’t know about until we actually built this: If you cancel a notification with an associated Bubble, the Bubble will be dismissed, too!

This resulted in a somewhat amusing bug until we figured out what was going on: The user would open the bubble for a conversation, that channel would be marked as read because we rendered it in the Bubble and then our notification clearing mechanism would kick in and immediately cancel the notification and the Bubble with it.

Thankfully, this was pretty easy to fix: We just check if there is an active Bubble associated with a notification before cancelling it.

The Slack web socket and Bubbles

Once we finally had Bubbles rendering, we had to make sure they worked properly with all our messaging infrastructure.

The Slack app uses a websocket for two-way communication with our backend. A websocket connection is associated with one specific workspace (referred to as team in our codebase) and only sends and receives data related to its workspace.

To support team switching, and to make sure we’re always connected to the right socket, we have a class called ActiveTeamDetector, which is implemented as simple ActivityLifeCycleCallbacks.

Activities implement a simple interface to communicate which team they’re associated with:

interface ActiveTeamEmitter {
  fun activeTeam(): Observable<String>
}

ActiveTeamDetector then takes that info and makes it available to other parts of our infra, including our socket connection manager:

class ActiveTeamDetector : Application.ActivityLifecycleCallbacks {

  /**
   * Streams the active team_id and guarantees a default value of [Team.NO_TEAM].
   */
  private val activeTeamRelay = BehaviorRelay.createDefault(Team.NO_TEAM)

  override fun onActivityResumed(activity: Activity) {
    if (activity is ActiveTeamEmitter) {
      activity.activeTeam().subscribe(activeTeamRelay)
    } else {
      activeTeamRelay.accept(Team.NO_TEAM)
    }
  }

  fun activeTeam(): Observable<String> = activeTeamRelay.distinctUntilChanged()
}

This all worked great… until we implemented Bubbles. Bubbles completely broke this model as you could have a Bubble for Team A rendering over the main app displaying Team B.

If you collapsed the Bubble, we would effectively be displaying Team B, but without a new onResume getting called to keep our ActiveTeamDetector up-to-date. We’d be showing Team B while the socket was connected to Team A and all sorts of bad things would happen.

Initially we thought we might have to overhaul our entire messaging infrastructure to support multiple simultaneous socket connections, which would have been a giant addition to the scope.

Thankfully, we found a simpler, if slightly less elegant solution: Because you can’t use the main app while the Bubble is drawing over it anyways, we figured we could get away without actually supporting multiple socket connections as long as we fix the scenario described above where collapsing a Bubble would lead to the main app entering a broken state.

To do so, we had the following idea: While we won’t get a new onResume event from the main app if the Bubble that’s drawing over it gets collapsed, we will get an onPause from the Bubble itself.

With that in mind, we could just store the value of ActiveTeamDetector before the Bubble was opened and then re-emit that once the Bubble is paused, thus resetting the active team to whatever it was before the Bubble was opened.

This simple addition to ActiveTeamEmitter did the trick:

override fun onActivityPaused(activity: Activity) {
  if (activity is OnPauseTeamEmitter) {
    activity.activeTeamWhenPaused().subscribe(activeTeamRelay)
  }
}

Android 11 Work Profiles and Bubbles

This was it, we thought. Bubbles were finally working perfectly on our emulators and we couldn’t wait to start using them ourselves and start sharing what we’ve built with our coworkers that are dogfooding the app.

So we installed the latest Build on our work phones, flipped the feature flag, clicked the Bubble icon on a notification aaaand… nothing happened.

We double and triple checked the feature flag, our code, and everything else we could think of. This was working perfectly on our emulators — why wasn’t it working on our phones? What’s the difference?

After a lot of sleuthing, we figured it out: The Slack app on our phones was running in an Android Work Profile, as mandated by our IT policy. Our emulators weren’t. So we did some more digging and discovered a bug in Android 11 that meant Bubbles did not work on Work Profiles. It wasn’t just that the Bubbles icon wouldn’t show up either, it was even worse: the icon would show up, but clicking it wouldn’t do anything.

Unfortunately, this meant that we wouldn’t be able to ship Bubbles to Android 11 users on Work Profiles. For a while, we were concerned it might mean we wouldn’t be able to ship on Android 11 at all as we didn’t know of a good way to programmatically check if we were running in a Work Profile and we figured we might have a fair number of users in Work Profiles, seeing how we’re a workspace productivity app.

Thankfully, Android 11 also finally added an easy way to figure out the answer to those questions: UserManager.isManagedProfile(). With this method, we could easily check whether we were in a Work Profile and use that to gate functionality on Android 11. We could also use it to collect some stats on Work Profile usage – turns out only around 5% of our Android 11 users are using Slack in a Work Profile.

With that last hurdle out of the way, we were finally ready to launch Bubbles!

We recently verified this bug has been fixed in the Android 12 Developer Preview – so our Android 12 users will be able to enjoy Bubbles even in Work Profiles!

If you like working on stuff like this, we’re hiring!