Chapters

Hide chapters

Real-World Android by Tutorials

Second Edition · Android 12 · Kotlin 1.6+ · Android Studio Chipmunk

Section I: Developing Real World Apps

Section 1: 7 chapters
Show chapters Hide chapters

5. Data Layer — Caching
Written by Ricardo Costeira

In this chapter, you’ll complete the data layer you started in the previous chapter by adding caching capabilities to it. At the end of the chapter, you’ll pull everything together by assembling the repository.

In the process, you’ll learn:

  • How to cache data.
  • Another way of mapping data to the domain layer.
  • More about dependency management.
  • How to bundle everything in the repository.
  • How to test the repository.

You’ll do this by working on the PetSave app.

Cache Data Models

The models are in the materials for this chapter, but they still need some work. You’ll use Room to create the caching system. Since Room is an abstraction over SQLite, you have to establish the relationships between database entities. SQLite is a relational database engine, after all.

Start by expanding common.data.cache.model. Inside, you’ll find two other packages, one for each domain entity. This kind of structure didn’t exist in the network code because you had to adapt to whatever the server sends. With caching, you have full control over the data.

Look at this entity relationship diagram:

Figure 5.1 — PetSave entity relationship diagram. Made with dbdiagram.io.
Figure 5.1 — PetSave entity relationship diagram. Made with dbdiagram.io.

Using database jargon:

  • One organization has many animals.
  • One animal has many photos.
  • One animal has many videos.
  • Many animals have many tags.

The organization to animals relationship is there for completeness only; you won’t implement it.

If you’re wondering about the last relationship, it’s easy to understand. Tags are attributes of the animal. They include descriptions like “cute”, “playful” and “intelligent”. So, an animal can have many tags, and a tag can be associated with many animals. Hopefully, more than one animal has the “cute” tag. :]

Expand the cachedanimal package. In it, you’ll find a few different files that correspond to most of the tables in the diagram above.

Now, open CachedPhoto.kt. The @Entity annotation specifies the table name, while @PrimaryKey defines the primary key — which is photoId, in this case. Having autoGenerate = true as a @PrimaryKey parameter tells Room that it might need to generate a new ID for new entries. It checks photoId’s value to determine if it needs to do so. If the value is zero, Room treats it as not set, creates a new ID and inserts it with the new entry. That’s why photoId has the default value of zero.

Adding Foreign Keys

Before creating relationships, you need to add the foreign keys that allow you to establish them. Complete the @Entity above the class with:

@Entity(
    tableName = "photos",
    foreignKeys = [
      ForeignKey(
          entity = CachedAnimalWithDetails::class, // 1
          parentColumns = ["animalId"], // 2
          childColumns = ["animalId"], // 3
          onDelete = ForeignKey.CASCADE // 4
      )
    ],
    indices = [Index("animalId")] // 5
)

This annotation defines a lot of stuff for you. It:

  1. Specifies the entity that the foreign key belongs to: CachedAnimalWithDetails, in this case. Although you have Animal and AnimalWithDetails as domain entities, there’s no CachedAnimal in the database. Having two sources of truth for the same thing goes against database normalization, so you should avoid it.
  2. Defines the column that matches the foreign key in the parent table, CachedAnimalWithDetails.
  3. Defines the column in this table where you find the foreign key.
  4. Instructs Room to delete the entity if the parent entity gets deleted.
  5. Sets the foreign key as an indexed column.

Setting columns as indices is a way for Room to locate data more quickly. Here, you set animalId as an index because it’s a foreign key. If you don’t set a foreign key as an index, changing the parent table might trigger an unneeded full table scan on the child table, which slows your app down. Fortunately, Room throws a compile-time warning if you don’t index the key.

Having indices speeds up SELECT queries. On the other hand, it slows down INSERTs and UPDATEs. This is nothing to worry about with PetSave, as the app will mostly read from the database.

Setting Up Relationships With Room

Starting on version 2.4, Room gives you two possible approaches for establishing relationships between entities:

  • Using intermediate data classes (already available before 2.4).
  • Using multimap return types.

Intermediate data classes allow you to be explicit about the relationships between entities through annotations at the expense of, well, having more classes to maintain. What you gain in return is that you avoid having to write complex SQL queries by making your query methods return these intermediate classes. Multimap return types allows your query methods to simply return a mapping of your entities instead of having to create an intermediate data class type for it. The catch here is that the SQL associated with said method will do most of the work.

You can go with whichever option you prefer. Google recommends multimap… so we’re going with data classes, not only due to (way) simpler SQL, but also to follow the screaming architecture workflow. It’s a lot easier to spot what kind of entities your database can return when you’ve named your classes properly. Besides, are you even an Android developer if you don’t ignore half of Google’s recommendations? :]

Setting Up Your One-to-Many Relationships

The @Entity annotations in CachedVideo and CachedAnimalWithDetails already adhere to the diagram in Figure 5.1, which means that everything’s ready to set up your one-to-many relationships. To model these with Room, you have to:

  1. Create one class for the parent and another for the child entity. You already have these.
  2. Create a data class representing the relationship.
  3. Have an instance of the parent entity in this data class, annotated with @Embedded.
  4. Use the @Relation annotation to define the list of child entity instances.

