Announcing Stream’s Android v5.0.1 Chat SDK

...

Over the last several months, our team has been hard at work upgrading v5 of our Android Chat SDK! 🚀🎉

Android v5 Chat SDK feature image

We added some major improvements (and quality of life fixes) to our v5.0.1 update with a focus on the following items:

  • Merging the ChatClient and ChatDomain classes, and adding a new OfflinePlugin class
  • New image and name properties for Channel and User models
  • Improved UI Components customization
  • Stable Jetpack Compose UI Components

We recommend migrating to v5.0.1 as soon as possible, but it’s worth noting that we will continue to support v4 through the end of September 2022.

Check out the changelog to see everything included in the update, or keep reading to see what each of these improvements means from a user-experience point of view.

Merging the ChatDomain and ChatClient Classes

The ChatDomain class allowed users to make offline requests and mirrored a lot of the ChatClient class. To simplify the API, we are replacing ChatDomain with the new, easy-to-use OfflinePlugin. 🎉

The OfflinePlugin class employs a new caching mechanism powered by side-effects we applied to ChatClient functions. Now, you only need to use the ChatClient interface and configure the OfflinePlugin to provide offline support.

The plugin should be provided to ChatClient.Builder instance and all you need to do is provide a configuration class:

val offlinePlugin = StreamOfflinePluginFactory(
    appContext = context,
    config = Config(
        backgroundSyncEnabled = true,
        userPresence = true,
        persistenceEnabled = true,
        uploadAttachmentsNetworkType = UploadAttachmentsNetworkType.NOT_ROAMING
    )
)

val client = ChatClient.Builder(apiKey, context)
    .withPlugin(offlinePlugin)
    .build()

Besides caching, the new offline library exposes a set of StateFlows, which can be used for current state observation, grouped in several state-related objects. Depending on the use case, you can choose from:

  • QueryChannelsState - Contains state related to a single query channels request.
  • ChannelState - Contains state related to a single channel.
  • ThreadState - Contains state for a single thread within the channel.
  • GlobalState - Contains a set of global information for the current user.

State objects can be accessed using the extension functions below:

// Returns QueryChannelsState object based on filter and sort used to query channels without 
// performing query channels API call
val queryChannelsState = chatClient.state.queryChannels(filter = filter, sort = sort)

// Returns ChannelState object for a given channel without performing an API call
val channelState = chatClient.state.channel(channelType = "messaging", channelId = "sampleId")

// Returns ThreadState object for a thread associated with a given parentMessageId
// without performing an API call
val threadState = chatClient.state.thread(messageId = "parentMessageId")

// Gives you access to GlobalState object
val globalState = chatClient.globalState

// Returns StateFlow<QueryChannelsState?> object and performs queryChannels request
val queryChannelsState: StateFlow<QueryChannelsState?> = chatClient.queryChannelsAsState(request = queryChannelsRequest, coroutineScope = scope)

// Returns StateFlow<ChannelState?> object and performs watchChannel request
val channelState: StateFlow<ChannelState?> = chatClient.watchChannelAsState(cid = "messaging:sampleId", messageLimit = 30, coroutineScope = scope)

// Returns ThreadState object for a thread associated with a given parentMessageId
val threadState: ThreadState = chatClient.getRepliesAsState(messageId = "messaging:sampleId", messageLimit = 30, coroutineScope = scope)

If you want to learn more about the OfflinePlugin class, check out our documentation and migration guide.

Introducing image and name Properties to Channel and User models

Prior to v5.0.1, you could only access image and name properties from the extraData object. Now, you can grab both of these properties in the Channel and User objects:

// The old way
val userName = user.extraData["name"]
val userImage = user.extraData["image"]
val channelName = channel.extraData["name"]
val channelImage = channel.extraData["image"]

// The new way
val userName = user.name
val userImage = user.image
val channelName = channel.name
val channelImage = channel.image

Decoupled and Customizable ViewHolders

To give developers more control and customization of our UI Components, we decoupled ViewHolders for attachments and messages so that each attachment type represents a singular ViewHolder.

