Chapters

Hide chapters

Real-World Android by Tutorials

First Edition · Android 10 · Kotlin 1.4 · AS 4

Section I: Developing Real World Apps

Section 1: 7 chapters
Show chapters Hide chapters

7. Building Features — Search
Written by Ricardo Costeira

In the previous chapters, you developed the Animals near you feature. You built it one layer at a time, with the small exception of the use cases. For the Search feature, you’ll follow a more dynamic approach, adding code to the layers as you need it.

In this chapter, you’ll learn about:

  • Handling user-triggered events.
  • Reacting to different events and reducing them to the same view state.
  • Handling pending requests.
  • Testing, and the advantages that this architecture brings to it.

There’s a lot of fun ahead!

Building a search feature

Your goal now is to create a search function to help potential owners find their perfect pet. Here’s a breakdown of how the feature works:

  1. The user types the animal’s name in the search query.
  2. The user can filter the queries by the age and type of animal.
  3. The app searches the cache for matching animals.
  4. If no animals exist locally, the app sends a request to the PetFinder API.
  5. The app stores the API result, if it finds one, and shows the search results to the user.

Now, it’s time to jump in and start finding pets!

Getting started

To start, go to the fragment_search layout and look around. The whole UI is ready to go: You have a search widget and Views to display the remote search and no results cases.

Figure 7.1 — SearchFragment Layout
Figure 7.1 — SearchFragment Layout

In the code, notice how every View has an ID. As a best practice, you should have IDs for all your Views. Views can be stateful and the Android system needs those IDs to restore their state when necessary. For instance, if a ScrollView doesn’t have an ID, the system won’t restore its scroll position after a configuration change.

Another thing to keep in mind is that you should strive for unique IDs whenever possible. This applies not only to the layout you’re working on, but throughout the whole app. This helps the system search for the correct View in the hierarchy tree. Having the same IDs can lead to subtle bugs in cases where you include different layouts under the same View hierarchy.

Searching locally

According to your plan, the app should search for pet names locally before calling on the remote API.

The classes you need to do this already exist. Open SearchFragment.kt in the search.presentation package. You’ll notice that it has a similar basic UI code as AnimalsNearYouFragment.kt, in the animalsnearyou.presentation package, does.

The app only has these two Fragments, so it’s not a big deal. With more Fragments, it might make sense to extract the common code into a common class or set of functions. Just don’t create a BaseFragment class. Over time, base classes get polluted with code that only specific child classes use. This creates an implicit coupling between that code and classes that don’t use it. It also turns the base class into a spaghetti mess, making maintenance and refactoring harder.

Ideally, you’d delegate the intended behavior through well-defined, single-responsibility classes that Fragments can then use through composition.

You need to set up a few things before the user can start interacting with the UI:

  • Search field: The search field is a SearchView. You need to set it up with an OnQueryTextListener to react to text changes.
  • Filters: Both filters are AutoCompleteTextView instances. You need to add an OnItemClickListener to both, so you can retrieve the selected option.

Every interaction will trigger an event, and each event is sent to the ViewModel. You can find the pre-created events in SearchEvent.kt:

sealed class SearchEvent {
  object PrepareForSearch : SearchEvent()
  data class QueryInput(val input: String): SearchEvent()
  data class AgeValueSelected(val age: String): SearchEvent()
  data class TypeValueSelected(val type: String): SearchEvent()
}

Recognizing text in the search field

Your first step is to change the search field so it recognizes when the user types a query. Start by adding the following method in the SearchFragment.kt:

@AndroidEntryPoint
class SearchFragment : Fragment() {
  // ...
  private fun setupSearchViewListener() {
    val searchView = binding.searchWidget.search

    searchView.setOnQueryTextListener(
        object : SearchView.OnQueryTextListener {
          override fun onQueryTextSubmit(
              query: String?
          ): Boolean {
            viewModel.onEvent(
              SearchEvent.QueryInput(query.orEmpty())  // 1
            )
            searchView.clearFocus()
            return true
          }

          override fun onQueryTextChange(
              newText: String?
          ): Boolean {
            viewModel.onEvent(
              SearchEvent.QueryInput(newText.orEmpty()) // 2
            )
            return true
          }
        }
    )
  }
  // ...
}

This method creates and sets OnQueryTextListener on SearchView. It sends a SearchEvent.QueryInput event:

  1. With the text you receive as a parameter of the onQueryTextSubmit() callback that’s invoked when you submit the text in input.
  2. With the String you get every time the text in input changes and onQueryTextChange() is invoked.

Both of the overrides trigger events on the ViewModel, updating the search query. The difference between them is that onQueryTextSubmit also calls clearFocus on the SearchView. This hides the soft keyboard when the user taps its Search button.

Handling the search filters

Next, you need to add the functionality that lets the user filter their results by age and type of animal. To handle the filters, add these methods to the same SearchFragment.kt:

@AndroidEntryPoint
class SearchFragment : Fragment() {
  // ...
  // 1
  private fun setupFilterListeners() {
    with (binding.searchWidget) {
      setupFilterListenerFor(age) { item ->
        viewModel
          .onEvent(SearchEvent.AgeValueSelected(item)) // 2
      }

      setupFilterListenerFor(type) { item ->
        viewModel
          .onEvent(SearchEvent.TypeValueSelected(item)) // 3
      }
    }
  }

  // 4
  private fun setupFilterListenerFor(
      filter: AutoCompleteTextView,
      block: (item: String) -> Unit
  ) {

    filter.onItemClickListener =
        AdapterView.OnItemClickListener { parent, _, position, _ ->
          parent?.let {
            block(it.adapter.getItem(position) as String)
          }
        }
  }
  // ...
}

This code defines:

  1. setupFilterListeners() as a utility method that allows you to set up the filter logic for the age and type of animal, passing in a lambda that triggers the ViewModel event that updates each filter.
  2. The event to trigger when the user selects a new age.
  3. The event to trigger when the user selects a new type.
  4. setupFilterListenerFor as a method that sets the listener on the filters. The listener gets the filter at a given position and passes it into the lambda. The behavior is the same for both filters, so you reuse it.