The data class that models the relationship already exists. It’s called CachedAnimalAggregate. You’ll use this class later to map cached data into domain data since it holds all the relevant information.

Open CachedAnimalAggregate.kt and annotate photos and videos in the constructor like this:

data class CachedAnimalAggregate(
    @Embedded // 1
    val animal: CachedAnimalWithDetails,
    @Relation( // 2
        parentColumn = "animalId",
        entityColumn = "animalId"
    )
    val photos: List<CachedPhoto>,
    @Relation(
        parentColumn = "animalId",
        entityColumn = "animalId"
    )
    val videos: List<CachedVideo>,
    // ...
) {
  // ...
}

In this code you used:

  1. @Embedded for the animal property of type CachedAnimalWithDetails.
  2. @Relation for the photos and videos properties of types List<CachedPhoto> and List<CachedVideo>, respectively. By specifying the keys of both the parent (CachedAnimalAggregate through the embedded animal property) and the children (photos and videos), Room can use this annotation to handle the relationships for you.

You’ve now prepared all your one-to-many relationships. But before proceeding, let’s talk about multimaps for a second.

So, multimaps allow Room to return a mapping of entities, rather than a custom class that defines the relationship between said entities, at the expense of a more complex SQL query. For instance, imagine that you want all animals that have photos, along with the corresponding photos. You’d have something like:

@Query(
  "SELECT * FROM animals " +
  "JOIN photos ON animals.animalId = photos.animalId"
)
abstract fun loadUserAndBookNames(): Map<CachedAnimalWithDetails, List<CachedPhoto>>

That’s all fine and dandy, but now try to get the same result that CachedAnimalAggregate gives you — an animal with (or without) photos, videos and tags — through a multimap. Go ahead, I’ll wait. Not that easy, huh?

Multimaps work fine for the simpler cases, but if you find yourself in despair over a multimap with an extremely complex SQL statement, just go with an extra class. Be kind to yourself!

With that out of the way, on to the next relationship you need to handle.

Setting Up the Many-to-Many Relationship

Now, you’ll handle the many-to-many relationship, which you need to model a little differently from the one-to-many case. Here, you need to create a:

  1. Class for each entity (already done).
  2. Third class to cross-reference the two entities by their primary keys.
  3. Class that models the way you want to query the entities. So, either an animal class with a list of tags, or a tag class with a list of animals.

In this case, you want an animal class with a list of tags. You already have that with CachedAnimalAggregate, so add the following above tags in the constructor, like this:

data class CachedAnimalAggregate(
    // ...
    @Relation( // 1
        parentColumn = "animalId", // 2
        entityColumn = "tag", // 2
        associateBy = Junction(CachedAnimalTagCrossRef::class) // 3
    )
    val tags: List<CachedTag>
) {
  // ...
}

In this code, you:

  1. Define a many-to-many relation with @Relation.
  2. Define the parent and child entities, although they’re not exactly parent and child. Again, this just defines the way you want to query the entities.
  3. Use associateBy to create the many-to-many relationship with Room. You set it to a Junction that takes the cross-reference class as a parameter. As you can see from the entity relationship diagram, the cross-reference class is CachedAnimalTagCrossRef.

You now need to define the cross reference through the CachedAnimalTagCrossRef class.

Implementing the Cross-Reference Table

All that’s missing now is to deal with the cross-reference table. Open CachedAnimalTagCrossRef.kt. You need to annotate the class as a Room entity:

@Entity(
    primaryKeys = ["animalId", "tag"], // 1
    indices = [Index("tag")] // 2
)
data class CachedAnimalTagCrossRef(
    val animalId: Long,
    val tag: String
)

Two things to note here:

  1. You’re defining a composite primary key with animalId and tag. So, the primary key of this table is always a combination of the two columns.
  2. While primary keys are indexed by default, you’re explicitly indexing tag, and tag only. You need to index both, because you use both to resolve the relationship. Otherwise, Room will complain.

What’s happening here?

It has to do with how SQLite works. A query can use a composite index or a subset of that index, as long as the subset matches the original index from the beginning. So in this case, since the index is (animalId, tag), both (animalId) and (animalId, tag) are valid. If you change the primary key above to ["tag", "animalId"], then you’d have to index animalId instead of tag:

@Entity(
    primaryKeys = ["tag", "animalId"], // HERE
    indices = [Index("animalId")]
)
data class CachedAnimalTagCrossRef(
    val animalId: Long,
    val tag: String
)

Anyway, you’re adding a new Room entity, so you need to inform Room about it. Open PetSaveDatabase.kt in the cache package. Add CachedAnimalTagCrossRef to the list of entities already there:

@Database(
    entities = [
      CachedVideo::class,
      CachedTag::class,
      CachedAnimalWithDetails::class,
      CachedOrganization::class,
      CachedAnimalTagCrossRef::class // HERE
    ],
    version = 1
)
abstract class PetSaveDatabase : RoomDatabase()

Rebuild your project and run the app. Everything works as expected!

This concludes the work on the models. However, you still need to map them to the domain.

Another Way of Data Mapping

When you worked with the API, you created specialized classes for data mapping. Remember reading about using static or member functions to achieve the same goal? Well, open CachedPhoto.kt and look at the companion object:

@Entity(tableName = "photos")
data class CachedPhoto(
  // ...
) {
  companion object {
    fun fromDomain( // HERE
        animalId: Long,
        photo: Media.Photo
    ): CachedPhoto {
      val (medium, full) = photo
      return CachedPhoto(
          animalId = animalId,
          medium = medium,
          full = full
      )
    }
  }
  // ...
}

In this code, fromDomain returns a CachedPhoto instance, which it builds from a domain Photo and the corresponding animalId. It has to be a companion object function due to dependencies. To make it a class member function, you’d have to add it to Photo, which would make the domain aware of the data layer.

You could also achieve the same result with an extension function, as long as it extends CachedPhoto. In the end, both options boil down to static functions. The extension function does have the advantage of extending CachedPhoto’s behavior without changing the class. However, people like to keep the extension in the same file for convenience.

Picking one or the other in this simple case is mostly a matter of preference. Choosing the companion object means keeping the mapping behavior close to the class. It’s a simple class, and its job is to be a DTO, so there’s no particular reason to hide the mapping.

Anyway, look below the companion and you’ll find CachedPhoto’s only function:

@Entity(tableName = "photos")
data class CachedPhoto(
  // ...
) {
  // ...
  fun toDomain(): Media.Photo = Media.Photo(medium, full) // HERE
}

This does the reverse of the other function, creating a domain model out of the cache DTO. Simple. :]

Cache models have toDomain and fromDomain functions, while API models only have toDomain. That’s because you won’t send anything to the API, so there’s no need to translate domain models into API DTOs.

OK, this wraps up model mapping. Time to get yourself some of those sweet SQL statements that everyone loves! The DAOs (data access objects) are waiting.

Caching Data With Room

Think back to when you implemented the API interface. You added the API method to meet the data needs for the Animals near you feature. Now, it’s just a matter of accessing the data. :]

Room uses DAOs to manage data access. Typically, you’ll want one DAO per domain entity since they’re the objects whose identity matters. You already have OrganizationsDao but you need to add one for animals.

Create a new file with name AnimalsDao.kt in common.data.cache.daos and add the following code:

@Dao // 1
abstract class AnimalsDao { // 2
  @Transaction // 3
  @Query("SELECT * FROM animals") // 4
  abstract fun getAllAnimals(): Flowable<List<CachedAnimalAggregate>> // 5
}

In this code, you:

  1. Use @Dao to tell Room that this abstraction will define the operations you want to use to access the data in its database.
  2. Create AnimalsDao as an abstract class because you’ll need to add some concrete methods. Room also supports interfaces in case you just need operations that you can specify solely through annotations.
  3. Use @Transaction to tell Room to run the specific operation in a single transaction. Room uses a buffer for table row data, CursorWindow. If a query result is too large, this buffer can overflow, resulting in corrupted data. Using @Transaction avoids this. It also ensures you get consistent results when you query different tables for a single result.
  4. Define the SQL query to retrieve all the animals with @Query.
  5. Declare getAllAnimals() as the function to invoke to fetch all the animals and their corresponding photos, videos and tags. This operation returns RxJava’s Flowable. This creates a stream that will infinitely emit new updates. That way, the UI always has live access to the latest cached data. It returns a list of CachedAnimalAggregate, which is the class with all the information you need to produce a domain AnimalWithDetails.

When you build the app, Room will create all the code you need to fetch all the animals from the database. Of course, you also need a way to insert the data.

Inserting New Animals

You’ve now gotten all the animals. To insert new ones, add the following code in AnimalsDao.kt:

@Dao 
abstract class AnimalsDao {
  // ...
  @Insert(onConflict = OnConflictStrategy.REPLACE) // 1
  abstract suspend fun insertAnimalAggregate( // 2
      // 3
      animal: CachedAnimalWithDetails,
      photos: List<CachedPhoto>,
      videos: List<CachedVideo>,
      tags: List<CachedTag>
  )
}

In this code:

  1. You annotate the method declaration with @Insert. This tells Room that it’s a database insertion. Setting onConflict to OnConflictStrategy.REPLACE makes Room replace any rows that match the new ones. There’s no @Transaction annotation because Room already runs inserts within a transaction.

  2. By default, Room won’t let you perform I/O operations on the UI thread. If you did, you’d most likely block the thread, which means freezing your app! A great way to avoid this is by using suspend functions. Since an insert is a one-shot operation, you don’t need anything fancy like reactive streams. Plus, using the suspend modifier here makes Room run the insert on a background thread.

  3. You can’t insert CachedAnimalAggregate because it’s not a Room entity. However, you can decompose it into its @Entity-annotated components and pass them into this method. Since they’re all Room entities, Room will know how to insert them.

The previous operation allows you to insert a single entity. In practice you usually need to insert many of them. In the next paragraph you’ll see how.

Mapping the API Results to the Cache

After parsing the results from the API, you need to map them to the cache. Since you’ll get a list of animals from the API, you’ll end up with a list of CachedAnimalAggregate after the mapping. The last method will handle the decomposing mentioned above.

In the same AnimalsDao.kt file, add the following code:

@Dao
abstract class AnimalsDao {
  // ...
  suspend fun insertAnimalsWithDetails(animalAggregates: List<CachedAnimalAggregate>) {
    for (animalAggregate in animalAggregates) {
      insertAnimalAggregate(
          animalAggregate.animal,
          animalAggregate.photos,
          animalAggregate.videos,
          animalAggregate.tags
      )
    }
  }
}