This follows our Compose API design and makes it easier for you to customize attachments to your ViewHolders’ look and feel.

The first ViewHolder to examine is the ImageViewHolder.

ImageViewHolder

We used to display both Giphies and image attachments with the MediaAttachmentViewHolder. But, using the same ViewHolder introduced janky behavior like mouse scroll jumping.

To fix this, we split the MediaAttachmentViewHolder into two separate ViewHolders:

  • ImageAttachmentViewHolder
  • GiphyAttachmentViewHolder

We also have a special ImageAttachmentView and GiphyMediaAttachmentView to handle the respective media content within each view. You can explore these views and their signatures and styles here:

The behavior of the image holders shouldn’t change, but we plan to introduce even more improvements, such as image resizing and size optimizations, so be sure to watch out for that in the future!

The second piece of the “media” ViewHolder used to display GIFs, so let’s explore the GiphyViewHolder.

GiphyAttachmentViewHolder

With our separated GiphyAttachmentViewHolder and improvements, we’ve managed to optimize the way Giphies load. Previously they resized to match the image size, which could cause scrolling performance issues.

Additionally, the Giphy that was loaded was the original sized one, meaning more data and time was required to show the GIF.

Now, you can specify which type of Giphy you want to load, based on the size and quality. You can also define if you want the GIFs to be cropped or to be centered to match the original aspect ratio of the image.

<style name="StreamUi.MessageList.GiphyMediaAttachment" parent="StreamUi">
    <item name="streamUiGiphyMediaAttachmentProgressIcon">@drawable/stream_ui_rotating_indeterminate_progress_gradient</item>
    <item name="streamUiGiphyMediaAttachmentGiphyIcon">@drawable/stream_ui_giphy_label</item>
    <item name="streamUiGiphyMediaAttachmentPlaceHolderIcon">@drawable/stream_ui_picture_placeholder</item>
    <item name="streamUiGiphyMediaAttachmentImageBackgroundColor">@color/stream_ui_literal_transparent</item>
    <item name="streamUiGiphyMediaAttachmentGiphyType">fixedHeight</item>
    <item name="streamUiGiphyMediaAttachmentScaleType">fitCenter</item>
</style>

As you can see, you can change the streamUiGiphyMediaAttachmentGiphyType and streamUiGiphyMediaAttachmentScaleType to apply changes to the way the Giphy looks.

We recommend using these default settings, as they keep the aspect ratio of the original GIF and use a fixedHeight GIF version, which is much smaller than the original (in data size) and doesn’t cause scrolling issues.

Additionally, you could use fixedHeightDownsampled if you want a GIF that only holds up to 6 frames and is around 6x smaller in data size so it loads much faster and causes less traffic.

GIF load comparison

The representation on the left is using a fixed height container, which fits the GIF to the container and prevents cropping and UI jumps when scrolling. This is the new and suggested behavior as it’s faster, more performant, and doesn’t cause frame drops or UI jumps. However, there might be borders around the GIF that help us preserve the aspect ratio.

The representation on the right is using the old approach, where we apply a centerCrop style of loading. The GIF will fully fill the container but disregard the aspect ratio. In some cases, GIFs loaded this way might have a nicer look and feel, but the cropping might remove important parts of the image.

Additionally, when scrolling, as the GIFs are resizing in height, you’ll most likely encounter jumping and poor performance. Again, this used to be the default behavior, but now we suggest using the fixedHeight approach.

The next piece of the puzzle is the LinkAttachmentsViewHolder.

LinkAttachmentsViewHolder

This LinkAttachmentsViewHolder ViewHolder is used to display messages that contain link attachments and no other types of attachments. Check out the LinkAttachmentView for more information.

The last part of default holders is the FileViewHolder. Let’s see how it works.

FileAttachmentsViewHolder

The FileAttachmentsViewHolder represents files and hasn’t changed much, it’s just been decoupled. Check out the FileAttachmentsView file for more information. You can check its style in the FileAttachmentsViewStyle class.

In case you’re looking for custom attachments and messages, they are still supported using our factories.

CustomAttachmentsViewHolder

