Hands on Jetpack AppSearch
Tags: android, jetpack, kotlin
Published: 2021-09-09 09:37:03

Among many others, Android 12 has one particularly cool new feature: AppSearch. It allows you to store information about your app data in a search engine and retrieve it later using full text search. As the search happens locally on the device, users can find information even when the actual data is in the cloud.

To make this feature available for older platforms, Google has created a new Jetpack component called *Jetpack AppSearch*. It's currently in alpha, so expect changes to the apis. This hands on article shows you how to use the library. As new versions are released, I plan update both this article and the accompanying code. The sample app is on [GitHub](https://github.com/tkuenneth/android-demos/tree/master/misc/AppSearchDemo).

Let's start by declaring dependencies.

```groovy
dependencies {
  implementation 'androidx.core:core-ktx:1.6.0'
  implementation 'androidx.appcompat:appcompat:1.3.1'
  implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.3.1"

  def appsearch_version = "1.0.0-alpha03"
  implementation "androidx.appsearch:appsearch:$appsearch_version"
  kapt "androidx.appsearch:appsearch-compiler:$appsearch_version"
  implementation "androidx.appsearch:appsearch-local-storage:$appsearch_version"
  implementation "androidx.appsearch:appsearch-platform-storage:$appsearch_version"

  // See similar issue: https://stackoverflow.com/a/64733418
  implementation 'com.google.guava:guava:30.1.1-android'
}
```

As you will see shortly, Jetpack AppSearch heavily relies on `ListenableFuture`. It seems that for now you need to include Guava to get it. Tis may change in the future, though. Also, you will need to work with quite a few annotations. I suggest you use Kotlin annotation processing, as you can see in the line starting with `kapt`. This implies that you need to activate the corresponding plugin:

```gradle
plugins {
  id 'com.android.application'
  id 'kotlin-android'
  id "kotlin-kapt"
}
```

One final note regarding *build.gradle*. Have you noticed that I use `androidx.lifecycle`?. You need to setup and tear down AppSearch, and I think this is best decoupled from the activity using lifecycle.

### Documents

The information to be stored and retrieved is modelled as documents. A simple document description looks like this:

```kotlin
@Document
data class MyDocument(
  @Document.Namespace
  val namespace: String,

  @Document.Id
  val id: String,

  @Document.Score
  val score: Int,

  @Document.StringProperty(indexingType = AppSearchSchema.StringPropertyConfig.INDEXING_TYPE_PREFIXES)
  val message: String
)
```

The `namespace` is an arbitrary user-provided string. It is used to group documents during querying or deletion. Indexing a document with a particular `id` replaces any existing documents with the same `id` in that namespace. The `id` is the document's unique identifier. A document must have exactly one such field. The `score` is an indication of the document's quality, relative to other documents of the same type. It can be used in queries. The field is optional. If it's not provided, the document will have a score of 0.

`@Document.StringProperty` makes `message` known to AppSearch. `AppSearchSchema.StringPropertyConfig.INDEXING_TYPE_PREFIXES` means that the content in this property should be returned for queries that are exact matches or query matches of the tokens appearing in this property.

Next, let's see how to setup and tear down AppSearch

### Setting up AppSearch

AppSearch must be setup prior to being used. And if you no longer need it, you should clean a few things up. I found it most convenient to tie this to `lifecycle`:

```kotlin
private const val TAG = "AppSearchDemoActivity"
private const val DATABASE_NAME = "appsearchdemo"

class AppSearchObserver(private val context: Context) : LifecycleObserver {

  lateinit var sessionFuture: ListenableFuture<AppSearchSession>

  @OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
  private fun setupAppSearch() {
    sessionFuture = if (BuildCompat.isAtLeastS()) {
      PlatformStorage.createSearchSession(
        PlatformStorage.SearchContext.Builder(context, DATABASE_NAME)
          .build()
      )
    } else {
      LocalStorage.createSearchSession(
        LocalStorage.SearchContext.Builder(context, DATABASE_NAME)
          .build()
      )
    }
  }

  @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
  fun teardownAppSearch() {
    /* val closeFuture = */ Futures.transform<AppSearchSession, Unit>(
      sessionFuture,
      { session ->
        session?.close()
        Unit
      }, context.mainExecutor
    )
  }
}
```

So, I have implemented a `LifecycleObserver` that reacts to `Lifecycle.Event.ON_RESUME` and `Lifecycle.Event.ON_PAUSE`. The main access point to the rest of the app is `sessionFuture`. On older platforms, AppSearch uses a search engine local to the app, whereas on Android 12 it can rely on a system-wide version. This distinction is made in `setupAppSearch()`.

```kotlin
class AppSearchDemoActivity : AppCompatActivity() {

  private lateinit var appSearchObserver: AppSearchObserver
  private lateinit var binding: MainBinding

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    binding = MainBinding.inflate(layoutInflater)
    setContentView(binding.root)
    appSearchObserver = AppSearchObserver(applicationContext)
    lifecycle.addObserver(appSearchObserver)
    lifecycleScope.launchWhenResumed {
      setSchema()
      addDocument()
      search()
      persist()
    }
  }
```