To call all these methods, update SearchFragment like this:

@AndroidEntryPoint
class SearchFragment : Fragment() {
  // ...
  private fun prepareForSearch() { // 1
    setupFilterListeners()
    setupSearchViewListener()
    viewModel.onEvent(SearchEvent.PrepareForSearch) // 2
  }

  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    setupUI()
    prepareForSearch() // 3
  }
  // ...
}

In this code you:

  1. Add prepareForSearch() as a convenience method that invokes both setupFilterListeners() and setupSearchViewListener().
  2. Send the SearchEvent.PrepareForSearch event to the ViewModel, so it knows when the UI is ready to start searching.
  3. Call prepareForSearch() when you initialize the UI for SearchFragment.

Build and run to make sure you didn’t break anything. You haven’t handled the events on the ViewModel side yet, so you’ll see the same incomplete UI as Figure 7.2:

Figure 7.2 — SearchFragment in Action
Figure 7.2 — SearchFragment in Action

Dealing with a more complex state

Before going to the ViewModel, open SearchViewState.kt. It might not look like it, but this view state is a lot more complex than the other one:

data class SearchViewState(
    val noSearchQuery: Boolean = true,
    val searchResults: List<UIAnimal> = emptyList(),
    val ageFilterValues: Event<List<String>> = Event(emptyList()),
    val typeFilterValues: Event<List<String>> = Event(emptyList()),
    val searchingRemotely: Boolean = false,
    val noRemoteResults: Boolean = false,
    val failure: Event<Throwable>? = null
)

The filters are modeled as Event’s for performance reasons.

AutoCompleteTextView uses an Adapter — not the same Adapter you used with RecyclerView — to display items. The simplest way to update that Adapter is to create a new one with the updated data. Once you set the filters, the data they display doesn’t change. However, creating a new Adapter on each state update is a waste of resources. Using the Event wrapper class, you ensure you only create one Adapter for each filter.

This feature has a lot of different states. It would get too complex to manage them without losing track of what they mean. That’s why SearchViewState has a few methods to manage that for you. Each method copies the original state into a new one associated with the method’s name.

You could also use sealed classes here, keeping a class for each state. Sealed classes have no copy method, though. So in that case, you’d either have to handle the state update itself or apply a State pattern.

The methods help give you an idea of the current state, but you can still have impossible state updates like going to a no remote results state immediately after a no search query state. At this point, the code is still simple enough to catch bugs like this quickly, but you might have to update to sealed classes if the state gets more complicated.

Populating the filters

Open SearchFragmentViewModel.kt. You can see that onEvent() already reacts to events, but the methods it calls don’t do anything yet. You’ll change that now adding loadFilterValues() like this:

@HiltViewModel
class SearchFragmentViewModel @Inject constructor(
    private val uiAnimalMapper: UiAnimalMapper,
    private val dispatchersProvider: DispatchersProvider,
    private val compositeDisposable: CompositeDisposable
) : ViewModel() {
  // ...
  private fun loadFilterValues() {
    // 1
    val exceptionHandler =
        createExceptionHandler(
            message = "Failed to get filter values!"
        )
    viewModelScope.launch(exceptionHandler) {
      val (ages, types) = withContext(dispatchersProvider.io()) {
        getSearchFilters() // 2
      }
      updateStateWithFilterValues(ages, types) // 3
    }
  }
}

This code:

  1. Creates CoroutineExceptionHandler through createExceptionHandler(), which you defined in the ViewModel.
  2. Launches a coroutine in viewModelScope. The coroutine calls getSearchFilters() in the background. The return value is destructured into ages and types. getSearchFilters() is a use case.
  3. Calls updateStateWithFilterValues() and passes in the filter values.

Before creating the use case, create updateStateWithFilterValues() like this:

@HiltViewModel
class SearchFragmentViewModel @Inject constructor(
    private val uiAnimalMapper: UiAnimalMapper,
    private val dispatchersProvider: DispatchersProvider,
    private val compositeDisposable: CompositeDisposable
) : ViewModel() {
  // ...
  private fun updateStateWithFilterValues(
      ages: List<String>,
      types: List<String>
  ) {
    _state.value = state.value!!.updateToReadyToSearch(
        ages,
        types
    )
  }
}

Now that it has the filter data, the UI is ready for the user, so you update the state to ready to search.

Updating the domain with search

You need to create getSearchFilters(), the use case that gets the data to populate the filters.

First, think about what the use case should return. Strings? Animals? According to the use case’s name, it should return search filters. But the domain layer doesn’t know what a search filter is, nor does it have any knowledge about the search.

To change this, go to the search.domain.model package and create the SearchFilters.kt file with the following code:

data class SearchFilters(
    val ages: List<String>,
    val types: List<String>
)

Now, create GetSearchFilters.kt in the search.domain.usecases package and write the use case class, like this:

class GetSearchFilters @Inject constructor(
    private val animalRepository: AnimalRepository
) {

  companion object {
    const val NO_FILTER_SELECTED = "Any"
  }

  suspend operator fun invoke(): SearchFilters {

  }
}

The companion object property is the default value for both filters. The use case will get both ages and types from the repository. The methods for this already exist. Don’t worry, you’ll create the whole method chain for the next use case. :]

Getting data from the repository

Now, you’re going to add the functionality to get the search results from the repository.

Complete invoke() with the following code:

suspend operator fun invoke(): SearchFilters {
  val unknown = Age.UNKNOWN.name

  //1
  val types =
      listOf(NO_FILTER_SELECTED) + animalRepository.getAnimalTypes()

  // 2
  val ages = animalRepository.getAnimalAges()
      .map {
        if (it.name == unknown) {
          NO_FILTER_SELECTED
        } else {
          it.name.toLowerCase(Locale.ROOT).capitalize(Locale.ROOT)
        }
      }

  return SearchFilters(ages, types)
}

Here, you:

  1. Request the animal types from the repository and add a default value, Any, to the head of the type list.
  2. Get the ages from the repository, then map the Enums to their names, replacing UNKNOWN with Any and capitalizing the words. After that, you return SearchFilters with the ages and types.