With v5.0.1, we have a dedicated CustomAttachmentsViewHolder for custom attachments, meaning the logic for custom factories is much simpler than before. Now, you can provide an AttachmentFactoryManager that holds multiple factories for building attachment content.

It provides the following API:

public class AttachmentFactoryManager(
    private val attachmentFactories: List<AttachmentFactory> = listOf(),
) {

    public fun canHandle(message: Message): Boolean {
        return attachmentFactories.any { it.canHandle(message) }
    }

    public fun createViewHolder(
        message: Message,
        listeners: MessageListListenerContainer?,
        parent: ViewGroup,
    ): InnerAttachmentViewHolder {
        val factory = attachmentFactories.first { it.canHandle(message) }
        return factory.createViewHolder(message, listeners, parent)
    }
}

Let’s look at the methods used in this code snippet:

  • attachmentFactories: Provides a list of factories that checks if given attachments can be transformed in a custom ViewHolder.
  • canHandle: Checks if any of the factories can handle and consume an attachment to provide a custom holder.
  • createViewHolder: Creates a ViewHolder based on the first custom attachment factory that can handle it.

To override the factory, pass it into ChatUI as seen in the example below:

ChatUI.attachmentFactoryManager = AttachmentFactoryManager(myCustomFactories)

These factories can be anything – from custom audio previews, to hidden/sensitive spoiler content, medical information, calendar or booking information, maps, and much more!

Stable Jetpack Compose UI Components

Lastly, we’re very proud to say that our Compose API is stable! Truth be told, most of the Compose API has been stable for a while now. We’ve been busy polishing and adding more features to specific components that give you more flexibility and customization options.

Again, there are a few important rocks in our latest Compose changes:

  • Scrolling behavior and control
  • Reaction types
  • Slot APIs
  • Accompanist tools

Let’s see what’s in store for you when it comes to scrolling behavior.

Scrolling Behavior APIs

We added several scrolling behavior APIs that help you control how and when your ChannelList and MessageList components scroll.

To control the behavior of these lists, you can use the new lazyListState parameter in these components, which allows you to define a custom state that responds to your custom UI events.

Additionally, if you want to override or customize the default scrolling content - such as the scroll to bottom/top buttons - you can use the new Slot APIs that we provide in these list components, called helperContent.

helperContent is a BoxScope composable that’s shown above the list. You can use it to show dialogs, bottom sheets, FABs, or anything that might suit your needs.

The default implementation is an empty list for the ChannelList, or a “scroll to bottom” button for the MessageList, but you can remove this behavior or override it as seen in the code below:

ChannelList(
    modifier = Modifier.fillMaxSize(),
    viewModel = listViewModel,
    onChannelClick = onItemClick,
    onChannelLongClick = { listViewModel.selectChannel(it) },
    helperContent = {
       // My custom UI
    }
)

Improved ReactionTypes

We finalized and improved the ReactionTypes API so that it allows complex reactions.

This means you can implement reactions that represent uploaded images and icons or custom animated emojis, just like on Slack, Twitch, and Discord! It’s very simple with our new API, which exposes a ReactionIconFactory that you can see in the code below:

public interface ReactionIconFactory {

    /** Checks if the factory is capable of creating an icon for the given reaction type. */
    public fun isReactionSupported(type: String): Boolean

    /** Creates an instance of [ReactionIcon] for the given reaction type. */
    @Composable
    public fun createReactionIcon(type: String): ReactionIcon

    /** Creates [ReactionIcon]s for all the supported reaction types. */
    @Composable
    public fun createReactionIcons(): Map<String, ReactionIcon>
}

The ReactionIconFactory exposes three functions:

  • isReactionSupported: Verifies if a given reaction is supported or not in the system, which is useful if you have many reactions that are sometimes inaccessible for certain users.
  • createReactionIcon: Uses the key (reaction type) to build a ReactionIcon that represents the icon that’s shown in the UI.
  • createReactionIcons: Creates all available reaction icons that are supported in the app.

