Building a Dialer/Call App with Android Jetpack: Chapter 2 [BroadcastReceiver, Room]

Miki Mendelson
10 min readAug 17, 2022

--

Read the previous chapter here, and get the full code here (soon).

Photo by NoWah Bartscher on Unsplash

We’ve previously seen how Jetpack Compose helps us efficiently and quickly build a powerful UI experience. But Compose is not only about UI, and it integrates deeply with other Android architectural components. In this chapter, we will take a look at how phone calls are handled on Android. We’ll use a Broadcast Receiver to handle incoming/outgoing calls, and we’ll use Room to store these calls locally.

For this chapter, I assume that you are somewhat familiar with concepts in SQL/database design. If you are not, don’t worry, there are links to guide you all over the place 😎.

Part 1: Recognizing calls in Android

In Android, applications often need to communicate with other components of the environment. These may be system components such as the networking interface of the device, or other user applications which are currently installed. To do so, the Android SDK offers a (somewhat) convenient form of the publisher-subscriber pattern called Broadcasts. To receiver broadcasts in Android, an app must implement an architectural component called a Broadcast Receiver. In essence, a receiver implements a single method, onReceive , which is called with a Context and some Intent representing the desired broadcast. To configure which events a receiver is bound to, an intent filter is used. In our case, we will implement the CallBroadcastReceiver, responsible for handling incoming and outgoing calls (for now). Let’s look at the declaration in the manifest:

We will go through this declaration step-by-step:

  • First, the receiver’s class name is defined, as well as exported="true". This is done to ensure that the receiver is visible by other components outside the application, and in our case we need it to capture the system events corresponding to call placement.
  • Second, an intent-filter is defined, with two possible actions: PHONE_STATE and NEW_OUTGOING_CALL. You can read about both here and here. Also note that the latter action was deprecated in Android API 29, but we will go with it for now.

Next, let’s look at a simple method to extract the call information from the Intent. To represent the possible call types, I will use an algebraic data type, which in Kotlin are usually represented by sealed classes. My goal is to represent all possible (finite) states of a phone call in a typed manner, like so:

This is good for a few reasons. In Kotlin, sealed classes are determined entirely by the package they are defined in. This means that external code cannot inherit them and extend the inheritance tree. This also means that they are highly constrained and useful for cases where state management needs to be strict. Let’s now consider how we can use this definition in a function that extracts the call state from an Intent:

In this case, we use the defined intent filters and documented intent extras to determine what the intent contains in each case.

With this, we can succinctly implement our CallBroadcastReceiver:

Here, we do a few things:

  • Check that all relevant permissions are granted by using a handy extension function Context.checkPermissions
  • If they are indeed granted by the app, we test for the phone call type extracted from the phoneCallInformation function, and handle it accordingly. For now, we just show a Toast message.

The checkPermissions function is a simple enough implementation as well:

Running the app, we can test with incoming and outgoing calls to see they are properly recognized by our receiver:

Part 2: Persisting calls to a local database

Now that we can recognize incoming and outgoing calls, we would like to persist them in a database. We usually encounter two options for persisting data:

  • In the cloud: costly, and requires authenticating each user against our application. However, online databases allow persistence across devices and installations of the application
  • Locally: easy, priceless, but only persists information on the installed device.

For now, we will take a look at implementing a local persistence solution. The Room persistence library is a very neat solution that integrates nicely with other architectural components, as we will see shortly.

Introduction to Room

First, let’s consider what the official documentation says about Room:

The Room persistence library provides an abstraction layer over SQLite to allow fluent database access while harnessing the full power of SQLite. In particular, Room provides the following benefits:

- Compile-time verification of SQL queries.

- Convenience annotations that minimize repetitive and error-prone boilerplate code.

- Streamlined database migration paths.

This is key to remember: Room is simply an abstraction over SQLite. Nothing more! When you consider learning it for your own benefits, it’s worth remembering, because it will make your life much easier.

Let’s take a look at how these benefits translate to our code. In Room, there are three major components: The database, the data access layer (represented by data access objects), and the data layer (represented by entities). This could not be explained better than this diagram from the official documentation:

Room’s three layer architecture (Credit: Android Developers)
Room’s three layer architecture (Credit: Android Developers)

To best demonstrate how this works, let’s implement each of our layers from bottom to top.

Entities

Remember our RegisteredCall class? This acts as our data representation. With room, we can make it into an SQL table without changing anything about the structure of the class itself, using annotation classes:

To understand what’s going on here, let’s look at a very simple entity relation diagram (ERD) of our database. Since we are only interested in the registered calls, our ERD is painfully simple:

Our app’s Entity Relation Diagram

Room gives us tools to transfer our Java/Kotlin types into entities, which are schemas that SQL can understand under the hood:

  1. The PrimaryKey annotation marks a field as a primary key. This means that it uniquely identifies each row in the generated table. The autoGenerate = true simply means that if 0 is passed as the value, it is replaced with a unique value generated by the SQL engine.
  2. The ColumnInfo annotation allows customizing how the specific column associated with a field looks. Room usually knows how to handle fields without this annotation as well, but there’s always a benefit in being explicit here.
  3. The Entity annotation allows customizing how the table associated with this data type looks like. Here, we set the name of the table.
  4. Lastly, TypeConverters is a very useful annotation that demonstrates a lot of the power of Room over traditional SQL engines. Since our data type has fields which the library does not know how to serialize (Duration, CallType), we need to explicitly define functions that convert to/from these types and their database representation. In both cases, I chose Int as the best representation for both, since a known enum can be represented by its type indices, and a duration can be represented by a known unit, for example seconds. Both converters are very easy to implement as classes using the TypeConverter annotation:

Notice that this poses a few restrictions that we ought to address:

  • Durations less than a second are not representable here. This is ok, because regarding calls, we usually don’t care about such granularities.
  • Adding new enum values in the future cannot break the interface. This means that if we add support for more call types (For example: conference, VOIP, etc.), we must do it in a way that does not break rows which are already in the database (For example, adding types only at the end of the enum definition to maintain monotonicity of the indices). In practice, for more complex database schemes, enums will usually be represented as their own SQL table with rows for values, but this is good enough for our needs.

Data Access Objects

Data access objects (DAOs) are the middle points between the database and its entities. They are simply interfaces that define what operations can be performed on our entities. For our RegisteredCall entity, we want to be able to list all calls, retrieve a call by its id, insert a new call toe the database, and delete a call. For testing purposes, it would also be useful to implement a method that deletes all calls in the database. Our DAO will like like this:

I’ll explain some of the more interesting parts here:

  1. Room allows writing methods that invoke generic SQL queries. For example, to list all calls, we invoke the simple query SELECT * FROM registered_calls (This is how we decided our entity table is named).
  2. Queries can also accept method parameters! For example, we we can get a call by id, with the meta-query SELECT * FROM registered_calls WHERE id == :id. Neat!
  3. The list() returns a Flow object! What’s that? Essentially, this is a stream that emits objects sequentially in a coroutine context. Room supports this behavior, and will emit an updated list whenever the database changes! This is very useful, because this means that we can easily integrate this behavior into a MVVM architecture. More on that later
  4. Trivial operations are supported: Insert, Delete. They also take an entity object as a parameter, and serialize it according to its type converters.

The Database

All we have to do to complete our Room implementation, is to define a database. A database facilitates access to our DAOs, and is also responsible for features such as database migration, which we will talk about in the future. For now, let’s see a simple implementation of our database:

Since we only have once DAO and one entity, we define them using the entities field of the @Database annotation, and we define an abstract function to return an instance of the DAO. Room will generate an implementation for this abstract class during compile-time, based on the properties and the function declarations!

We usually want our database to be a singleton. This is true, because initialization of a database is probably a costly operation, and we only ever use it to facilitate access to DAOs (Which by themselves are not singletons). This is the factory design pattern, because we provide a way to get instances of the RecordingClassDao without worrying about the implementation details.

There are excellent ways to make AppDatabase a runtime singleton in our app, which we will discuss in later chapters when we discuss dependency injection. For now, we will use the classic way of making a singleton in Kotlin:

Let’s explain what’s going on here:

  • A companion object in Kotlin is a true singleton. This means that it is evaluated only once during the lifetime of the app, and is also thread safe. This is important, because if it were not thread-safe, multiple threads could evaluate it and result in an invalid states of two separate instances of the singleton object across the application lifetime.
  • Instead of using the companion object directly, we hold a nullable instance of our own class and use the instance function to evaluate it once on first access.
  • Why do we use the synchronized method here? Well, for the same reasons I explained earlier, we want this evaluation to be thread safe, which means that if two threads try to access the object at the same time, they will be synchronized under a shared object monitor. This is still a generally bad method of synchronization in Kotlin, and we will see better ones in later chapters.
  • Lastly, we mark the singleton field as @Volatile for the JVM, so that once evaluated, it is immediately non-null in all other threads. Not adding this could result in another thread trying to call instance after the first thread finished evaluating it, and seeing a null object that hasn’t been written to the shared memory yet. Read more about this here.

Lastly, since we are yet to implement the logic that saves actual calls to the database, we can use our dummyCall method to populate it. Here, we use the RoomDatabase.Callback interface. Our DatabaseCallback implements it, and uses the same DAO and database methods we declared:

We could write our objects directly to the database by using the supplied db object, which allows raw access to the SQLite instance, but this is easier.

Lastly, we can swap our implementation in the UI to get the call list from the database instead from a dummy list. First, in our MainActivity, we get an instance of our AppDatabase :

private val applicationScope = CoroutineScope(SupervisorJob())
private val appDatabase = AppDatabase.instance(applicationContext, applicationScope)

Then, inside our composable, we can transform our call list Flow into a Compose state with the utility function collectAsState:

val recordings = appDatabase.callRecordingsDao().list().collectAsState(initial = emptyList())

Then, we can use the state’s value inside the CallList composable:

CallList(recordings.value) { _, call ->
snackbarCoroutineScope.launch {
scaffoldState.snackbarHostState
.showSnackbar("Call recording ${call.id} clicked")
}
}

The full implementation of MainActivity is given here:

Conclusion

In this chapter, we introduced the Room persistence library and added support for saving our calls to a locally persistent database. We also wrote a BroadcastReceiver that’s able to detect incoming and outgoing calls. In the next chapter, we will complete this implementation by replacing our dummy calls in the database with actual calls from the receiver. We will also introduce another screen to the app and move between them using Compose Navigation. We will also improve our UI and business logic separation by introducing ViewModels and the android MVVM architecture. Feel free to follow me if you want to stay updated when more chapters are released 😆.

--

--

Miki Mendelson
Miki Mendelson

Written by Miki Mendelson

I’m always excited to learn something new. My interests include Full stack development, Machine learning, Android architecture, sports and music.

Responses (2)