The default value will be at the head of the ages list as well. This is due to the order you set the Enum values.

Note: It’s not advisable to rely on Enum’s value order, which can change over time. By doing so, you create a tight coupling between this code and the Enum’s implementation.

Head to SearchFragmentViewModel. Inject the use case in the constructor:

@HiltViewModel
class SearchFragmentViewModel @Inject constructor(
    private val uiAnimalMapper: UiAnimalMapper,
    private val getSearchFilters: GetSearchFilters, // HERE
    private val dispatchersProvider: DispatchersProvider,
    private val compositeDisposable: CompositeDisposable
): ViewModel()

Finally, in the empty prepareForSearch() method of the VIewModel, call loadFilterValues():

private fun prepareForSearch() {
  loadFilterValues()
}

Build and run to make sure everything works. You won’t see any differences yet because the Fragment isn’t observing these changes.

Updating the UI

To update the UI with the filter information, you need to implement two methods. Go to SearchFragment and add:

@AndroidEntryPoint
class SearchFragment : Fragment() {
  // ...
  // 1
  private fun setupFilterValues(
      filter: AutoCompleteTextView,
      filterValues: List<String>?
  ) {
    if (filterValues == null || filterValues.isEmpty()) return

    filter.setAdapter(createFilterAdapter(filterValues))
    filter.setText(GetSearchFilters.NO_FILTER_SELECTED, false)
  }

  // 2
  private fun createFilterAdapter(
      adapterValues: List<String>
  ): ArrayAdapter<String> {
    return ArrayAdapter(
        requireContext(),
        R.layout.dropdown_menu_popup_item,
        adapterValues
    )
  }
  // ...
}

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

  1. You’ll use this method for both filters. It returns early if the list is either null or empty — for instance, on the initial state or when the filter content was already handled. It creates the adapter for a given filter and sets the filter to show the default value from the use case. Both filters will have the default value as the first one on the list. However, to avoid relying on the age Enum’s value order, it’s best to use the default value from the use case instead. Having the Fragment access the use case isn’t great either, but it’s better. A workaround here would be to have the ViewModel declare a property for the default value, which it would get from the use case, and have the Fragment access that instead.
  2. Creates an ArrayAdapter that displays a TextView for each element, as per the dropdown_menu_popup_item layout.

Finally, locate updateScreenState(), the method responsible for rendering the state. Update it to call setupFilterValues() for both filters, like this:

private fun updateScreenState(
    newState: SearchViewState,
    searchAdapter: AnimalsAdapter
) {
  val (
      inInitialState,
      searchResults,
      ageFilterValues,
      typeFilterValues,
      searchingRemotely,
      noResultsState,
      failure
  ) = newState

  updateInitialStateViews(inInitialState)

  with (binding.searchWidget) {
    setupFilterValues(
        age,
        ageFilterValues.getContentIfNotHandled()
    )
    setupFilterValues(
        type,
        typeFilterValues.getContentIfNotHandled()
    )
  }

  handleFailures(failure)
}

The view state observer already calls updateScreenState(), so view state updates will already trigger it.

Build and run. The app now displays the filters with data!

Figure 7.3 — Working Search Filters
Figure 7.3 — Working Search Filters

Cool. Now you can use this data, along with a search query, to search for animals.

Triggering the search

Earlier, you triggered the search parameters’ change events, but the code doesn’t react to them yet. You’ll change that next.

Open SearchFragmentViewModel.kt. At the top of the class is one BehaviorSubject for the search query and two others for the age and type filters. You’ll use all three of them, merge them into one single Flowable and operate on it so it searches the cache. This same Flowable will then update the view state.

Locate onSearchParametersUpdate() in SearchFragmentViewModel and update it to:

private fun onSearchParametersUpdate(event: SearchEvent) {
  when (event) {
    is SearchEvent.QueryInput -> updateQuery(event.input)
    is SearchEvent.AgeValueSelected -> updateAgeValue(event.age)
    is SearchEvent.TypeValueSelected -> updateTypeValue(event.type)
  }
}

This method is already called in onEvent() and, in turn, calls a different method for each event.

You’re probably getting a yellow squiggly line under the when. You could solve this by having these cases join the PrepareForSearch case, instead of having them in a separate method. However, bear with it for now — this separation will make sense later.

