Stack logo
Sync up on the latest from Convex.

Introducing Convex for Android

Image of phone with gears and pencil next to it to represent mobile dev.

On behalf of the team at Convex, I’m happy to announce the initial release of Convex for Android!

Over the last 10 years, I’ve had the chance to help build some of the biggest Android apps in the world. In a lot of ways, I grew as a developer along with the Android platform as it has matured. Like any growing process, there were bumps along the way, but by and large I have learned a lot about what good application and API design looks like.

Recently I’ve had the exciting opportunity to work with the team here at Convex to bring mobile support to their reactive backend platform. It was my goal to create an API that both feels natural to Android developers who have kept up with modern application architecture principles, as well something that feels like a natural fit in the Convex ecosystem. Ideally it should disappear into the background as your app takes shape around it.

What follows is a walkthrough of how you can integrate Convex for Android into an app written in Kotlin.

Getting started

It’s really easy to hack together a quick example app with the library, as we demonstrate in the Android quickstart. If you want to go deeper and see how to structure a more realistic application with Android and Convex, read on to learn how a chat app like the one in the official Convex tour might be built. You’ll learn the fundamentals to create something that works like this:

ConvexClient - your data source

At the heart of Convex for Android is the ConvexClient. It wraps the official convex-rs Rust client and handles the connection and communication with the backend. Your application should keep a single reference to a ConvexClient and use it whenever it needs to communicate with the backend. To create a client, simply pass it the Convex deployment URL you want it to connect to.

val convex = ConvexClient(DEPLOYMENT_URL)

ConvexClient provides methods like mutation to trigger updates to your data based on passed in arguments and subscribe to reactively receive results from a Convex query.

Kotlin data classes for your data

It’s idiomatic to represent structured data as a data class in Kotlin. Not surprisingly they’re a great fit for the chat messages our app will send and receive and they work well with the ConvexClient too. Here’s a short data class for the chat messages:

@Serializable
data class ChatMessage(
    val author: String,
    val body: String,
)

The class is @Serializable so it can be marshaled across the FFI boundary between the Kotlin and Rust layers of the library. The author and body properties allow the ChatMessage to match the “shape” of the data stored in the backend messages table.

A Repository to wrap ConvexClient

Now that we have our data source and a class to represent the data, we need somewhere to tie them together. In a modern Android application that is done in a Repository class. Our ChatRepository will contain the business logic required to implement a simple chat client: sending and receiving messages.

class ChatRepository(private val client: ConvexClient) {
    /** Sends the given message to the backend. */
    suspend fun sendMessage(message: ChatMessage) {
        client.mutation(
            "messages:send",
            mapOf(
                "body" to message.body,
                "author" to message.author,
            )
        )
    }

    /**
     * Subscribes to a Flow of messages from the backend.
     *
     * The Flow will be updated whenever a new message is posted.
     */
    suspend fun getMessages(): Flow<Result<List<ChatMessage>>> =
        client.subscribe<List<ChatMessage>>("messages:list")
}

A typical Repository layer will likely be pretty thin when using Convex. The ConvexClient provides fairly rich and high level functionality that the Repository exposes with a slightly more friendly API. If you want, you can extract an interface for your Repository and use it in place of concrete implementations. That makes it easier to switch to different data storage implementations (like a fake for testing) while keeping your UI layer blissfully ignorant of those lower level details.

That’s actually about it for the Convex-specific integration! The rest of the walkthrough shows how to neatly wire this functionality into a reactive Android UI.

A custom Application class for holding the Repository

It’s convenient to provide access to instances of your Repository class(es) in a custom Application class. The custom class can be accessed at runtime and used to pass the Repository instance to other code that needs it.

class ChatApplication : Application() {
    lateinit var repository: ChatRepository
    override fun onCreate() {
        super.onCreate()
        val convex = ConvexClient(DEPLOYMENT_URL)
        repository = ChatRepository(convex)
    }
}

A ViewModel to bridge the Repository and your UI

Whether you build your UI with standard Android Views or with a modern reactive style using Jetpack Compose, the core of your UI state should live in a ViewModel. The ViewModel is the source of truth for your UI. It provides all of the data to render it and methods to perform any functionality it exposes to users. The ViewModel will also contain the in-memory state for your UI, comprised of:

  1. Data received from the Repository for use in the UI
  2. Data received from the UI for eventual transmission to the Repository

To start, let’s create a ChatViewModel to expose a custom UiState object that will be consumed by the UI to update the appearance of the app with messages received from the backend and an outgoing message.

data class UiState(
    val outgoingMessage: String,
    val messages: List<ChatMessage>,
)

class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {
    private val _outgoingMessage: MutableStateFlow<String> = MutableStateFlow("")
    private val _uiState: MutableStateFlow<UiState> = MutableStateFlow(
        UiState(
            outgoingMessage = "",
            messages = emptyList(),
        )
    )
    val uiState: StateFlow<UiState> = _uiState
}