Now we can actually use AppSearch. 

### Using AppSearch

Inside the main activity of my sample app I (in `onCreate()`) I create an instance of `AppSearchObserver` and pass it to `lifecycle.addObserver()`. The actual work is done in a coroutine, which is started like this: `lifecycleScope.launchWhenResumed { ...`.

First we set up a schema:

```kotlin
private fun setSchema() {
  val setSchemaRequest =
    SetSchemaRequest.Builder().addDocumentClasses(MyDocument::class.java)
      .build()
  /* val setSchemaFuture = */ Futures.transformAsync(
    appSearchObserver.sessionFuture,
    { session ->
      session?.setSchema(setSchemaRequest)
    }, mainExecutor
  )
}
```

The current version of the library relies on `ListenableFuture`s, which certainly are a modern programming paradigm. On the other hand, Kotlin `Flow`s are used in so many other places. This makes me wonder why the team decided to not use them. It [appears to be](https://issuetracker.google.com/issues/192155606#comment2) a planned feature for some time in the future, though.

Adding a document looks like this:

```kotlin
private fun addDocument() {
  val doc = MyDocument(
    namespace = packageName,
    id = UUID.randomUUID().toString(),
    score = 10,
    message = "Hello, this doc was created ${Date()}"
  )
  val putRequest = PutDocumentsRequest.Builder().addDocuments(doc).build()
  val putFuture = Futures.transformAsync(
    appSearchObserver.sessionFuture,
    { session ->
      session?.put(putRequest)
    }, mainExecutor
  )
  Futures.addCallback(
    putFuture,
    object : FutureCallback<AppSearchBatchResult<String, Void>?> {
      override fun onSuccess(result: AppSearchBatchResult<String, Void>?) {
        output("successfulResults = ${result?.successes}")
        output("failedResults = ${result?.failures}")
      }

      override fun onFailure(t: Throwable) {
        output("Failed to put document(s).")
        Log.e(TAG, "Failed to put document(s).", t)
      }
    },
    mainExecutor
  )
}
```

So, in essence you create an instance of your document and pass it to AppSearch by creating a put request with `PutDocumentsRequest.Builder().addDocuments(doc).build()`.

Next, let's look at an example of performing a search:

```kotlin
private fun search() {
  val searchSpec = SearchSpec.Builder()
    .addFilterNamespaces(packageName)
    .setResultCountPerPage(100)
    .build()
  val searchFuture = Futures.transform(
    appSearchObserver.sessionFuture,
    { session ->
      session?.search("hello", searchSpec)
    },
    mainExecutor
  )
  Futures.addCallback(
    searchFuture,
    object : FutureCallback<SearchResults> {
      override fun onSuccess(searchResults: SearchResults?) {
        searchResults?.let {
          iterateSearchResults(searchResults)
        }
      }

      override fun onFailure(t: Throwable?) {
        Log.e("TAG", "Failed to search in AppSearch.", t)
      }
    },
    mainExecutor
  )
}

private fun iterateSearchResults(searchResults: SearchResults) {
  Futures.transform(
    searchResults.nextPage,
    { page: List<SearchResult>? ->
      page?.forEach { current ->
        val genericDocument: GenericDocument = current.genericDocument
        val schemaType = genericDocument.schemaType
        val document: MyDocument? = try {
          if (schemaType == "MyDocument") {
            genericDocument.toDocumentClass(MyDocument::class.java)
          } else null
        } catch (e: AppSearchException) {
          Log.e(
            TAG,
            "Failed to convert GenericDocument to MyDocument",
            e
          )
          null
        }
        output("Found ${document?.message}")
      }
    },
    mainExecutor
  )
}
```

So, we first need a search specification: `SearchSpec.Builder() ... .build()`. Then we invoke the search using our `sessionFuture`. As you can see, the actual retrieval takes place inside `iterateSearchResults()`. The idea of the api is to iterate over *pages* using `searchResults.nextPage`. My example uses only the first page. That's why I configured the search using `.setResultCountPerPage(100)`. I assume this is not a best practice 😂, but for a demo it should do.

The last function we will look at is `persist`. As the name suggests, you need to persist change you make to the database.

```kotlin
private fun persist() {
  val requestFlushFuture = Futures.transformAsync(
    appSearchObserver.sessionFuture,
    { session -> session?.requestFlush() }, mainExecutor
  )
  Futures.addCallback(requestFlushFuture, object : FutureCallback<Void?> {
    override fun onSuccess(result: Void?) {
      // Success! Database updates have been persisted to disk.
    }

    override fun onFailure(t: Throwable) {
      Log.e(TAG, "Failed to flush database updates.", t)
    }
  }, mainExecutor)
}
```

So, the mechanics are:

- obtain a `ListenableFuture` using `Futures.transform()`
- if needed, add a callback using `Futures.addCallback()`

### Conclusion

Frankly, I am still in the process of familiarizing myself with the library. I find the code very verbose and not easy to understand. What's your impression? Am I missing the point? Please share your thoughts in the comments.