None of the methods exist, so add them in ´SearchFragmentViewModel`:

@HiltViewModel
class SearchFragmentViewModel @Inject constructor(
    private val uiAnimalMapper: UiAnimalMapper,
    private val getSearchFilters: GetSearchFilters,
    private val dispatchersProvider: DispatchersProvider,
    private val compositeDisposable: CompositeDisposable
): ViewModel() {
  // ...
  private fun updateQuery(input: String) {
    resetPagination() // 1

    querySubject.onNext(input) // 2

    // 3
    if (input.isEmpty()) {
      setNoSearchQueryState()
    } else {
      setSearchingState()
    }
  }

  // 4
  private fun updateAgeValue(age: String) {
    ageSubject.onNext(age)
  }

  private fun updateTypeValue(type: String) {
    typeSubject.onNext(type)
  }
  // ...
}

In this code, you:

  1. Reset the pagination with each query. The search screen needs infinite scrolling for the cases where the remote results return more than one page. For simplicity, though, some parts of that code were omitted.
  2. Send the input to the input’s BehaviorSubject.
  3. Want to show different things on the screen, depending on whether the input is empty or not. The no search query state is visually identical to the ready to search state. For instance, if you write something on the SearchView and then delete it, you want to revert to no search query.
  4. Send the selected filter values to the corresponding BehaviorSubjects.

Build and run. Now, when you type something into the search, the background cat will disappear as you update to the searching state.

Tying everything together

You now have all the ingredients for the local search. You just need to tie everything together to make the search work.

You’ll now create a SearchAnimals use case. Just like before, this use case returns a specific domain model: SearchResults.

Start by creating the model. In the search.domain.model package, create SearchResults.kt with the code:

data class SearchResults(
    val animals: List<Animal>,
    val searchParameters: SearchParameters
)

It’s composed of a list of animals and SearchParameters, a value object that models the search parameters. You’ll use it to search the cache and to propagate the search parameters to a remote search, in case nothing in the cache matches.

Create SearchParameters.kt in the same package, with the value object code:

data class SearchParameters(
    val name: String,
    val age: String,
    val type: String
)

Finally, in the search.domain.usecases package, create SearchAnimals.kt. In it, add the code:

class SearchAnimals @Inject constructor(
    private val animalRepository: AnimalRepository
) {
  operator fun invoke(
      querySubject: BehaviorSubject<String>,
      ageSubject: BehaviorSubject<String>,
      typeSubject: BehaviorSubject<String>
  ): Flowable<SearchResults> {

  }
}

This use case takes in all the BehaviorSubjects and outputs a Flowable of SearchResults. This Flowable emits new values every time one of the BehaviorSubjects emits something new.

You need to do some work on the streams before you’re able to use them. You’ll start with the query stream first.

Handling search queries

There are a few steps to follow to handle the search queries properly. Update the invoke operator method of the use case:

operator fun invoke(
    querySubject: BehaviorSubject<String>,
    ageSubject: BehaviorSubject<String>,
    typeSubject: BehaviorSubject<String>
): Flowable<SearchResults> {
  val query = querySubject
      .debounce(500L, TimeUnit.MILLISECONDS) // 1
      .map { it.trim() } // 2
      .filter { it.length >= 2 } // 3
}

Here’s what’s going on above:

  1. debounce is important because it helps you avoid reacting to every little change in the query. There’s no need to react instantly to what a user types when waiting half a second longer might allow you to provide more information. The user won’t notice, you’ll provide a better service and you’ll lighten the load on the device, performance-wise.
  2. The user might add unnecessary spaces before or after the query, and the app considers these to be characters. It’s best to remove them.
  3. This avoids events with a single character or less. Hopefully, there are no animals called Z or something. :]

Removing the Any value

For the filters, you need to replace the Any value with an empty string because you don’t want ages or types that match Any. The reason for this will become clearer when you implement the cache search method. For now, add these two lines to invoke:

operator fun invoke(
    querySubject: BehaviorSubject<String>,
    ageSubject: BehaviorSubject<String>,
    typeSubject: BehaviorSubject<String>
): Flowable<SearchResults> {
  val query = querySubject
      .debounce(500L, TimeUnit.MILLISECONDS)
      .map { it.trim() }
      .filter { it.length >= 2 }

  val age = ageSubject.replaceUIEmptyValue() // This
  val type = typeSubject.replaceUIEmptyValue() // And this
}

And create the replaceUIEmptyValue() private extension function in the use case’s scope:

class SearchAnimals @Inject constructor(
    private val animalRepository: AnimalRepository
) {
  // ...
  private fun BehaviorSubject<String>.replaceUIEmptyValue() = map {
    if (it == GetSearchFilters.NO_FILTER_SELECTED) "" else it
  }
  // ...
}

This extension function handles the required string replacement. You can now merge the BehaviorSubjects and use their joint result to output a Flowable.

To do so, you need to add the following property, called combiningFunction, to the class. You’ll see why in a second:

class SearchAnimals @Inject constructor(
    private val animalRepository: AnimalRepository
) {
  // ...
  private val combiningFunction: Function3<String, String, String, SearchParameters>
    get() = Function3 { query, age, type ->
      SearchParameters(query, age, type)
    }
  //...
}

To avoid trouble with Function3, add this import at the top:

import io.reactivex.functions.Function3

Make the final update to invoke by adding the return statement:

operator fun invoke(
    querySubject: BehaviorSubject<String>,
    ageSubject: BehaviorSubject<String>,
    typeSubject: BehaviorSubject<String>
): Flowable<SearchResults> {
  val query = querySubject
      .debounce(500L, TimeUnit.MILLISECONDS)
      .map { it.trim() }
      .filter { it.length >= 2 }

  val age = ageSubject.replaceUIEmptyValue()
  val type = typeSubject.replaceUIEmptyValue()

  return Observable.combineLatest(query, age, type, combiningFunction) // 1
    .toFlowable(BackpressureStrategy.LATEST) // 2
    .switchMap { parameters: SearchParameters -> // 3
      animalRepository.searchCachedAnimalsBy(parameters)
    }
}

Here’s what this does:

  1. combineLatest joins the latest results of each stream, using the combining function. In this case, your combining function is the property you just created. It outputs a SearchParameters instance with the values from all the streams. Every time a stream emits something new, combineLatest creates an updated SearchParameters instance.
  2. The toFlowable operator transforms the stream into a Flowable. You need to do this to wire the stream up to the Flowable you’ll get from the repository. When you create a Flowable with this operator, you need to specify a backpressure strategy. Only the most recently emitted event matters. As such, you create the Flowable with BackpressureStrategy.LATEST, which discards any previous event it’s holding in favor of the new one.
  3. switchMap discards any old events in favor of new ones. This is exactly what you want for a search. Also, using switchMap makes the backpressure definition above unnecessary. Regardless, since you have to specify one anyway, you might as well use the one that fits better. Inside switchMap, you call the repository’s searchCachedAnimalsBy(), passing in the search parameters.

The repository method doesn’t exist yet. In fact, none of the needed methods exist, so buckle up: You need to go through the layers and create all the necessary methods.

Adding search to the Repository

Since you’re already calling the repository’s method in the use case, it makes sense to start from there. Go to AnimalRepository and add the method declaration:

interface AnimalRepository {
  // ...
  fun searchCachedAnimalsBy(searchParameters: SearchParameters): Flowable<SearchResults>
  // ...
}

Then, implement it in PetFinderAnimalRepository:

class PetFinderAnimalRepository @Inject constructor(
    private val api: PetFinderApi,
    private val cache: Cache,
    private val apiAnimalMapper: ApiAnimalMapper,
    private val apiPaginationMapper: ApiPaginationMapper
) : AnimalRepository {
  // ...
  override fun searchCachedAnimalsBy(
      searchParameters: SearchParameters
  ): Flowable<SearchResults> {
    val (name, age, type) = searchParameters

    return cache.searchAnimalsBy(name, age, type)
        .distinctUntilChanged()
        .map { animalList ->
          animalList.map {
            it.animal.toAnimalDomain(
                it.photos,
                it.videos,
                it.tags
            )
          }
        }
        .map { SearchResults(it, searchParameters) }
  }
  // ...
}

This is similar to getAnimals, which also returns a Flowable. The difference is that there’s an extra map at the end.

Of course, searchAnimalsBy() also doesn’t exist yet. Add it to the Cache interface:

interface Cache {
  // ...
  fun searchAnimalsBy(
      name: String,
      age: String,
      type: String
  ): Flowable<List<CachedAnimalAggregate>>
  // ...
}

And implement it in RoomCache:

class RoomCache @Inject constructor(
    private val animalsDao: AnimalsDao,
    private val organizationsDao: OrganizationsDao
) : Cache {
  // ...
  override fun searchAnimalsBy(
      name: String,
      age: String,
      type: String
  ): Flowable<List<CachedAnimalAggregate>> {
    return animalsDao.searchAnimalsBy(name, age, type)
  }
  // ...
}

Finally, add the most interesting method of them all, in AnimalsDao:

@Dao
abstract class AnimalsDao {
  // ...
  @Transaction
  @Query("""
      SELECT * FROM animals
        WHERE name LIKE '%' || :name || '%' AND
        AGE LIKE '%' || :age || '%'
        AND type LIKE '%' || :type || '%'
  """)
  abstract fun searchAnimalsBy(
      name: String,
      age: String,
      type: String
  ): Flowable<List<CachedAnimalAggregate>>
  // ...
}

This query uses the search parameters to filter the table elements. Using """ lets you write multiline statements. SQLite’s LIKE operator is case-insensitive, so you don’t need to worry about capitalization. '%' || and || '%' search for the parameters, even if they’re prefixed or suffixed with other characters. So for instance, searching by rce will return an animal named “Marcel”.

Here, you can see why you replaced Any with empty strings. Using LIKE with an empty string matches every item, so it works as if you’re not using any filter at all.

Whew! That’s the price you pay for organized layers. The only thing missing now to call the search use case and observe its Flowable to see the search results.

Adding search to the ViewModel

Head back to SearchFragmentViewModel.kt and inject the SearchAnimals use case in the constructor:

@HiltViewModel
class SearchFragmentViewModel @Inject constructor(
    private val uiAnimalMapper: UiAnimalMapper,
    private val searchAnimals: SearchAnimals, // HERE
    private val getSearchFilters: GetSearchFilters,
    private val dispatchersProvider: DispatchersProvider,
    private val compositeDisposable: CompositeDisposable
): ViewModel()

Then, update prepareForSearch():

private fun prepareForSearch() {
  loadFilterValues()
  setupSearchSubscription() // WITH THIS
}

This method is where you’ll call the use case. Add it to SearchFragmentViewModel:

@HiltViewModel
class SearchFragmentViewModel @Inject constructor(
    private val uiAnimalMapper: UiAnimalMapper,
    private val searchAnimals: SearchAnimals,
    private val getSearchFilters: GetSearchFilters,
    private val dispatchersProvider: DispatchersProvider,
    private val compositeDisposable: CompositeDisposable
): ViewModel() {
  // ...
  private fun setupSearchSubscription() {
    searchAnimals(querySubject, ageSubject, typeSubject)
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe(
            { onSearchResults(it) },
            { onFailure(it) }
        )
        .addTo(compositeDisposable)
  }
  // ...
}

Nothing new here, but you still need to create onSearchResults(). Add it to SearchFragmentViewModel as well:

@HiltViewModel
class SearchFragmentViewModel @Inject constructor(
    private val uiAnimalMapper: UiAnimalMapper,
    private val searchAnimals: SearchAnimals,
    private val getSearchFilters: GetSearchFilters,
    private val dispatchersProvider: DispatchersProvider,
    private val compositeDisposable: CompositeDisposable
): ViewModel() {
  // ...
  private fun onSearchResults(searchResults: SearchResults) {
    val (animals, searchParameters) = searchResults

    if (animals.isEmpty()) {
      // search remotely
    } else {
      onAnimalList(animals)
    }
  }
  // ...
}

This is where you’ll decide whether you need to search remotely. You’ll do that later. onAnimalList() already updates the state with the search results. You now have to update updateScreenState() in SearchFragment to react to those changes.

To do this, go to SearchFragment.kt. Add this line in updateScreenState():

@AndroidEntryPoint
class SearchFragment : Fragment() {
  // ...
  private fun updateScreenState(
      newState: SearchViewState,
      searchAdapter: AnimalsAdapter
  ) {
    val (
        inInitialState,
        searchResults,
        ageFilterValues,
        typeFilterValues,
        searchingRemotely,
        noResultsState,
        failure
    ) = newState

    updateInitialStateViews(inInitialState)
    searchAdapter.submitList(searchResults) // HERE

    // ...
  }
  // ...
}

Build, run, and try out the search! You’ll see some results now. Note that there’s no filter on the left, while the results on the right are filtered.

Figure 7.4 — Search Results!
Figure 7.4 — Search Results!

If there aren’t any results, the screen will just stay empty. Also, the state doesn’t update properly when you display results, change the search parameters and don’t get any results for that change.

To fix that, you’ll implement remote searching next.

Searching remotely

Go back to onSearchResults() in SearchFragmentViewModel. There’s an empty if inside, reserved to act upon an empty animal list. That’s where the remote search will start.

Delete the comment inside the if (if any) and add this line in its place:

private fun onSearchResults(searchResults: SearchResults) {
  val (animals, searchParameters) = searchResults

  if (animals.isEmpty()) {
    onEmptyCacheResults(searchParameters) // THIS ONE
  } else {
    onAnimalList(animals)
  }
}

Then create the method:

@HiltViewModel
class SearchFragmentViewModel @Inject constructor(
    private val uiAnimalMapper: UiAnimalMapper,
    private val searchAnimals: SearchAnimals,
    private val getSearchFilters: GetSearchFilters,
    private val dispatchersProvider: DispatchersProvider,
    private val compositeDisposable: CompositeDisposable
): ViewModel() {
  // ...
  private fun onEmptyCacheResults(searchParameters: SearchParameters) {
    _state.value = state.value!!.updateToSearchingRemotely()
    searchRemotely(searchParameters)
  }
  // ...
}

This method updates the state to searching remotely, which shows a ProgressBar and a warning message. You still have to update the Fragment to see these changes, but you’ll leave that for later.

Most of what you need to do now just copies what you’ve done so far. To reduce repetition, most of the code already exists, you just have to uncomment it.

Locate SearchAnimalsRemotely.kt in the search.domain.usecases package and uncomment invoke.

Then, go to AnimalRepository and uncomment the searchAnimalsRemotely() declaration.

Finally, go to PetFinderAnimalRepository and uncomment the implementation.

The API method already exists, so you don’t need to worry about it. It’s similar to the animals near you method, but with added fields for the search.

Build and run to make sure everything works.

Triggering the search API call

Go back to SearchFragmentViewModel. Just like before, inject a SearchAnimalsRemotely instance in the constructor:

@HiltViewModel
class SearchFragmentViewModel @Inject constructor(
    private val uiAnimalMapper: UiAnimalMapper,
    private val searchAnimalsRemotely: SearchAnimalsRemotely, // HERE
    private val searchAnimals: SearchAnimals,
    private val getSearchFilters: GetSearchFilters,
    private val dispatchersProvider: DispatchersProvider,
    private val compositeDisposable: CompositeDisposable
): ViewModel()

Next, create searchRemotely():

@HiltViewModel
class SearchFragmentViewModel @Inject constructor(
    private val uiAnimalMapper: UiAnimalMapper,
    private val searchAnimals: SearchAnimals,
    private val getSearchFilters: GetSearchFilters,
    private val dispatchersProvider: DispatchersProvider,
    private val compositeDisposable: CompositeDisposable
): ViewModel() {
  // ...
  private fun searchRemotely(searchParameters: SearchParameters) {
    val exceptionHandler = createExceptionHandler(message = "Failed to search remotely.")

    viewModelScope.launch(exceptionHandler) {
      val pagination = withContext(dispatchersProvider.io()) {
        Logger.d("Searching remotely...")

        searchAnimalsRemotely(++currentPage, searchParameters)
      }

      onPaginationInfoObtained(pagination)
    }
  }
  // ...
}

This is a one-shot operation, as any network operation should be. You have the search results Flowable up and running. This operation will store any results in the database, triggering the Flowable to display them.

Finally, go to SearchFragment and update updateScreenState():

  private fun updateScreenState(
      newState: SearchViewState,
      searchAdapter: AnimalsAdapter
  ) {
    val (
        inInitialState,
        searchResults,
        ageFilterValues,
        typeFilterValues,
        searchingRemotely,
        noResultsState,
        failure
    ) = newState

    // ...

    updateRemoteSearchViews(searchingRemotely) // WITH THIS LINE

    handleFailures(failure)
  }

You also have to create the method in SearchFragment:

@AndroidEntryPoint
class SearchFragment : Fragment() {
  // ...
  private fun updateRemoteSearchViews(searchingRemotely: Boolean) {
    binding.searchRemotelyProgressBar.isVisible = searchingRemotely
    binding.searchRemotelyText.isVisible = searchingRemotely
  }
  // ...
}

Build and run, then try searching for random names. Your remote search now works. :]

Figure 7.5 — Searching Remotely
Figure 7.5 — Searching Remotely

There’s something important to consider regarding remote search: What happens when the user starts a new remote search before the old one is complete? The previous one keeps going!

You won’t see this in the UI. Even if you store items that come from an old request, they probably won’t pass the search parameters’ filtering. However, behind the curtain, you can have the bad luck of a previous request taking longer to finish than a new one. This can mess up the pagination data, for instance. For safety and good hygiene, you should cancel old requests.

Canceling old search requests

When you call launch on a CoroutineScope, you create a coroutine. launch returns a Job that represents that coroutine. You’ll use this Job to control the remote requests.

In SearchFragmentViewModel, add:

@HiltViewModel
class SearchFragmentViewModel @Inject constructor(
    private val uiAnimalMapper: UiAnimalMapper,
    private val searchAnimalsRemotely: SearchAnimalsRemotely,
    private val searchAnimals: SearchAnimals,
    private val getSearchFilters: GetSearchFilters,
    private val dispatchersProvider: DispatchersProvider,
    private val compositeDisposable: CompositeDisposable
): ViewModel() {
  // ...
  private var remoteSearchJob: Job = Job()
  // ...
}

You’ll set this property to any new job you create for remote search. That said, go to searchRemotely() and update the launch call to:

private fun searchRemotely(searchParameters: SearchParameters) {
  // ...

  remoteSearchJob = viewModelScope.launch(exceptionHandler) { // THIS
    // ...
  }
}

Here, you’re getting the job for each coroutine and storing it. But when should you cancel it?

When you change any of the search parameters, you search for a different parameter set. Therefore, onSearchParametersUpdate() seems like the best place to cancel the old coroutine.

To implement this, update the method, like so`:

private fun onSearchParametersUpdate(event: SearchEvent) {
  remoteSearchJob.cancel( // cancels the job
      CancellationException("New search parameters incoming!")
  )

  when (event) {
    is SearchEvent.QueryInput -> updateQuery(event.input)
    is SearchEvent.AgeValueSelected -> updateAgeValue(event.age)
    is SearchEvent.TypeValueSelected -> updateTypeValue(event.type)
  }
}

That’s why you need the SearchEvent checks in this method.

Build and run. Everything works as before, but how do you know you’re canceling the coroutine? An easy way to verify that is to check when the job completes, and why.

Checking that the coroutine canceled

Back in searchRemotely(), at the bottom of the method and outside launch’s scope, add:

private fun searchRemotely(searchParameters: SearchParameters) {
  // ...

  remoteSearchJob = viewModelScope.launch(exceptionHandler) {
    // ...
  }

  remoteSearchJob.invokeOnCompletion { it?.printStackTrace() } // THIS LINE
}

Now, build and run. Try changing search parameters while a remote search is running. You’ll now see the CancellationException above printed in Logcat with that same message!

Another way of knowing if the coroutine was canceled is by checking Logcat for interceptor logs. Retrofit supports coroutine cancellation, so the request gets canceled and logged. This also cancels the coroutine.