Here, the method goes through the list and calls insertAnimalAggregate for each one. This is why you’re using an abstract class. Although interfaces can also have method implementations, they can be extended. This way, since the method doesn’t have the open modifier, it’s clear that it should not be extended in any way. It’s just a matter of better conveying the code’s intent.

Each iteration of this method’s for loop will trigger the Flowable from getAllAnimals. Worst case, this can cause some backpressure in the stream. This isn’t a problem — Room’s backpressure strategy keeps only the latest event, which is what you want in the end. Still, it’s something important to be aware of.

You don’t need to declare any more methods for now. Open the PetSaveDatabase.kt file in the common.data.cache package and add the following code:

@Database(
  // ...
)
abstract class PetSaveDatabase : RoomDatabase() {
  // ...
  abstract fun animalsDao(): AnimalsDao // HERE
}

By adding animalsDao(), you tell Room that AnimalsDao is also available so it’ll provide a way to access it.

Build the app. Go back to AnimalsDao now and you’ll see that the IDE displays a little green icon on the left, meaning that something implemented the abstract methods.

Figure 5.2 — Room Dao implementation
Figure 5.2 — Room Dao implementation

Room automagically did all the hard work for you!

Updating the Cache to Handle the Data

You now need to update the cache implementation to handle animal data. Open Cache.kt under common.data.cache. This is the interface that exposes the caching methods for the repository to use. In terms of abstraction level, it’s on par with PetFinderApi. The difference is that you have to implement it manually because Room only creates the lower-level stuff for you.

Cache already has code for organizations. The code for animals is the DAO equivalent of the methods you just created.

Add the following code:

interface Cache {
  // ...
  fun getNearbyAnimals(): Flowable<List<CachedAnimalAggregate>>
  suspend fun storeNearbyAnimals(animals: List<CachedAnimalAggregate>)
}

Now, open RoomCache.kt in the common.data.cache package that contains the class that implements Cache. Notice how the name is the interface’s name prefixed with the mechanism you’ll use to handle caching. Always try to name interface implementations based on their purpose and/or functionality. Suffixing interface implementations with Impl doesn’t give you any information about them.

Next, update its code, like this:

class RoomCache @Inject constructor(
    private val animalsDao: AnimalsDao, // 1
    private val organizationsDao: OrganizationsDao
) : Cache {
  // ...
  override fun getNearbyAnimals(): Flowable<List<CachedAnimalAggregate>> { // 2
    return animalsDao.getAllAnimals()
  }

  override suspend fun storeNearbyAnimals(animals: List<CachedAnimalAggregate>) { // 3
    animalsDao.insertAnimalsWithDetails(animals)
  }
}

In this code, you:

  1. Add the primary constructor parameter of type AnimalsDao.
  2. Implement getNearbyAnimals(), which delegates the operation to animalsDao by invoking getAllAnimals() on it.
  3. Do the same for storeNearbyAnimals(), delegating again to animalsDao, but this time invoking insertAnimalsWithDetails.

These simply wrap the DAO calls with more domain-friendly names.

And… you’re done! Build and run to ensure everything still works.

You just injected an AnimalsDao instance. However, you can’t annotate abstract classes with @Inject. Even if you could, you’d still want Room’s implementation of it, and not Hilt’s. That said, you don’t have a way to provide an instance yet. But not for long!

Managing Cache Dependencies With Hilt

Open CacheModule.kt in common.data.di. It’s already a Dagger module, but it’s missing some provider methods:

  1. PetSaveDatabase
  2. AnimalsDao
  3. Cache

You’ll work on the methods in that order. Update the code like this:

@Module
@InstallIn(SingletonComponent::class)
abstract class CacheModule {

  companion object {

    @Provides
    @Singleton // 1
    fun provideDatabase(
        @ApplicationContext context: Context // 2
    ): PetSaveDatabase {
      return Room.databaseBuilder( // 3
          context,
          PetSaveDatabase::class.java,
          "petsave.db"
      )
          .build()
    }

    @Provides
    fun provideAnimalsDao(
        petSaveDatabase: PetSaveDatabase
    ): AnimalsDao = petSaveDatabase.animalsDao() // 4
    // ...
  }
}

A few different things are happening here:

  1. Due to all the SQLite setup, creating a Room database is expensive. Ideally, you create a reference and reuse it, instead. For that reason, you annotate the method with @Singleton.
  2. This @ApplicationContext is another one of Hilt’s useful features. You don’t need to use Dagger’s @BindsInstance to provide a Context anymore. Instead, you annotate a Context with @ApplicationContext and Hilt automatically injects it for you. You could also use @ActivityContext, but here you want the context for the application because you want the database to have the lifetime of the app itself.
  3. You return a Room database instance specifying PetSaveDatabase, which is the class type that extends RoomDatabase. You then give the database a name which, for consistency, is the same name the app uses.
  4. You inject the PetSaveDatabase parameter you provide in the previous method, then you use it to call animalsDao(). This returns Room’s own implementation of the class.

The bindings for the DAOs and the database are in the dependency graph. You need now to add the Cache.

Adding the Cache

