Kotlin callbackFlow: A lightweight architecture for location-aware Android apps

I’ve been working towards updating the architecture of several Android apps that make heavy use of location services. Specifically, some of them use LocalBroadcastManager, which has been deprecated, to share locations across app components like Services and Activities. Time for something new.

After reviewing numerous resources on 2021-era Android best practices, primarily focused on Android Jetpack, I prototyped a repository-based architecture that leveraged Room and Kotlin Flow. I wrote about the results in this article:

However, after finishing that project I had a nagging feeling that there was a simpler solution, especially for lightweight apps that don’t already include a database. This article explains my journey to a pure Kotlin Flow-based solution for location-aware apps.

Database buyer’s remorse

As a quick recap from the first article, we started with a solution where the Service passed locations to the Activity using a LocalBroadcastManager:

The legacy approach to sharing locations based on the deprecated LocalBroadcastManager

By the end of the article we had a new architecture that leverages Flow from Room to observe location updates:

My initial solution —the Service inserts new locations in Room, and our UI can observe changes from Room using Kotlin Flow

I started implementing this solution in my GPSTest app, which is a utility app for Global Navigation Satellite Systems (GNSS). GPSTest doesn’t currently store location information, which got me thinking — do I really need to add a full-fledged database to the app?

By adding a database I was introducing a lot of overhead:

  1. Increased APK size due to dependencies for Room
  2. Increased device storage requirements due to database footprint
  3. Possible execution time penalty for writing to/reading from persistent storage
  4. Increased memory requirements for the portions of Room and data loaded into memory

There are some mitigations for some of the above issues, like possibly running Room as an in-memory database instead of being backed by persistent storage, but those have their own tradeoffs (like possibly even more increased memory requirements, or execution time penalties for database bootstrap on each startup).

There had to be a better way…

The solution — callbackFlow

Taking a step back, I realized that I was primarily using Room for the Kotlin Flow implementation.

So what if I implemented my own Flow-based solution (let’s say a SharedLocationManager) to manage location updates and subscriptions? Something like this:

A pure Kotlin Flow-based solution is lightweight and allows all app components to be observers

For apps that don’t need to store a persistent location history, this is a lightweight solution that keeps the repository observer pattern via Flow but doesn’t require the addition of a database.

There are several key differences from the previous architecture:

  • The Service is just another observer — Instead of the Service sending the location updates to the Repository, it observes the Repository via Flow just like the other consumers of location information (in this case, to update an ongoing notification). This means that because the Service isn’t the source of the location updates, other components in the app don’t need to wait for it to bind or start to observe locations.
  • Lifecycle of SharedLocationManager is tied to the lifecycle of the observers — We need to be careful how we configure the SharedLocationManager so it survives the creation and destruction of various app components when there is at least one remaining subscriber. And, when no more app components are subscribed, we can stop listening for new locations.
  • The most recent location is returned instead of multiple locations — We change the Flow to return a single Location instead of List<Location>, primarily because we don’t have a persistent store of locations. However, as we’ll see shortly, if the SharedLocationManager was already running when a new component subscribes there is still a way to get access to locations going back to when the SharedLocationManager started for the first subscriber.

So let’s look at the code. Similarly to the previous article, I’ll present this solution in context of what needs to be added to Google’s while-in-use-location code example to make it work with Kotlin Flow, but this time without using Room.

SharedLocationManager and callbackFlow 🌟

The star of the show here is the SharedLocationManager that manages the Flow for location updates, so let’s look at this class first.

We use callbackFlow, which is ideal for wrapping callback-based APIs with a Flow:

In the first few lines of code we set up the fusedLocationClient and locationRequest to define how frequently we want location updates, etc. — normal boilerplate stuff.

Then, a key line — we define a private _locationUpdates variable that is assigned a callbackFlow<Location> , which wraps the setup of the fused location provider. This private variable is exposed to external classes via the fun locationFlow(): Flow<Location> function that you see at the bottom of the class — we’ll see this being called from the Activity and Service later (via the repository abstraction).

Let’s walk through the rest of the code that sets up the observers for the fused location provide within callbackFlow<Location> {...}.

First, we define the callback. In LocationCallback.onLocationResult(), on every new LocationResult we trySend() the new location to the Flow — this enables any observers to receive the new location (and won’t result in exceptions like offer()).