If SearchAnimalsRemotely wasn’t using Retrofit, nothing in it would check for coroutine cancellation. In this case, since coroutine cancellation is cooperative, you’d have to do the check yourself.

For instance, imagine that Retrofit didn’t care about coroutine cancellation. In that case, right after a Retrofit call, you’d need something like:

if (!coroutineContext.isActive) {
  throw CancellationException(
      "Cancelled — New data was requested"
  )
}

Fortunately, Retrofit is a great library and it handles all of this for you!

Finishing touches

You’re almost done. Your search is just missing a state update in the Fragment.

SearchAnimalsRemotely throws a NoMoreAnimalsException when the search has no results. onFailure() in SearchFragmentViewModel already handles this, updating the state to no results.

So go to SearchFragment and update it by adding this line to updateScreenState():

private fun updateScreenState(
    newState: SearchViewState,
    searchAdapter: AnimalsAdapter
) {
  val (
      inInitialState,
      searchResults,
      ageFilterValues,
      typeFilterValues,
      searchingRemotely,
      noResultsState,
      failure
  ) = newState

  // ...
  updateNoResultsViews(noResultsState)
}

Then, create this method:

@AndroidEntryPoint
class SearchFragment : Fragment() {
  // ...
  private fun updateNoResultsViews(noResultsState: Boolean) {
    binding.noSearchResultsImageView.isVisible = noResultsState
    binding.noSearchResultsText.isVisible = noResultsState
  }
  // ...
}