You’ll handle Cache a little differently. You want to provide the Cache interface type, but you also have to provide the class that implements it, RoomCache. This is where Dagger’s @Binds comes in. In the same CacheModule.kt in common.data.di, add the following code outside the companion object:

@Module
@InstallIn(SingletonComponent::class)
abstract class CacheModule {

  @Binds
  abstract fun bindCache(cache: RoomCache): Cache // HERE

  companion object {
    // ...
  }
}

This allows you to provide the return type, but under the hood, along with whatever you pass in as a parameter. The parameter has to be assignable to the return type.

This is why CacheModule is an abstract class instead of an object like ApiModule. You can only apply the @Binds annotation to abstract methods, which an object can’t have. Regardless, by having the @Provides-annotated bindings inside the companion, you get the same benefits as if you were using an object module.

That’s it for cache dependency injection. Following the same pattern you’ve used so far, the next step would usually be to test the code. In this case, however, you’ll skip that and leave it as a challenge you can do to get some extra practice. Now, you’ll move on to assembling the repository.

Putting It All Together

At this point, you already have a sense of the data your app needs. You now have to update the repository interface in the domain layer accordingly.

It’s funny that the data layer actually helps you to figure out the domain layer — but, hey, that’s Android for you. It might seem that the domain layer has an implicit dependency on the data layer. This also makes it clearer why some people just prefer to skip the domain layer altogether. In all fairness, you need the data for the features that the domain defines. It can become tricky to understand what depends on what. :]

Anyway, climbing back out of the “Android doesn’t need a domain layer” rabbit hole, go to domain.repositories and open AnimalRepository.kt. Unless I’ve done a horrible job up to now, you should already expect the repository to provide ways to:

  • Get cached data.
  • Store cached data.
  • Get remote data.

Turning that into code, change AnimalRepository, like this:

interface AnimalRepository {
  fun getAnimals(): Flowable<List<Animal>> // 1
  suspend fun requestMoreAnimals(pageToLoad: Int, numberOfItems: Int): PaginatedAnimals // 2
  suspend fun storeAnimals(animals: List<AnimalWithDetails>) // 3
}

These should be self-explanatory. The declaration:

  1. Returns the Flowable that emits when the database updates.
  2. Calls the API to get more animals, passing in the page number and how many animals you want. Like the API call, it’s a suspend function.
  3. Stores a list of animals in the database.

Now, the implementation. In common.data, create a new file called PetFinderAnimalRepository.kt. In it, create PetFinderAnimalRepository, like this:

class PetFinderAnimalRepository @Inject constructor( // 1
    private val api: PetFinderApi, // 2
    private val cache: Cache,
    private val apiAnimalMapper: ApiAnimalMapper,
    private val apiPaginationMapper: ApiPaginationMapper
) : AnimalRepository

Here, you:

  1. Annotate the constructor with @Inject, both to inject PetFinderAnimalRepository when needed and to inject other dependencies into it.
  2. Add the dependencies you need to fulfill the interface contract. Since it implements a domain interface, this class defines a boundary between layers. That’s why you need those two mappers.

Since you haven’t implemented the interface yet, there’s a red squiggly line under the class’s name. To fix that, place the cursor on the class name and press Option-Enter on MacOS or Alt-Enter on Windows.

Figure 5.3 — Implement missing members
Figure 5.3 — Implement missing members

Choose the Implement members option, then select all three in the dialog that opens.

Figure 5.4 — Select all operations
Figure 5.4 — Select all operations

This creates the stubs for the missing classes. They contain some TODOs, but you’ll come back to work on them soon.

Returning the Flowable

Going one by one, delete the TODO comments in getAnimals and add the following in their place:

class PetFinderAnimalRepository @Inject constructor(
    private val api: PetFinderApi,
    private val cache: Cache,
    private val apiAnimalMapper: ApiAnimalMapper,
    private val apiPaginationMapper: ApiPaginationMapper
) : AnimalRepository {
  override fun getAnimals(): Flowable<List<Animal>> {
    return cache.getNearbyAnimals() // 1
        .distinctUntilChanged() // 2
        .map { animalList -> // 3
          animalList.map {
            it.animal.toAnimalDomain(
                it.photos,
                it.videos,
                it.tags
            )
          }
        }
  }
  // ...
}

This code:

  1. Calls the corresponding cache method, which returns a Flowable.
  2. Calls distinctUntilChanged on the stream. This is important because it ensures only events with new information get to the subscriber. For instance, since the insertion abstract method has the REPLACE conflict strategy, the same items can get inserted. In general, it’s a good practice to use this operator with Room because Room knows when a table is modified, but doesn’t know what was modified. That means, if you’re observing only one item, you’ll get false updates when any table involved in the corresponding SQLite query changes.
  3. Maps the CachedAnimalAggregate list to the Animal list by calling the toAnimalDomain mapper for each CachedAnimalAggregate instance.

The previous operation returns all the Animals in the repository. You need now to implement the other operations.

Calling the API for More Animals

Next, you’ll modify requestMoreAnimals. Replace the TODO with the following code:

class PetFinderAnimalRepository @Inject constructor(
    private val api: PetFinderApi,
    private val cache: Cache,
    private val apiAnimalMapper: ApiAnimalMapper,
    private val apiPaginationMapper: ApiPaginationMapper
) : AnimalRepository {
  // ...
  override suspend fun requestMoreAnimals(pageToLoad: Int, numberOfItems: Int): PaginatedAnimals {
    val (apiAnimals, apiPagination) = api.getNearbyAnimals( // 1
        pageToLoad,
        numberOfItems,
        postcode,
        maxDistanceMiles
    )

    return PaginatedAnimals( // 2
        apiAnimals?.map {
          apiAnimalMapper.mapToDomain(it)
        }.orEmpty(),
        apiPaginationMapper.mapToDomain(apiPagination)
    )
  }

  private val postcode = "07097" // 3
  private val maxDistanceMiles = 100 // 3
  // ...
}

Here, you:

  1. Call the corresponding API method and destructure the resulting ApiPaginatedAnimals instance.
  2. Build a PaginatedAnimals instance with the destructured components, using the mappers in the process.
  3. postcode and maxDistanceMiles don’t exist yet. You’ll get these later, by using another feature. Right now, you just use temporary values so you can add them as properties.

You’re still missing one final operation.

Storing the Animal List in the Database

Finally, you’ll handle storeAnimals. Add the following code:

class PetFinderAnimalRepository @Inject constructor(
    private val api: PetFinderApi,
    private val cache: Cache,
    private val apiAnimalMapper: ApiAnimalMapper,
    private val apiPaginationMapper: ApiPaginationMapper
) : AnimalRepository {
  // ...
  override suspend fun storeAnimals(animals: List<AnimalWithDetails>) {
    val organizations = animals.map { CachedOrganization.fromDomain(it.details.organization) } // 1

    cache.storeOrganizations(organizations)
    cache.storeNearbyAnimals(animals.map { CachedAnimalAggregate.fromDomain(it) }) // 2
  }
}

Here’s what’s going on in this code:

  1. You map each Organization to a CachedOrganization, creating a list. Don’t forget that organizations have a one-to-many relationship with animals, so you have to insert them before inserting animals. Otherwise, Room will complain about not being able to satisfy the foreign key’s constraint in CachedAnimalWithDetails.
  2. After inserting all the organizations, you insert the animals, mapping them to the appropriate type.

That’s it! Build and run, and everything should go smoothly. Well, really, it has to. You implemented this data layer code, but so far, no other code is using it — and won’t until the next chapter. If only you could assert the correctness of your code somehow… You know where this is going. :]

Testing Your Repository

The repository is a great opportunity to venture into integration tests. In fact, unit testing wouldn’t add anything new to what you’ve seen so far, so you’ll only do integration tests. This means that instead of using fake or mock dependencies, you’ll use the real thing! Most of it, at least. :]

Start by creating the test file for PetFinderAnimalRepository with Android Studio’s aid, just as you did for the interceptor. Only this time, instead of choosing the test directory, choose androidTest. You’ll see why later.

You’ll start by testing the integration with the network code. This involves:

  • The API itself
  • The interceptors
  • Preferences

You need to build an instance of the API, using an instance of OkHttpClient that has the interceptors. It, in turn, needs a fake/mock Preferences. Plus, you also need a Cache instance to create the repository, along with mapper instances.

Ugh. No wonder so many people just skip testing completely! The thing is, those people probably aren’t aware of the power a DI framework leverages to make testing a breeze.

Integration Tests With Hilt

Hilt has some built-in features specifically for testing. It requires some configuration in the beginning, but that work pays off later. Your steps are to:

  1. Implement a custom AndroidJUnitRunner implementation for testing with Hilt.
  2. Configure the custom runner for Instrumentation tests.
  3. Set up an instrumented test for PetFinderAnimalRepository.
  4. Prepare the dependency graph for the instrumentation test.
  5. Implement @Before with code that all the tests have in common.
  6. Write your tests.

It’s time to have some more fun. :]

Implementing HiltTestRunner

You’ll run instrumented tests here, so the first thing to do is tell your test runner to run them with an Application object that supports Hilt. For this, Hilt provides its own instance, HiltTestApplication.

In the petsave package of androidTest, create a file called HiltTestRunner.kt. In it, add:

class HiltTestRunner : AndroidJUnitRunner() {

  override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
    return super.newApplication(cl, HiltTestApplication::class.java.name, context)
  }
}

Here, you create a test runner that forces the tests to run in HiltTestApplication. Now, you need to set your test configuration to use this test runner.

Configuring the HiltTestRunner

You need to tell Gradle to use HiltTestRunner when running an instrumentation test. Open build.gradle in the app module and apply the following changes:

// ...
android {
  compileSdkVersion rootProject.ext.compileSdkVersion

  defaultConfig {
    // ...
    testInstrumentationRunner "com.realworld.android.petsave.HiltTestRunner" // 1
    // ...
  }
  // ...
  sourceSets { // 2
    androidTest {
      assets.srcDirs = ["src/debug/assets"]
    }
  }
}
// ...

In this definition, you:

  1. Tell Gradle to use HiltTestRunner as the AndroidJUnitRunner for instrumentation tests.
  2. Configure Gradle to reach the assets in the debug package, like you did for the unit tests earlier. You’ll use MockWebServer for these tests, so you need this code to access the mocked API responses.