You can use any approach here that returns a reaction icon to support your use case. This can be resource-based items/drawables, local files, uploaded icons and images, small animated emoji/giphies, or something else.

The ReactionIcon looks like:

public data class ReactionIcon(
    val painter: Painter,
    val selectedPainter: Painter,
) {
    /** Returns either one of the [Painter]s depending on the reaction state. */
    public fun getPainter(isSelected: Boolean): Painter {
        return if (isSelected) selectedPainter else painter
    }
}

It allows you to define both a selected and unselected (regular) Painter that represents this image. This way you can easily have alternative images for the selected and unselected state.

We’ve also improved the number of our Slot APIs and the granularity in which you can customize the default slots and behavior of certain components. Let’s see how.

Improvements to Slot APIs

We’ve also improved our Slot APIs! Not only did we add helper content slots for scrolling, but we’ve also expanded the way you can customize message items. We now provide a MessageContainer that has slots for each default item type we provide:

@Composable
public fun MessageContainer(
    messageListItem: MessageListItemState,
    onLongItemClick: (Message) -> Unit = {},
    onReactionsClick: (Message) -> Unit = {},
    onThreadClick: (Message) -> Unit = {},
    onGiphyActionClick: (GiphyAction) -> Unit = {},
    onImagePreviewResult: (ImagePreviewResult?) -> Unit = {},
    dateSeparatorContent: @Composable (DateSeparatorState) -> Unit = {
        DefaultMessageDateSeparatorContent(dateSeparator = it)
    },
    threadSeparatorContent: @Composable (ThreadSeparatorState) -> Unit = {
        DefaultMessageThreadSeparatorContent(threadSeparator = it)
    },
    systemMessageContent: @Composable (SystemMessageState) -> Unit = {
        DefaultSystemMessageContent(systemMessageState = it)
    },
    messageItemContent: @Composable (MessageItemState) -> Unit = {
        DefaultMessageItem(
            messageItem = it,
            onLongItemClick = onLongItemClick,
            onReactionsClick = onReactionsClick,
            onThreadClick = onThreadClick,
            onGiphyActionClick = onGiphyActionClick,
            onImagePreviewResult = onImagePreviewResult
        )
    },
)

The DateSeparator, ThreadSeparator, SystemMessage, and MessageItem composables are the default items we provide in the list.

You can customize each item type individually and use the default implementation and slots for most of the items if you want. But you can also override the entire item to provide a custom UI as well.

In case you want to customize the content completely and remove some items, you can override the itemContent of the MessageList. This will give you more control over what will be shown in the UI, when, and how.

Finally, there’s a part of the Compose SDK that uses “experimental” APIs that we have to address.

Accompanist and Compose

To wrap up, let’s talk about experimental APIs and the parts of the Compose framework that just aren’t ready yet.

Our SDK is stable and we’re proud to say that we’re happy with our API and the way our customers reached out, especially with the feedback we’ve received, that helped us improve the customizability and depth we provide.

However, due to Compose framework restrictions, we still rely on some Experimental APIs in our components, such as:

  • Permissions
  • Grid API
  • Click/Touch Listeners

All of these APIs come either from Compose itself or the Accompanist set of packages.

It’s important to note that these experimental APIs are used internally for our components. In case they do change in the future, you won’t have to update your codebase – we’ll provide the updates on our end.

Unfortunately, we have to rely on these APIs for the time being, as they’re coming from the Jetpack Compose team and there are no alternatives available.

Recommending Compose

Because of all these powerful APIs and changes to the Compose SDK, we highly recommend it for new applications and anything that requires a more detailed level of customization.

Compose is here to provide numerous Slot APIs for you to build the app just how you want it while still keeping the behavior and business logic set up in case you want to rely on it!

Upgrade Now

If you want to upgrade to v5, refer to the v5 migration guide and reach out to our support team if you have any questions. We want everyone to get on board with the latest versions of our chat messaging SDKs.

If you have any feedback or feature requests, please submit an issue on GitHub — it will help us improve our Chat SDK and build the APIs you need.

Lastly, be sure to follow us on Twitter @getstream_io for all of our future updates.

Happy coding!