Next, we add the code that will start location updates. We pass that callback along with the locationRequest to fusedLocationClient.requestLocationUpdates(), and add an OnFailureListener that closes the Flow in case of error during setup.

Then, we define awaitClose() that will stop the location updates when the Flow is closed.

We then set several attributes of the callbackFlow via .shareIn():

  • externalScope — It’s a best practice to pass in scopes versus defining it within the class. We’ll see where this comes from in the next section.
  • replay — Here’s that part where observers could potentially be notified of Locations from before when they subscribed (as long as the SharedLocationManager was already started by a different subscriber). We set it to 0, which means that when subscribers subscribe they will only get location updates going forward. But if we set it to 3, subscribers would get the last 3 Locations (if they exist) upon subscription.
  • started — This is key to defining when the Flow is active. Using SharingStarted.WhileSubscribed(), it will start location updates when the first subscriber appears and immediately stop location updates when the last subscriber disappears. This ensures that as long as one app component has a coroutine that’s observing the flow, the location updates will continue. We just need to be careful to scope the observing coroutine to the lifecycle of that app component, which we will see in the following sections.

To stick with the repository-observer design pattern, we also abstract the SharedLocationManager behind a more general LocationRepository:

Now that we have our classes defined that manage location updates and makes them observable via callbackFlow, how do we make it available to our app components?

Hilt implementation for dependency injection 🔪

Similar to last time, we’ll use Hilt for a dependency injection framework, which we can use to access the SharedLocationManager from the Activity and Service (and ViewModel, Fragments, or wherever else you need it).

You may have notice the @Inject annotation in the above LocationRepository.kt class. This tells Hilt that we want to make this component available in other classes.

We also need to annotate the LocationApplication.kt class to allow Hilt to manage the application (which is also where we define our applicationScope that will be used in SharedLocationManager):

…and define a DataModule.kt class that indicates that we want a single, application-wide SharedLocationManager to be created (hence the @InstallIn(SingletonComponent::class) and @Singleton annotations, and also why we use the LocationApplication context applicationScope):

Now, we can configure the Service and Activity to observe updates.

Activity

Let’s start with the MainActivity first. Here’s the UI of the app, which allows the user to start and stop location updates via a button:

In the while-in-use-location example, the user can start and stop location updates from the UI

Here’s the important part of the Activity code for observing the location Flow:

The initial setup using Hilt is the same as last time —we again inject the repository by annotating the class with @AndroidEntryPoint and the repository with @Inject. We also setup the button OnClickListener to alternately call the subscribeToLocationUpdates() and unsubscribeToLocationUpdates() methods to start and stop location updates, respectively.

We setup the Flow in subscribeToLocationUpdates() . We call repository.getLocations() with flowWithLifecycle() and launchIn() with the Activity’s lifecycle and lifecycleScope respectively to ensure that this Flow will be closed when the Activity state is less than the STARTED state (e.g., when it is stopped or destroyed). Within .onEach {} we simply update a TextView with each new location latitude and longitude within the logResultsToScreen() function.

To handle the case when the user taps on the button to stop location updates, we also add the locationFlow: Job? variable at the stop of the class and assign the Flow to it. This allows us to manually close the flow in unsubscribeToLocationUpdates() by calling locationFlow?.cancel() even if the Activity is still STARTED (otherwise, the Flow and location updates would continue until we stopped or destroyed the Activity).

The subscribeToLocationUpdates() and unsubscribeToLocationUpdates() method also tells the Service that the user subscribed or unsubscribed to location updates, respectively.

Service

The main difference between the previous solution using Room and this one using the SharedLocationManager is that the responsibility for managing the location updates is removed entirely from the Service. Instead, the Service is just another observer.

So instead of setting up the LocationCallback when the Service starts, we instead need to start observing the Flow when the Service is started. This will allow us to update the ongoing notification with the latest location information.

The Service observes location updates via Flow to update an ongoing notification

Here are the key parts of the Service for observing the location Flow:

Again, we inject the repository by annotating the class with @AndroidEntryPoint and the repository with @Inject.

