Building an Uber Clone in Jetpack Compose

In this article, I will explain how I built a functioning rideshare app for the Android platform using Stream’s Chat SDK. The Chat SDK is used both to power messaging between drivers and passengers as well as the ridesharing functionality of the app.

Nash R.
ryan_kay
Nash R. & ryan_kay
Published August 28, 2023

The core functionality of this app is as follows:

  • Basic user authentication and profile management (Chat SDK, FirebaseAuth, Firebase Storage)
  • Autocomplete destination searching for passengers (Google Places)
  • Drivers can view open ride requests which contain details on distance to the passenger and their destination (Chat SDK Channels)
  • Drivers and passengers can communicate during an active ride (Chat SDK Channels)
  • Drivers and passengers see each other’s positions (FusedLocationProvider, Chat SDK Channels)
  • Drivers and passengers may view directions to each other and the destination (Chat SDK Channels, Google Maps, Google Directions)
  • Driver and passenger user interface changes based on interactions across different (Chat SDk Channels and Events)

While the rideshare functionality of the app is complete, this project has not been optimized for multiple screen sizes. We also use only basic email and password authentication for user session management.

I suggest you treat it as something like a Minimum Viable Product (MVP) and hopefully a useful learning tool.

Prerequisites

  • Android SDK fundamentals (Fragments, Single Activity Architecture, Gradle)
  • Kotlin programming language fundamentals
  • You will need to set up a Firebase project and Google Cloud Services on your own to build the full app (however, this is not required to follow the tutorial!)
  • You will need to have a free Stream account and API key in order to use their SDK in the application.

Application Architecture

This application uses a blend of architectural concepts which I have taken from MVVM (Model-View-ViewModel) and MVI (Model-View-Intent). Let us take a moment to review the principles of these architectures.

Model-View-View-Model

Although every architecture works well in some situations and poorly in others, MVVM is a reasonably effective general purpose architecture for Android. There is no single MVVM architecture, but most implementations of it follow one of two approaches.

In the first approach, ViewModels expose data to Views with little to or no details on how that data should be presented in the Views.

This promotes reusable ViewModels which can be observed by different Views. The downside of this approach is that the Views must include these presentation details in the form of logic.

The second approach has the opposite benefit and weakness of the first. Instead of promoting reusability, ViewModels contain all or most of the presentation details of a specific View.

This means that ViewModels can handle the majority of the presentation logic, which will simplify the View. The obvious cost is that such a ViewModel is not typically reusable.

Although I would happily use the first approach if I saw a need for it, I typically apply the second approach, as I did in this application.

Creating a UI State Machine

Another principle which we will see in the more complex features of the application, namely Passenger Dashboard and Driver Dashboard, is to create a UI state machine. The idea is that we represent all or most of the “state” of a given View with some kind of model.

The state refers to the information that is shown on the screen like:

  • The locations of the driver and passenger to be drawn on the map
  • The name and avatar of a driver or passenger
  • Whether the driver is heading to pick up the passenger or both are heading to the destination

Creating a state machine is easy when everything runs in synchronous fashion. However, our application will be listening to multiple asynchronous data and event streams! We will see later how to build a state machine which accounts for this complexity.

How To Write Useful Use-Cases

Use-cases are one frequently argued about concepts in software architecture. In fact, the phrase “Clean Architecture” is likely one of the most argued about concepts in my twitter network.

In this project, we will apply my take on this idea which focuses on being pragmatic:

  • If a ViewModel would otherwise need to coordinate multiple BE services to perform a single function (i.e. logging in, which requires a request to - - FirebaseAuth and Chat SDK), we delegate that logic to a use-case
  • Otherwise, our ViewModels make calls to whatever service they require

This approach allows us to avoid useless use-cases which make a single call to a single backend service. However, we still get to hide details from the ViewModels that they do not need to know about when it is appropriate to do so.

A Brief Summary of Services

There are four primary services which drive the functionality of this application:

  • RideService manages our Chat SDK Channels which is what we use to drive the ridesharing functionality
  • UserService manages our Chat SDK users to store and manage their user data and their session state
  • AuthorizationService handles user session management in conjunction with UserService
  • PhotoService simply stores user avatar images so that we simply need to store a url to that photo within UserService

Assuming you looked at them, you will notice that they are interfaces. This affords you several benefits:

  • You may provide “fake” implementations to run and test the app without having to set up Firebase and so forth
  • You can switch to other authorization tools such as your own hand-written authorization server

Later on, we will look at the implementations we use in the app, which are available in the same package.

Compose & XML: Best of Both Worlds

Unless you have not been doing much Android development lately, you will be aware that the Android team has released a new UI framework called Compose. As someone who never enjoyed writing UI with XML, I was an early adopter of the framework since its alpha release.