Build, run, and search for qwe. Hopefully, no one has terrible taste in pet naming. The remote search won’t have any results, and you’ll see a sad little pug in the background.

Figure 7.6 — No Results Pug
Figure 7.6 — No Results Pug

You’re done! To sum up the chapter so far:

  • You implemented two new features with cache and network data sources.
  • You separated your logic into well-defined and easily testable layers.
  • You did all that while following a unidirectional data flow approach.

Of course, even though you did a lot, there are still things missing: Cache invalidation, better error handling, request retries, possibly one or two bugs to solve… Regardless, these changes only require their essential complexity, as the overall architecture of the app makes it easier to apply changes and extend behavior.

Now, I don’t want to be that guy, but you know that there’s still one thing to do before proceeding to the next chapter. Trust me, as you do it more and more in this kind of architecture, you actually start to enjoy it. :]

Testing

To test the presentation layer, you’ll use two different kinds of tests. You’ll test:

  1. The ViewModel
  2. The UI

You won’t test the use cases directly because there’s nothing new to learn from that — that would be a simple unit test of a class. You will test how the use cases integrate with the ViewModel, however.

ViewModel tests

Thanks to this architecture, testing the ViewModel is only a matter of sending events in and getting view states out. It’s so clean and straightforward that it’s actually enjoyable. Also, since ViewModel doesn’t require a device to run, you can run the unit tests on the JVM.

You’ll find SearchFragmentViewModelTest.kt in the test package, in a directory matching the original ViewModel. It has an empty class for now. Before writing any tests, there’s something you need to do.

Setting up your tests

In the debug package, locate common/data/FakeRepository.kt. Open it and uncomment everything. As the name suggests, it’s a fake AnimalRepository implementation to use with tests.

Go back to SearchFragmentViewModelTest. Start by adding these rules to the class:

class SearchFragmentViewModelTest {
  @get:Rule
  val instantExecutorRule = InstantTaskExecutorRule() // 1

  @get:Rule
  val testCoroutineRule = TestCoroutineRule() // 2

  @get:Rule
  val rxImmediateSchedulerRule = RxImmediateSchedulerRule() // 3
}

This code overrides:

  1. The main looper, which you need to test code with LiveData.
  2. Coroutine dispatchers, replacing the main dispatcher with a test dispatcher. It’s also why the class has the @ExperimentalCoroutinesApi annotation.
  3. RxJava schedulers, setting them all to execute immediately.

InstantTaskExecutorRule comes from the androidx.arch.core:core-testing library. TestCoroutineRule and RxImmediateSchedulerRule are custom rules. Both are defined in the debug package, which means tests in both test and androidTest can use them.

Below the rules, inside SearchFragmentViewModelTest, add these properties:

private lateinit var viewModel: SearchFragmentViewModel
private lateinit var repository: FakeRepository
private lateinit var getSearchFilters: GetSearchFilters

private val uiAnimalsMapper = UiAnimalMapper()

And below them, setup():

@Before
fun setup() {
  // 1
  val dispatchersProvider = object : DispatchersProvider {
    override fun io() = Dispatchers.Main
  }

  // 2
  repository = FakeRepository()
  getSearchFilters = GetSearchFilters(repository)

  viewModel = SearchFragmentViewModel(
      SearchAnimalsRemotely(repository),
      SearchAnimals(repository),
      getSearchFilters,
      uiAnimalsMapper,
      dispatchersProvider,
      CompositeDisposable()
  )
}

Here’s what’s happening above:

  1. This anonymous class implements DispatchersProvider by replacing the IO dispatcher with Main.
  2. You instantiate the lateinit properties.

Now, to the actual test.

Building your test

You’ll start by testing the case where you do a remote search and get results. Add the method signature:

@Test
fun `SearchFragmentViewModel remote search with success`() = testCoroutineRule.runBlockingTest {
  // Given

  // When

  // Then
}

testCoroutineRule.runBlockingTest is like runBlocking, but optimized for tests. It gives you more control over coroutines, like pausing the dispatcher or advancing the test clock.

Note that you don’t need anything like that for this test. In fact, you could use the regular runBlocking here. The important thing is the declaration of TestCoroutineRule at the top of the class. Without it, the test will run on the default main dispatcher and fail.

Adding the initial conditions

Below // Given, add the initial conditions:

// 1
val (name, age, type) = repository.remotelySearchableAnimal
val (ages, types) = getSearchFilters()