In subscribeToLocationUpdates(), we first start the Service. Then, we again call repository.getLocations() with flowWithLifecycle() to observe the Flow and tie it to the lifecycle of the Service. Note that to use the lifecycleScope here, we need our ForegroundOnlyLocationService to extend a LifecycleService instead of a normal Service. In onEach{}, we update the notification text with the currentLocation.

The code in onStartCommand() handles the case when the user taps on the “Stop receiving location updates” button in the Service notification. This triggers an intent to onStartCommand() where cancelLocationTrackingFromNotification is true, which triggers the call to stopSelf() to stop the service and to unsubscribeToLocationUpdates().

But wait…why do we need unsubscribeToLocationUpdates()? And why do we have the locationFlow: Job? variable in the Service? If the location updates are tied to the Service lifecycle, and we want the updates to start when the service starts and stop when the service stops, shouldn’t the link to the lifecycle be enough? That’s what I thought too. But when I implemented it that way, the Flow in the Service would continue when I tapped on the stop button in the Activity — even when the Service was stopped 🤔.

I started digging into the LifecycleService source code to understand why this was happening. It dispatches lifecycle events to ServiceLifecycleDispatcher, which is where I found my answer.

ServiceLifecycleDispatcher.onServicePreSuperOnBind() calls postDispatchRunnable(Lifecycle.Event.ON_START). So if the Service is bound, the lifecycleScope still considers the lifecycle state to be at least STARTED, even if the service itself it NOT started!

This is why the Flow was still active in the Service — even though it was stopped, the Service was still bound to the Activity! Thinking through this more, it actually makes sense. A Service can be bound but not started (i.e., the initial app state), and it can be started but not bound (when the Activity is destroyed after tracking has started). So neither state logically comes before the other.

So this is why we need unsubscribeToLocationUpdates() and the call to locationFlow?.cancel() — to handle the case when we tap on the button within the activity to stop the Service and it’s Flow.

Gradle configuration 🔧

The absence of Room makes this Gradle configuration simpler than before. You’ll need this in the root build.gradle to include Hilt:

…and this in the application build.gradle for the LifecycleService and Hilt, and to use trySend() instead of offer():

Putting it all together

Add all that together, and we have the same UI as before, but without a Room database. Now we have a SharedLocationManager that uses callbackFlow, and the Activity and Service both directly observe that Flow!

SharedLocationManager uses a callbackFlow to manage location updates which is observed by the Service and Activity

Closing thoughts

That’s it! You now have a fully working example of managing location updates via callbackFlow and observing the Flow via an Activity and Service, with Hilt injecting the needed dependencies!

You can find the above as a working project at https://github.com/barbeau/while-in-use-location in the no-database branch in the complete folder. If you want to see the Git diff with the code changes from the original Google example, click here.

A few final observations:

  • For simplicity in this example I haven’t changed where the state of the location tracking is stored, which is in the SharedPreferences. However, note that you could switch to using StateFlow within the SharedLocationManager instead.
  • If you remove the button from the UI and have location updates start and stop when the Activity and Service start and stop, the solution is simpler — we could tie Flow entirely to the lifecycle of the Activity and Service and not need to call locationFlow?.cancel().
  • Unlike the Room example, this solution doesn’t require translation of Android system classes to a separate set of project domain model classes— you can have Activities and Services directly observing system classes like android.location.Location from the Flow directly instead of creating your own com.example.android.whileinuselocation.model.Location object and filling it with the same data. I’ll leave you to debate whether this is a good architecture decision 😁. If you do decide to translate to your own domain model classes, you can use the .map operator on the Flow to help with this.

Do you have suggestions on improving this design further? Any problems that you see? Please let me know in the comments below!

As I was thinking of this solution, I stumbled on a few Android code examples that pointed in this direction that deserve credit — this example is based in part on code from:

And here are some additional references I found helpful when learning about Kotlin Flow and callbackFlow:

Finally, thanks also to Chris Arriola for pointing out that trySend() can replace offer()!

Was this article helpful? Consider following me on Medium. You can also find me on Twitter and LinkedIn. If you’re a user of the open-source GPSTest app and you’d like to support it, you can check out the GPSTest “Buy me a coffee” page:

Improving the world, one byte at a time. @sjbarbeau, https://github.com/barbeau, https://www.linkedin.com/in/seanbarbeau/. I work @CUTRUSF. Posts are my own.