Despite this, I do not form tribes over frameworks like many other developers do. While Compose has come a very long way and is usable in production for most common cases, it still has its limitations.

At the time of creating this application, Compose support for Google MapView was experimental at best. Having a fair amount of experience with MapView in XML, I decided to take the pragmatic path of using it for the two most complex features of the application: The dashboard features.

As a developer with nearly a decade of experience, I advise you to adopt a similar attitude to languages, frameworks, and tools. Do not treat them like sports teams; choose the right tool for the right job based on project requirements.

Compose for the Simple Things

The simplest features in this application are as follows:

  • Splash Screen
  • Sign In Screen
  • Sign Up Screen
  • Profile Screen

Given that none of these features had anything I felt that Compose could not support, these features were built via Compose. We will not be going into detail on the basics of this framework, but I will talk about how to set it up in a project which also uses XML, single Activity navigation, and MVVM architecture.

Each Compose feature is contained within a root Fragment. Although Fragments will likely become obsolete in pure Compose projects, I love them as containers for features of the application.

The role of these Fragments is to act as a navigation destination (more on that later), provide a ViewModel to the Composables, and to set the as the content to be displayed.

kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class LoginFragment : Fragment() { private val viewModel by lazy { lookup<LoginViewModel>()} override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { viewModel.toastHandler = { handleToast(it) } val backstack = backstack return ComposeView(requireContext()).apply { // Dispose the Composition when the view's LifecycleOwner // is destroyed setViewCompositionStrategy( ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed ) setContent { LoginScreen(viewModel) } } } }

Two import notes when working with composables within a Fragment are:

  • setContent { //… } is where we bind the composables to the Fragment’s View
  • View Composition Strategy: Passing ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed into setViewCompositionStrategy( //… ) ensures that our composables will be properly garbage collected according to the lifecycle of its parent Fragment

    You will find the composables in Kotlin files (.kt extension) typically suffixed with Screen. For example, take a look at LoginScreen.kt:

    kotlin
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @Composable fun LoginScreen( viewModel: LoginViewModel ) { //... SignupText( modifier = Modifier.padding(top = 32.dp), viewModel = viewModel ) }
    kotlin
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    @Composable fun SignupText( modifier: Modifier, viewModel: LoginViewModel ) { TextButton( modifier = modifier, onClick = { viewModel.goToSignup() }) { Text( style = typography.subtitle2, text = buildAnnotatedString { append(stringResource(id = R.string.no_account)) append(" ") withStyle( SpanStyle( color = color_primary, textDecoration = TextDecoration.Underline ) ) { append(stringResource(id = R.string.sign_up)) } append(" ") append(stringResource(id = R.string.here)) } ) } }

Sometimes I find it more convenient to manage the composable’s state in a ViewModel, and other instances to manage the composable’s state within the composables themselves. It is outside the scope of this tutorial, but check out ProfileSettingsScreen.kt for an example of how to manage state inside of your composables with remember delegates.

XML Is Not Deprecated Yet

At least as of writing this article, the Android XML based View system is not yet deprecated. Although it has plenty of pain points, it is also mature and stable if you know what you are doing.

Again, we will not be doing a deep dive into the View system, but I will provide some tips on how I built the most complex features of the application:

Similar to how I would encourage you to break down large composables into smaller ones (though I do not suggest you do that arbitrarily), I suggest you make use of tags to break down layouts into distinct components.

The map layout of this application is shared across both dashboards, is a perfect example of creating such a component:

xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:background="@android:color/white"> <com.google.android.gms.maps.MapView android:id="@+id/mapView" android:layout_width="0dp" android:layout_height="0dp" app:layout_constraintTop_toTopOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintDimensionRatio="5:3.5" /> <TextView android:layout_width="0dp" android:layout_height="wrap_content" android:id="@+id/subtitle" tools:text="@string/passenger_location" android:textSize="16sp" android:fontFamily="@font/poppins_medium" android:textColor="@color/color_light_grey" android:includeFontPadding="false" android:layout_marginStart="16dp" app:layout_constraintBottom_toBottomOf="@+id/cancelButton" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" /> <TextView android:layout_width="0dp" android:layout_height="wrap_content" android:id="@+id/address" tools:text="123 Idk Street, Markham, ON, Canada" android:textSize="18sp" android:fontFamily="@font/poppins_semi_bold" android:textColor="@android:color/black" android:includeFontPadding="false" android:layout_marginHorizontal="16dp" android:layout_marginTop="4dp" android:layout_marginBottom="16dp" app:layout_constraintTop_toBottomOf="@id/subtitle" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintBottom_toBottomOf="parent" /> <Button android:layout_width="wrap_content" android:layout_height="wrap_content" android:id="@+id/cancelButton" android:text="@string/cancel" android:textSize="14sp" android:fontFamily="@font/poppins_medium" android:textColor="@android:color/white" android:includeFontPadding="false" android:backgroundTint="@color/color_red" android:layout_marginHorizontal="16dp" android:layout_marginTop="8dp" app:layout_constraintTop_toBottomOf="@id/mapView" app:layout_constraintEnd_toEndOf="parent" /> <View android:layout_width="match_parent" android:layout_height="1dp" android:id="@+id/bottomDiv" android:layout_marginHorizontal="16dp" android:background="@android:color/black" android:alpha="0.12" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintBottom_toBottomOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout>

When we want to use this layout in some other layout file, we do so via the include tag. In the passenger dashboard layout, you can see how we include our map layout on line 100 of the XML:

xml
1
2
3
4
5
6
7
8
<include android:id="@+id/mapLayout" layout="@layout/view_map_layout" android:layout_width="0dp" android:layout_height="wrap_content" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" />

Building a Chat Screen With Stream’s Reusable Components

The entire driver-passenger chat feature of the application is something that I was able to build in an afternoon.

It consists of the following components:

  • A simple XML Fragment layout which uses some premade layouts from Stream’s UI components (note that they also provide Compose based components as well)
  • A Fragment to bind our layout to
  • Premade ViewModels from Chat SDK to manage all of the logic for us

    This process only took a few hundred lines of code to implement. First, using a ConstraintLayout, I added a MessageListView and MessageInputView from Chat SDK to fragment_chat.xml:

xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//… <io.getstream.chat.android.ui.message.list.MessageListView android:id="@+id/message_list_view" android:layout_width="0dp" android:layout_height="0dp" app:layout_constraintBottom_toTopOf="@id/messageInputView" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toBottomOf="@id/toolbarLayout" /> <io.getstream.chat.android.ui.message.input.MessageInputView android:id="@+id/messageInputView" android:layout_width="0dp" android:layout_height="wrap_content" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" /> //…

Then, within ChatFragment.kt’s onViewCreated function, I bound chat SDK’s layouts to their appropriate ViewModels:

kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//… override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding = FragmentChatBinding.bind(view) binding.backIcon.setOnClickListener { viewModel.handleBackButton() } val channelId: String = (getKey() as ChatKey).channelId val messageListViewModel: MessageListViewModel by viewModels { MessageListViewModelFactory(cid = channelId) } val messageInputViewModel: MessageInputViewModel by viewModels { MessageListViewModelFactory(cid = channelId) } messageListViewModel.bindView(binding.messageListView, viewLifecycleOwner) messageInputViewModel.bindView(binding.messageInputView, viewLifecycleOwner) } //…

Note: that the channelId that we pass into this fragment represents both the chat channel between the driver and passenger, as well as the ride itself. We will see how that works in a later section.

What if I were to tell you that there is a library which handles navigation in a simpler and more idiomatic way, in my opinion, than Jetpack Navigation? What if I also told you that it can handle all of your dependency injection requirements with a few dozen lines of code?

This project uses Simple-Stack to handle both navigation between features, dependency injection, and even the lifecycle of our ViewModels. I will not go into too much detail but hopefully enough to get you started.

Firstly, we configure the dependencies in a subclass of the Android SDK’s Application class. Such a class is useful when you want to configure objects or states which are expected to live throughout the lifecycle of a running android application (a.k.a. an Android process).

UnterApp.kt is an example of such a class. Note that if you create an Application subclass, you must register it in your AndroidManifest.xml file before it can be used!

Configuring Simple-Stack is quite straight forward. First, you need to have a GlobalServices variable which will allow the rest of the application to get a hook to required dependencies at runtime:

kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class UnterApp: Application() { lateinit var globalServices: GlobalServices //… override fun onCreate() { super.onCreate() //… initialize the dependencies here //…then add them to our globalServices object globalServices = GlobalServices.builder() .add(streamRideService) .rebind<RideService>(streamRideService) .add(streamUserService) .rebind<UserService>(streamUserService) .add(firebaseAuthService) .rebind<AuthorizationService>(firebaseAuthService) .add(googleService) .add(getUser) .add(signUpUser) .add(logInUser) .add(logOutUser) .add(updateUserAvatar) .build() } //… }

Adding a service is as easy as calling the add(...) function. If we want to provide an interface instead of a concrete class, we use the rebind<T>(...) function.

Like most navigation libraries, Simple-Stack provides a way to specify navigation destinations. In this application, I used single activity architecture, with fragments representing each feature, or destination of the app. This is also true of Compose based screens as it promotes interoperability.

Though Simple-Stack can be configured to work with other things than just fragments, I will only discuss fragment based navigation here.

Within the navigation package, you will find various “Key” classes which represent destinations. Let us take a look at PassengerDashboardKey.kt as an example:

kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Parcelize data class PassengerDashboardKey(private val noArgsPlaceholder: String = ""): DefaultFragmentKey(), DefaultServiceProvider.HasServices { override fun instantiateFragment(): Fragment = PassengerDashboardFragment() override fun getScopeTag(): String = toString() //How to create a scoped service override fun bindServices(serviceBinder: ServiceBinder) { with(serviceBinder) { add(PassengerDashboardViewModel(backstack, lookup(), lookup(), lookup())) } } }

A few key points to note are:

  • Since we are using Fragments, we have our Key class extend - DefaultFragmentKey.kt which comes from the simple-stack-extensions repository.
  • The Key class also extends DefaultServiceProvider.HasServices which requires us to override bindServices
  • bindServices is where we can actually provide (i.e. inject) our dependencies into the ViewModel

You may wonder what the lookup() function is doing, and why we have repeated it. The best way to understand this is to look at the constructor for PassengerDashboardViewModel.kt:

kotlin
1
2
3
4
5
6
7
8
class PassengerDashboardViewModel( val backstack: Backstack, val getUser: GetUser, val rideService: RideService, val googleService: GoogleService ) : ScopedServices.Activated, CoroutineScope { //… }

The dependencies we earlier configured in UnterApp.kt, can be found automatically by calling lookup(). As long as they have been added to the globalServices object in our application class, Simple-Stack will do the work of providing the correct dependencies for us.

Navigating with Simple-Stack is in my opinion, far simpler than with other libraries I have tried. As with other navigation libraries, you may want to review your understanding of the navigation backstack in the developer documentation.

We will consider two situations for navigation in Unter. Firstly, suppose that after a user has signed in, we do not want the back button to take them back to the sign in feature. Instead, we expect them to explicitly sign out first.

In backstack terminology, this essentially means that we want to send the user to the appropriate screen after they sign in, and set that screen as the bottom of the backstack.

In LoginViewModel.kt, we perform navigation actions by making calls to the backstack argument it is provided:

kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class LoginViewModel( private val backstack: Backstack, private val login: LogInUser ) : ScopedServices.Activated, CoroutineScope { //… private fun sendToDashboard(user: UnterUser) { when (user.type) { "PASSENGER" -> backstack.setHistory( History.of(PassengerDashboardKey()), StateChange.FORWARD ) "DRIVER" -> backstack.setHistory( History.of(DriverDashboardKey()), StateChange.FORWARD ) } } //… }

History, refers to the backstack in this case. History.of(...) accepts a variable number of arguments, which means that we could actually create a new backstack with multiple destinations in it. In our case, we simply send them to the appropriate dashboard based on their user type.

StateChange.FORWARD is a way of specifying what kind of animation to use when transitioning between destinations.

Suppose that instead of resetting the backstack, we simply want to navigate to a new destination and add it to the backstack? For example, if the user navigates to the sign up feature from the login feature, we do want the user to be able to navigate back to log in by pressing the back button.

In this case, as you see in LoginViewModel.kt, we use backstack.gotTo(...) instead:

swift
1
2
3
4
5
//… fun goToSignup() { backstack.goTo(SignUpKey()) } //…

Reactive State Driven UI

Creating a reactive, state machine based UI is a topic which is worthy of its own article. However, I will overview how it works here to give you some ideas about how to build such a thing in principle.

Building your own app? Get early access to our Livestream or Video Calling API and launch in days!

Why Even Bother?

When building simple features which operate in a completely synchronous manner, you will not find much benefit in using the approach I will describe shortly.

However, eventually in your career, you may come across a situation where you must coordinate a number of different asynchronous event streams in a single feature.

Within either of the dashboard features of Unter, we have most of the following asynchronous event streams:

  • User accepting or denying location permissions
  • GoogleMap onMapReady callback
  • Device’s current location being periodically updated
  • Messages between drivers and passengers
  • Changes is ride state based on driver and passenger actions
  • Autocomplete search results
  • Maintaining a list of open rides

There is never a single way to deal with such a situation, but I chose to create a state machine to represent and react to all of these event streams appropriately. In case you are not familiar with this term, do not overthink its meaning.

To explain it in my own terms, a state machine in this context is some code which accepts an input state (or multiple states such as each of our event streams), and outputs a resulting state.

How To Build A State Machine

We will use an approach which is common amongst more advanced users of libraries like RxJava, LiveData, or Kotlin Flows. The first step in this process is that we need to represent the different event/data streams using our respective stream based library (in this case I do not mean the Stream Chat SDK!).
Within PassengerDashboardViewModel.kt, you will see that we do this by adding some kind of Flow for each data stream:

kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
class PassengerDashboardViewModel( val backstack: Backstack, val getUser: GetUser, val rideService: RideService, val googleService: GoogleService ) : ScopedServices.Activated, CoroutineScope { //… private var _passengerModel = MutableStateFlow<UnterUser?>(null) private var _rideModel: Flow<ServiceResult<Ride?>> = rideService.rideFlow() private val _mapIsReady = MutableStateFlow(false) //… }

There are other event streams Flows in this class, but we will focus on these three in particular:

  • _passengerModel represents the user data of the current user who is a passenger
  • _rideModel represents whether or not the user is in an active rideshare, and any data associated with that rideshare if it exists
  • _mapIsReady simply signals whether or not the Google MapView is ready to be updated

We use a MutableStateFlow in situations where we want the ViewModel itself to be able to modify and create the data associated with each Flow. “Mutable” is a fancy word for “changeable.”

To modify a MutableStateFlow, we simply assign its value like so:

kotlin
1
2
3
4
5
//… fun mapIsReady() { _mapIsReady.value = true } //…

_rideModel is distinct from the others as it is something which is modified and created in a different class: RideService. In that case, to prevent the ViewModel from modifying this state inappropriately, we use a Flow instead of a MutableStateFlow.
Now comes something which may look quite scary to those who are not familiar with data streaming libraries. If this does not come naturally to you, I advise you to do your best to understand what this code does and worry later about how it works; that should come with time.

The following code example uses a function which is borrowed from Gabor Varadi’s combine-tuple library.

We need a way to combine the latest values (often called “emissions”) of each of these flows (event streams), and produce a result based on those values. The key concept here is that each time a single value changes, the code block will be rerun with all of the most recent values; including the one which just changed.

kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class PassengerDashboardViewModel( val backstack: Backstack, val getUser: GetUser, val rideService: RideService, val googleService: GoogleService ) : ScopedServices.Activated, CoroutineScope { //… val uiState = combineTuple( _passengerModel, _rideModel, _mapIsReady ).map { (passenger, rideResult, isMapReady) -> if (rideResult is ServiceResult.Failure) return@map PassengerDashboardUiState.Error val ride = (rideResult as ServiceResult.Value).value //only publish state updates whe map is ready! if (passenger == null || !isMapReady) PassengerDashboardUiState.Loading else { when { //… } } } //… }

If you are not familiar with a Tuple, in OOP languages at least, it is a less structured kind of class. It allows us to group together and name emissions from our three flows as a single variable which looks like (passenger, rideResult, isMapReady).

This is more convenient as we do not care at all about creating a class specifically to do this grouping on these specific values.

Now, I must emphasize something important here: We are using the .map(...) function which will take our inputs (the Tuple) and map (transform) it into a different output. I have omitted part of the code in this function, but every branch of the state machine in it ends in a specific type: PassengerDashboardUiState.

This is the end result of the computations which occur within our state machine, which is effectively the code inside our .map(...) function. These state models hold all of the data necessary to render a particular UI state:

kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
sealed interface PassengerDashboardUiState { object RideInactive: PassengerDashboardUiState data class SearchingForDriver( val passengerLat: Double, val passengerLon: Double, val destinationAddress: String ): PassengerDashboardUiState data class PassengerPickUp( val passengerLat: Double, val passengerLon: Double, val driverLat: Double, val driverLon: Double, val destinationLat: Double, val destinationLon: Double, val destinationAddress: String, val driverName: String, val driverAvatar: String, val totalMessages: Int ): PassengerDashboardUiState data class EnRoute( //… ): PassengerDashboardUiState data class Arrived( //… ): PassengerDashboardUiState object Error: PassengerDashboardUiState object Loading: PassengerDashboardUiState }

Rendering UI States In The View

Finally, we must actually deal with these states in the View. Within the onCreate(...) function of PassengerDashboardFragment, we “collect” this state from the ViewModel:

kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class PassengerDashboardFragment : Fragment(R.layout.fragment_passenger_dashboard), OnMapReadyCallback { private val viewModel by lazy { lookup<PassengerDashboardViewModel>() } //… override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) //… lifecycleScope.launch { viewModel.uiState .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) .distinctUntilChanged() .collect { uiState -> updateUi(uiState) } } //… } private fun updateUi(uiState: PassengerDashboardUiState) { //… } }

Within the collect function’s lambda expression is where our computed UI state shows up and is handled. It is worth discussing the operators we have added to improve the safety and performance of this code:

  • Since collect is a suspend function, we need to call it within a coroutine builder such as lifecycleScope.launch {//…}
  • .flowWithLifecycle(//…) will prevent our collect function from being called at a time when the Fragment’s lifecycle is not prepared to handle the result
  • .distinctUntilChanged() filters out unnecessary invocations of collect when the value of the uiState has not actually changed from its most recent value

Managing User Data With Stream Chat SDK

Apart from the initial authentication step, which will be handled by FirebaseAuth, we will manage users and their data with Chat SDK.

There is obviously no strict requirement to use FirebaseAuth as your authentication server; feel free to use whatever you like. However, if you choose to use it, please refer to this guide by Nash from the Stream DevRel team.

Stream has integrations with Firebase that, among other things, automatically create users in Stream’s servers once they are created within Firebase’s servers.

Configuring FirebaseAuth is outside of the scope of this tutorial, so please refer to their documentation for those steps. Note that I chose to use email and password authentication merely because it was easy to implement; not because I consider it an ideal choice in production.

User Authentication & Session Management

Authentication actions such as sign up, sign in, and sign out all follow a two step process which looks like:

  1. Make a call to FirebaseAuthService.kt
  2. If that call succeeded, make a call to StreamUserService.kt

This is necessary as FirebaseAuth acts as the source of truth of the user’s session (whether they are sign in or out), whereas our Chat server acts as the source of truth of the user’s data.

Let us take a look at the sign up flow as an example. Firstly, in SignUpUser.kt, we make the appropriate calls to FirebaseAuth:

kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class SignUpUser( val authService: AuthorizationService, val userService: UserService ) { suspend fun signUpUser(email: String, password: String, username: String): ServiceResult<SignUpResult> { val authAttempt = authService.signUp(email, password) return if (authAttempt is ServiceResult.Value) { when (authAttempt.value) { is SignUpResult.Success -> updateUserDetails( username, authAttempt.value.uid ) else -> authAttempt } } else authAttempt } //… }

Assuming that call was successful, it means that a new user has been created both in our FirebaseAuth server and our Stream server, which you can view and manage in the Stream dashboard (assuming you have signed up!).

However, since we are not storing the username or any other details in FirebaseAuth, we must immediately update the user data within Stream upon successful user creation.

kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class SignUpUser( val authService: AuthorizationService, val userService: UserService ) { //… private suspend fun updateUserDetails( username: String, uid: String ): ServiceResult<SignUpResult> { return userService.initializeNewUser( UnterUser( userId = uid, username = username ) ).let { updateResult -> when (updateResult) { is ServiceResult.Failure -> ServiceResult.Failure(updateResult.exception) is ServiceResult.Value -> ServiceResult.Value(SignUpResult.Success(uid)) } } } }

Let us now take a look at initializeNewUser(...) within StreamUserService.kt:

kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//… override suspend fun initializeNewUser(user: UnterUser): ServiceResult<UnterUser?> = withContext(Dispatchers.IO) { disconnectUser(user.userId) delay(4000L) val streamUser = user.let { User( id = user.userId, name = user.username, extraData = mutableMapOf( KEY_STATUS to user.status, KEY_TYPE to user.type ) ) } val devToken = client.devToken(user.userId) val result = client.connectUser(streamUser, devToken).await() if (result.isSuccess) { ServiceResult.Value( user ) } else { ServiceResult.Failure(Exception(result.error().cause)) } } //…

Let us discuss some of these functions in detail. It is not likely to happen, but in case a user currently has a session within ChatClient, we clear that state by calling disconnectUser(...), which can be found just below initializeNewUser(...):

kotlin
1
2
3
4
5
6
private suspend fun disconnectUser(userId: String) { val currentUser = client.getCurrentUser() if (currentUser != null && userId == currentUser.id) { client.disconnect(false).await() } }

You also may have noticed that we delay the execution of initializeNewUser(...) 4 seconds afterwards.

As of writing this, there appeared to be a bit of a delay necessary between initializing a user in FirebaseAuth with Stream Firebase Extensions, and updating that user within Stream’s servers. So far as I understand, this is specifically something which is caused by using that particular approach to authentication.

I do not believe you will need anything like a 4 seconds delay in any case. Truthfully, I forgot to test the lower bounds of this delay after discovering that some level of delay was required.

Stream Chat ExtraData

Apart from making it easy to create a fully functional messaging experience in a matter of hours, my favorite feature of Chat SDK is the extra data you can store on both users and channels.

In fact, this feature is what we use to drive the functionality of Unter in principle. For initial user creation, we start by simply adding a username and some default values:

kotlin
1
2
3
4
5
6
7
8
9
10
11
12
//… val streamUser = user.let { User( id = user.userId, name = user.username, extraData = mutableMapOf( KEY_STATUS to user.status, KEY_TYPE to user.type ) ) } //…

The extraData property accepts a Map object with key-value pairs of type <String, Any>. New UnterUser objects have their status defaulted to “INACTIVE” and their type defaulted to “PASSENGER”.

Once the User has been configured properly, we need to connect that user to our ChatClient using the connectUser(...) function:

kotlin
1
2
3
4
5
6
7
8
9
10
11
//… val result = client.connectUser(streamUser, devToken).await() if (result.isSuccess) { ServiceResult.Value( user ) } else { ServiceResult.Failure(Exception(result.error().cause)) } //…

This function does quite a few things under the hood, which you can learn more about by reading the documentation. Most importantly, it designed the user we pass into it as the current user of the application. Afterwards, that user is now ready to access the ridesharing functionality.

One final note on the previous snippet. While I do not have time to deep dive into coroutines in this article, it is worth mentioning why I chose to use the await() function after client.connectUser(...).
Within a suspend function, code may execute in a sequential, non-blocking manner. This is a fancy way of saying that by using await(), we will simply wait for connectUser(...) to complete before moving on to the next line of execution. However, we will not be blocking (hence non-blocking) the current thread while we wait!

I personally find sequential, non-blocking code to be easier to read, write, and understand than using callbacks. Should you prefer callbacks, you may use enqueue() instead of await like so:

kotlin
1
2
3
4
5
6
7
8
9
client.connectUser(user, token).enqueue { result -> if (result.isSuccess) { // Logged in val user: User = result.data().user val connectionId: String = result.data().connectionId } else { // Handle result.error() } }

This snippet was taken from the Chat SDK Android documentation, which I suggest you read further.

Managing Ride Data With Channels

Chat SDK’s Channels fulfill two core pieces of functionality in Unter:

  • Communication between drivers and passengers
  • Storing, retrieving, and updating the data associated with an active rideshare
    The only other element which we require, is some way to listen to updates for messages and ride data which occur across different client applications (i.e. the driver’s app and the passenger’s app).

This can be achieved with only a small amount of extra effort, as subscribing to events and messages is a core part of the ChannelClient class we will be working with.

Creating New Channels

Creating a Channel follows a similar process to creating a user in Chat SDK. Open up StreamRideService.kt and have a look at the createRide(...) function:

kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
//… override suspend fun createRide( passengerId: String, passengerName: String, passengerLat: Double, passengerLon: Double, passengerAvatarUrl: String, destinationAddress: String, destLat: Double, destLon: Double ): ServiceResult<String> = withContext(Dispatchers.IO) { val channelId = generateUniqueId(6, ('A'..'Z') + ('0'..'9')) val result = client.createChannel( channelType = STREAM_CHANNEL_TYPE_LIVESTREAM, channelId = channelId, memberIds = listOf(passengerId), extraData = mutableMapOf( KEY_STATUS to RideStatus.SEARCHING_FOR_DRIVER, KEY_PASSENGER_ID to passengerId, KEY_PASSENGER_NAME to passengerName, KEY_PASSENGER_LAT to passengerLat, KEY_PASSENGER_LON to passengerLon, KEY_PASSENGER_AVATAR_URL to passengerAvatarUrl, KEY_DEST_ADDRESS to destinationAddress, KEY_DEST_LAT to destLat, KEY_DEST_LON to destLon ) ).await() if (result.isSuccess) { ServiceResult.Value(result.data().cid) } else { ServiceResult.Failure(Exception(result.error().cause)) } } //…

First, let us discuss a few quick points regarding the above snippet:

  • We will be querying channels based on what users they contain, so generating a random but unique channel id is sufficient
  • Passengers create new rides, so initially they are the only members of the new channel
  • extraData is where we store all of the data necessary for a rideshare at a given state (i.e. searching for a driver, picking up the passenger, etc.)

STREAM_CHANNEL_TYPE_LIVESTREAM is refers to a string value “livestream” which you can find with the rest of the keys in Constants.kt. Chat SDK provides a number of default channel types which have different properties and permissions. A “livestream” channel allows channels to be publicly queried; whereas a “messaging” channel does not by default.

Upon mentioning this to the Stream team, they suggested it might be better to choose a different channel type and modify i’s roles and permissions; which I completely agree with!

One final general point on channels: Channels have both an id and a cid. The cid is comprised of the channel type, a colon, and the channel id (e.g. “livestream:123456”).

Connecting A Driver To A Channel

Drivers are presented a list of open rides which they can accept. These rides, which are actually channels, are initially created with passenger data only. Therefore, the process of adding a driver to a ride involves two primary steps:

  1. Add the driver to the existing channel using the rideId (which is also the channel id)
  2. Update the channel’s data with details about the driver

In StreamRideService.kt, have a look at the connectDriverToRide(...) function:

kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
override suspend fun connectDriverToRide( ride: Ride, driver: UnterUser ): ServiceResult<String> = withContext(Dispatchers.IO) { val channelClient = client.channel( cid = ride.rideId ) val addToChannel = channelClient.addMembers(listOf(client.getCurrentUser()?.id ?: "")).await() if (addToChannel.isSuccess) { //… } else { ServiceResult.Failure(Exception(addToChannel.error().cause)) } }

As you can see, adding the driver to the channel is quite simple. To update the data, we use the updatePartial(...) function to ensure we do not overwrite any existing data:

kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
override suspend fun connectDriverToRide( ride: Ride, driver: UnterUser ): ServiceResult<String> = withContext(Dispatchers.IO) { //… if (addToChannel.isSuccess) { val updateDetails = channelClient.updatePartial( set = mutableMapOf( KEY_STATUS to RideStatus .PASSENGER_PICK_UP.value, KEY_DRIVER_ID to driver.userId, KEY_DRIVER_NAME to driver.username, KEY_DRIVER_LAT to ride.driverLatitude!!, KEY_DRIVER_LON to ride.driverLongitude!!, KEY_DRIVER_AVATAR_URL to driver.avatarPhotoUrl ) ).await() if (updateDetails.isSuccess) { ServiceResult.Value(channelClient.cid) } else { ServiceResult.Failure( Exception(updateDetails.error().cause) ) } } //… }

Respond to Events & Messages Across Client Apps

One of the coolest parts of Stream SDK, is that they have built in functionality to support real time, cross-client updates. This feature will allow us to do the following things:
Let passengers and drivers know if either person has sent messages
Give live and real time updates on locations, which are then rendered via GoogleMaps
Drive the various stages of progression of a rideshare across both client apps

Coding this kind of functionality by hand is tricky, as it would likely require setting up a socket connection between client apps and the app server. Stream handles all of this for you, and provides an easy to use API for listening to these updates.

In StreamRideService.kt, check out the observeChannelEvents(...) function:

kotlin
1
2
3
4
5
6
7
8
9
10
11
12
private suspend fun observeChannelEvents( channelClient: ChannelClient) { channelClient.subscribe { event: ChatEvent -> when (event) { is ChannelDeletedEvent -> { _rideModelUpdates.value = ServiceResult.Value(null) } //… } } }

First, we check to see if the channel has been deleted. This signals that either the passenger or driver have canceled the ride, or that the driver has indicated that the ride is complete.

kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private suspend fun observeChannelEvents( channelClient: ChannelClient) { channelClient.subscribe { event: ChatEvent -> when (event) { //.. is ChannelUpdatedByUserEvent -> { _rideModelUpdates.value = ServiceResult.Value( streamChannelToRide(event.channel) ) } //… } } }

Next, we listen for updates to the state of the channel, and immediately update the local state appropriately. _rideModelUpdates is a flow which is passed into the ViewModel. Therefore, updating its value here will reactively update the UI.

streamChannelToRide(...) is a helper function which does the work of mapping all of the data of a channel to a Ride object.

Finally, we want to inform both users when messages have been sent from either the passenger or driver. This is achieved by looking for a NewMessageEvent:

kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
private suspend fun observeChannelEvents(channelClient: ChannelClient) { channelClient.subscribe { event: ChatEvent -> when (event) { //… is NewMessageEvent -> { val currentRideModel = _rideModelUpdates.value if (currentRideModel is ServiceResult.Value && currentRideModel.value != null) { val channelClient = client.channel( cid = event.cid ) channelClient.create( emptyList(), mutableMapOf() ).enqueue { result -> if (result.isSuccess) { val lastMessageAt = result.data() .lastMessageAt val hasMessage = if (lastMessageAt == null) 0 else 1 _rideModelUpdates.value = ServiceResult.Value( currentRideModel.value.copy( totalMessages = hasMessage ) ) } } } } //… } } }

Further Reading & Resources

This article is a general overview and highlight of some key features of this application. However, I strongly encourage you to look through the source code to continue your learning. Also, as I have done throughout this article, I encourage you to look through the chat SDK documentation, blogs, and terrific open source applications.

A great deal of the chat SDK and flow implementation in this app was based off of this terrific project by Jaewoong Eum.

Integrating Video With Your App?
We've built a Video and Audio solution just for you. Check out our APIs and SDKs.
Learn more ->