val expectedRemoteAnimals = repository.remoteAnimals.map {
  uiAnimalsMapper.mapToView(it)
}

// 2
viewModel.state.observeForever { }

// 3
val expectedViewState = SearchViewState(
    noSearchQuery = false,
    searchResults = expectedRemoteAnimals,
    ageFilterValues = Event(ages),
    typeFilterValues = Event(types),
    searchingRemotely = false,
    noRemoteResults = false
)
  1. The fake repository has a few helper properties for testing. Here, you get the name, age and type to use for searching, along with the list of remote animals you expect.
  2. LiveData only emits events if something’s observing it. Here, you observe ViewModel’s state so it emits items.
  3. At the end of the test, you expect a certain state. Since you’re testing for the remote search case, you expect that the view state corresponds to that case.

Triggering the events to test

Now, for the // When:

viewModel.onEvent(SearchEvent.PrepareForSearch)
viewModel.onEvent(SearchEvent.TypeValueSelected(type))
viewModel.onEvent(SearchEvent.AgeValueSelected(age))
viewModel.onEvent(SearchEvent.QueryInput(name))

The view state can only reach the remote search state after a specific sequence of view state updates. As such, you need to trigger the events on the ViewModel that lead to that state.

Checking the results

Finally, the // Then:

val viewState = viewModel.state.value!!

assertThat(viewState).isEqualTo(expectedViewState)

So simple, yet so effective. You get the state and compare it to what you expect it to be. You’re effectively testing the whole state of your screen by doing so.

On a side note, you had to convert Event to a data class so it implements equals().

Tests like this make you think about what each state should represent. They’ll fail if you mess those states up. Build and run the test to make sure it works.

That’s it for the ViewModel tests, as every test will follow this same recipe. It’s time to test the UI.

UI tests

Animations affect UI tests, so you need to disable them before testing. Go to your device’s developer options. If you don’t have developer options, go to the About section of the settings and click Build number until you unlock them.

Change the animation settings so:

  • Window animation scale is off.
  • Transition animation scale is off.
  • Animation duration scale is off.

See the image below:

Figure 7.7 — Changing the animation settings.
Figure 7.7 — Changing the animation settings.

To create a UI test, the androidTest code needs to implement the full DI graph. Most of that is ready, including updates of the previous tests you made to handle the full graph. To wrap it up, go to common/di/TestActivityRetainedModule.kt in androidTest and uncomment bindAnimalRepository(). This injects the FakeRepository instead of the real one.

With that out of the way, go to search/presentation/SearchFragment.kt. You’ll test a case that’s similar to the one before, but from the UI perspective. It’ll test the integration of the Fragment, ViewModel and use cases. It stops testing real code at the FakeRepository, but you could easily make it an end to end test by uninstalling TestActivityRetainedModule.kt instead of ActivityRetainedModule, and setting up a fake server with mockWebServer.

Building your test

Locate searchFragment_testSearch_success(). Below // Given, add:

val nameToSearch =
    FakeRepository().remotelySearchableAnimal.name
launchFragmentInHiltContainer<SearchFragment>()

As in the ViewModel unit test, you get the name of the animal to search. The second line is a lot more interesting though.

When you run tests on Fragments, you’d typically use a FragmentScenario, which lets you launch your Fragment and control its lifecycle state. However, Hilt doesn’t support that, at least for now.

Instead, you’ll do what the Hilt team recommends and use launchFragmentInHiltContainer().

You declare this function in the debug package. Along with it, you declare a HiltTestActivity annotated with @AndroidEntryPoint. The function creates an Intent to launch the HiltTestActivity, then creates an ActivityScenario with it and uses it to host your Fragment. Just like any Activity, you’ll find the HiltTestActivity definition in the (debug) manifest.

Triggering what to test

At this point, your Fragment is running. You want to test the search, so you need to write nameToSearch in the Fragment’s SearchView. Below // When, add:

with (onView(withId(R.id.search))) {
  perform(click())
  perform(typeSearchViewText(nameToSearch))
}

Using Espresso, you access the SearchView through its ID. You click it for focus, then run typeSearchViewText(). Typing in SearchView programmatically is a little more complex than typing in a simple TextView. Therefore, using Espresso’s typeText() won’t work.

You can see typeSearchViewText() below the test method. It creates an anonymous ViewAction class, where the two main methods are:

// 1
override fun getConstraints(): Matcher<View> {
  return allOf(
      isDisplayed(),
      isAssignableFrom(SearchView::class.java)
  )
}

// 2
override fun perform(uiController: UiController?, view: View?) {
  (view as SearchView).setQuery(text, false)
}

The code above defines:

  1. Every View the ViewAction can operate on.
  2. The action you want to perform.

Checking the results

Go back to the test and add the final code below // Then:

with (onView(withId(R.id.searchRecyclerView))) {
  check(matches(childCountIs(1)))
  check(matches(hasDescendant(withText(nameToSearch))))
}

This code checks if RecyclerView has only one item and if the text in that item matches what you expect. childCountIs() is also custom. It’s defined below typeSearchViewText(), and it compares the value you pass to Adapter’s item count.

Build and run the test. Look at your device while the test runs and you’ll see the changes in the UI.

There you have it. By adding these two tests, you are now testing every layer of your app. Well done!

This concludes your work on the Search feature. In the next chapter, you’ll work on a new feature while learning how to create a multi-module app.

Key points

  • Avoid using base classes for Android components.
  • View state management can get complex when you use data classes. Consider using functions to transition states, a state pattern or sealed classes.
  • Avoid relying on Enum’s value order.
  • A great way to handle user input is to treat it as a reactive stream, especially when input from one source can influence another.
  • I can’t stress this enough: Network requests are one-shot operations. So don’t handle them like their event streams!
  • Always consider the network requests you make. If you have requests that don’t matter anymore, find a way to cancel them. Coroutines allow you to do this organically, thanks to structured concurrency and cooperative cancellation.
  • Following a unidirectional data flow makes unit testing the ViewModel a breeze.
  • Hilt makes it easy to include test dependencies, but it has some limitations.
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.