The actual UiState that is produced by the ViewModel will change over time; as new messages are received from the backend and as the user enters new messages to send.

Modeling changes to the data over time is a great fit for Kotlin’s Flow type. If you’re not familiar with Flow, think of it as a Future or Promise that can “complete” multiple times whenever the upstream producer of data emits a new value.

Above we use MutableStateFlow<String> and MutableStateFlow<UiState> which represent flows that will emit a given value and always return their latest state to a consumer. In both uses, an initial value is passed in to be used as the value emitted by the flow before any real data is produced.

Let’s update the code to populate the uiState flow with messages received from Convex.

class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {		    
    init {
        // When the ViewModel is initialized, collect the Flow of messages from
        // the Repository and set a new _uiState.value each time the data changes.
        viewModelScope.launch {
            chatRepository.getMessages().collect {
                incoming ->
	                _uiState.value = UiState(
	                    messages = incoming.orElse(emptyList()),
	                    outgoingMessage = ""
	                )
            }
        }
    }
}

That code is enough to ensure that the public uiState value will always contain the latest messages from the backend. We can make use of that in a Jetpack Compose UI. The following code will observe changes to uiState and automatically re-render the list of ChatMessage values when there’s a change:

@Composable
fun MessageList(val viewModel: ChatViewModel) {
    val uiState by viewModel.uiState.collectAsState()
    LazyColumn {
        items(uiState.messages) { message ->
            Text(
                text = message.body
            )
        }
    }
}

Receiving messages and rebuilding the UI is great, but let’s add the ability to send messages too. We already have a MutableStateFlow in our ViewModel to store an outgoing message. Now we’ll add methods to update that value and send it to the backend.

class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {		    
    /** Called as the user enters text to compose an outgoing message. */
    fun updateOutgoingMessage(message: String) {
        _outgoingMessage.value = message
    }

    /** Called to send a composed outgoing message. */
    fun sendOutgoingMessage() {
        viewModelScope.launch {
            chatRepository.sendMessage(
                ChatMessage(
                    author = "Android Demo User",
                    body = _outgoingMessage.value
                )
            )
            // Clear the outgoing message after it's sent.
            _outgoingMessage.value = ""
        }
    }
}

Let’s hook those methods up in the UI. We’ll create a simple Row with a TextField and an ElevatedButton.

@Composable
fun OutgoingMessage(val viewModel: ChatViewModel) {
		val uiState by viewModel.uiState.collectAsState()
    Row{
        TextField(
            value = uiState.outgoingMessage,
            onValueChange = viewModel::updateOutgoingMessage
        )
        ElevatedButton(
            onClick = viewModel::sendOutgoingMessage
        ) {
            Text(text = "Send")
        }
    }
} 

Now the OutgoingMessage UI will send changes in the TextField to the ChatViewModel and will also update its state when new UiState is emitted (e.g. outgoingMessage cleared after send).

There’s a problem though. The UiState exposed by the ChatViewModel only updates when new messages are received from the backend.

The flow without combineThe flow without combine

The TextInput in the UI won’t reflect what’s typed on the keyboard. Let’s change it so it updates whenever the outgoingMessage changes too.

Again, we’ll make use of Flow and use the combine operator to take the latest ChatMessage values from Convex and input from the UI and create one flow of UiState from their data.

class ChatViewModel(private val chatRepository: ChatRepository) : ViewModel() {		    
    init {
          // When the ViewModel is initialized, combine the messages from
          // the Repository and the the outgoing message into a new Flow and
          // collect to build a new _uiState.value each time either source changes.
	      viewModelScope.launch {
	          combine(_outgoingMessage,
	          chatRepository.getMessages()) { outgoing, incoming ->
	              UiState(
	                    messages = incoming.orElse(emptyList()),
	                    outgoingMessage = outgoing
	                )
	          }.collect { uiState ->
	              _uiState.value = uiState
	          }
        }
    }
}

There we go! Now when either _outgoingMessage or chatRespository.getMessages() receives new data, the UI will rebuild with the latest data from each source.

The flow with combineThe flow with combine

Where to next?

The walkthrough above left out some details for the sake of brevity. The app in the demo video shown near the beginning is nicely styled and has support for signing in and signing out, and the walkthrough code left those details out.

If you’re interested in building out a compelling UI using Jetpack Compose, take a look at the official Jetpack Compose Basics codelab.

If you want more details on how to use Convex with Android, check out the official docs and the source code for a multi-screen example app with auth support called Workout Tracker.

As for me, I’m excited to keep working on bring Convex to more mobile platforms! Keep an eye out for a follow on release for iOS and let us know if you hit any bugs or have a feature request by filing an issue in the convex-mobile Github repo.

Build in minutes, scale forever.

Convex is the sync platform with everything you need to build your full-stack project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.

Get started