Sync Gradle to make sure it accepts the settings. Now, it has all it needs to run the instrumentation test with Hilt. It’s time to write the test for PetFinderAnimalRepository.

Preparing Your Instrumentation Test

Now, for the fun stuff! Back in PetFinderAnimalRepositoryTest.kt, add:

@HiltAndroidTest // 1
@UninstallModules(PreferencesModule::class) // 2
class PetFinderAnimalRepositoryTest

This code:

  1. Marks the test class for injection. This way, Hilt will know it has to inject some dependencies here.
  2. Tells Hilt to not load the original PreferencesModule, so you can replace it with a test module.

You need to add a few properties to the class now. Inside the same PetFinderAnimalRepositoryTest.kt, add:

@HiltAndroidTest
@UninstallModules(PreferencesModule::class)
class PetFinderAnimalRepositoryTest {
  private val fakeServer = FakeServer() // 1
  private lateinit var repository: AnimalRepository
  private lateinit var api: PetFinderApi

  @get:Rule // 2
  val hiltRule = HiltAndroidRule(this)

  @Inject // 3
  lateinit var cache: Cache

  @Inject
  lateinit var retrofitBuilder: Retrofit.Builder

  @Inject
  lateinit var apiAnimalMapper: ApiAnimalMapper

  @Inject
  lateinit var apiPaginationMapper: ApiPaginationMapper
}

Here, you:

  1. Add AnimalRepository and PetFinderApi properties, which you’ll initialize later. You also create something called FakeServer. This is a helper class that will handle MockWebServer for you, including reading from the assets.
  2. Add a Hilt rule that you’ll use later to tell Hilt when to inject the dependencies. This is important because it gives you leeway to handle any configuration you might need before the injection.
  3. Mark every dependency you want to inject with @Inject so Hilt knows what you need.

At this point, you’re injecting all the dependencies you need to build a PetFinderAnimalRepository instance, except for the API. That’s because you need to configure the API manually, using the Retrofit.Builder you’re injecting here.

You’ll get to that in a second; there’s still one dependency missing. Remember that you told Hilt to ignore PreferencesModule through @UninstallModules? You need to provide a replacement, or the test won’t even build.

Providing the Dependency Graph for Testing

When you implement an instrumentation test with Hilt and want to inject test dependencies, you need to provide a different dependency graph than the one you use in the production app. In your case, you need to replace PreferencesModule.

Hilt gives you two options here:

  1. Build an entirely new module to replace the original binding.
  2. Use a special set of annotations that both replace the original binding and bind anything else in its place.

For now, you’ll go with the second option. In the same PetFinderAnimalRepositoryTest.kt file add the following code:

Below the other dependencies you just added, add:

@HiltAndroidTest
@UninstallModules(PreferencesModule::class)
class PetFinderAnimalRepositoryTest {
  // ...
  @BindValue // 1
  val preferences: Preferences = FakePreferences() // 2
}

Going line-by-line:

  1. This annotation handles the replacement and injection for you. If you need to need to work with multibindings, there are other variants to use, like @BindValueIntoSet and @BindValueIntoMap.
  2. This is what you replace the original binding with. You’re providing a fake implementation of Preferences that simply has a private map to read and write the properties.

Now, it’s time to implement your tests. They’ll have some initalization in common. You can handle this in @Before.

Implementing the @Before Function

With this done, you can now implement the typical “before and after” test configuration methods. Add setup and teardown below what you just added:

@HiltAndroidTest
@UninstallModules(PreferencesModule::class)
class PetFinderAnimalRepositoryTest {
  // ...
  @Before
  fun setup() {
    fakeServer.start() // 1

    // 2
    preferences.deleteTokenInfo()
    preferences.putToken("validToken")
    preferences.putTokenExpirationTime(
        Instant.now().plusSeconds(3600).epochSecond
    )
    preferences.putTokenType("Bearer")

    hiltRule.inject() // 3

    // 4
    api = retrofitBuilder
        .baseUrl(fakeServer.baseEndpoint)
        .build()
        .create(PetFinderApi::class.java)

    // 5
    repository = PetFinderAnimalRepository(
        api,
        cache,
        apiAnimalMapper,
        apiPaginationMapper
    )
  }

  @After // 6
  fun teardown() {
    fakeServer.shutdown()
  }
}

In this code:

  1. You tell the fake server to start itself. This will start the MockWebServer instance.
  2. This is a cool thing that Hilt lets you do. Since you already created the instance that will replace the original Preferences binding, you can change it. So here, you delete any previous information and add the information you need for the “happy path”. You’ll only test happy paths. Note that you could also mark the property as a lateinit var and initialize it here.
  3. You’ve configured all the dependencies, so you’re ready for Hilt to inject them. To do so, you call inject on the rule instance.
  4. Before creating the repository instance, you still need to configure the API. You need to redirect the calls to MockWebServer instead of the real endpoint, so you take the Retrofit.Builder you injected earlier, change its base URL and, finally, create a PetFinderApi instance.
  5. Lastly, you create the repository instance.
  6. This just shuts off the server, like you did in the unit tests.

Finally, you can start writing your tests.

Writing Your Tests

Whew! That took some work, but you’re finally able to write your tests. In the same PetFinderAnimalRepositoryTest.kt file add the following code:

@HiltAndroidTest
@UninstallModules(PreferencesModule::class, CacheModule::class)
class PetFinderAnimalRepositoryTest {
  // ...
  @Test
  fun requestMoreAnimals_success() = runBlocking { // 1
    // Given
    val expectedAnimalId = 124L
    fakeServer.setHappyPathDispatcher() // 2

    // When
    val paginatedAnimals = repository.requestMoreAnimals(1, 100) // 3

    // Then
    val animal = paginatedAnimals.animals.first() // 4
    assertThat(animal.id).isEqualTo(expectedAnimalId)
  }
  // ...
}

In this code, you use:

  1. runBlocking, which makes the test run inside a coroutine. You need it because the network request is a suspend function.
  2. Set MockWebServer to choose the happy path: Return a successful response on the animals endpoint.
  3. Make the request. MockWebServer doesn’t check the request parameters in this case, but in theory, it could.
  4. Get the first — and only, since the mocked response only has one in the list — animal, and check if its ID matches the expected value.

Note: Never use runBlocking in production code! It completely blocks the thread the coroutine runs on, defeating the purpose of having coroutines in the first place.

This test starts at the repository, goes to the API, works through the interceptors and goes back up again. With Hilt’s help, not only can you test the way all the bits and pieces fit together, but you can also make little tweaks here and there to test a wide variety of scenarios.

Now that you’ve covered the API integration, you’ll test how the cache fits into all this. There’s one small problem though: You don’t want to mess with the real database in your tests!

Using Room’s In-Memory Database

Fortunately, Room provides a neat way to work around this: an in-memory database. You’ll inject that instead of the production database, then use it to test the repository. That’s why you’re running the tests in androidTest: Room needs to run on a device.

First, tell Hilt to remove CacheModule by updating @UninstallModules:

@UninstallModules(PreferencesModule::class, CacheModule::class)

Then, remove the @Inject annotation from cache:

private lateinit var cache: Cache

Below it, inject PetSaveDatabase:

@Inject
lateinit var database: PetSaveDatabase

Now, to replace CacheModule, you’ll try the other option. Add a new module below Preferences:

@Module
@InstallIn(SingletonComponent::class) // 1
object TestCacheModule {

  @Provides
  fun provideRoomDatabase(): PetSaveDatabase { // 2
    return Room.inMemoryDatabaseBuilder(
        InstrumentationRegistry.getInstrumentation().context,
        PetSaveDatabase::class.java
    )
        .allowMainThreadQueries() // 3
        .build()
  }
}

In this code, you:

  1. Install TestCacheModule in the same component as CacheModule.
  2. Provide a single PetSaveDatabase instance, as you don’t need any of the other dependencies.
  3. Call allowMainThreadQueries when building the in-memory database, which lets you ignore the thread where you run the queries in your tests. Please, never do this in production code!

Building the Cache

With the module ready, you can now build your own Cache with the test database. In setup, between api and repository, add the line:

cache = RoomCache(database.animalsDao(), database.organizationsDao())

And that’s it! It’s time for an actual test now. You’ll test the insertion into the database. To assert the result, you’ll use getAllAnimals and check if the Flowable stream returns anything. Technically, it’s almost like doing two tests in one!

Speaking of streams, you want it to emit the new event right away, so you need to ensure that Room executes all its operations instantly. To do this, you have to use a JUnit rule that swaps the background executor used by Architecture Components with one that’s synchronous. This rule is called InstantTaskExecutorRule. Add it below the Hilt rule:

@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()

With that out of the way, you’ll create your test. At the bottom of the class, add:

@Test
fun insertAnimals_success() {
  // Given
  val expectedAnimalId = 124L

  runBlocking {
    fakeServer.setHappyPathDispatcher()

    val paginatedAnimals = repository.requestMoreAnimals(1, 100) // 1
    val animal = paginatedAnimals.animals.first()

    // When
    repository.storeAnimals(listOf(animal)) // 2
  }

  // Then
  val testObserver = repository.getAnimals().test() // 3

  testObserver.assertNoErrors() // 4
  testObserver.assertNotComplete()
  testObserver.assertValue { it.first().id == expectedAnimalId }
}

Here’s a breakdown of this code:

  1. To save you from creating an Animal instance, the code uses the mocked data that MockWebServer returns.
  2. You store the animal…
  3. … And then subscribe to the getAnimals stream. Calling test() on it returns a special TestObserver that allows you to assess both its state and the stream’s.
  4. Using the test observer, you assert that there were no errors on the stream and it didn’t complete. It’s an infinite stream after all. Finally, you call assertValue on the observer, which asserts that the stream emitted one event only. It also gives you access to event data for you to make sure that it’s what you expect.

This test is not completely contained in runBlocking, like the previous one. JUnit tests are expected to return Unit and that last testObserver line returns a type. Having it inside runBlocking’s scope would return that type.

Build and run your tests. Congratulations on making sure your different components are well integrated!

This concludes this chapter. In the next chapters, you’ll include the presentation layer in your work and, finally, wrap up a basic version of the Animals near you and Search features.

Key Points

  • Room relationships allow you easily manipulate data… after you go through the effort of creating them.
  • Dependency injection is helpful, not only with dependency management, but with testing as well.
  • Just like MockWebServer, Room’s in-memory database allows you to build more robust and realistic tests.
Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.