Hands on Jetpack AppSearch

tkuenneth

Thomas Künneth

Posted on September 9, 2021

Hands on Jetpack AppSearch

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.

Let's start by declaring dependencies.

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'
}
Enter fullscreen mode Exit fullscreen mode

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:

plugins {
  id 'com.android.application'
  id 'kotlin-android'
  id "kotlin-kapt"
}
Enter fullscreen mode Exit fullscreen mode

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:

@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
)
Enter fullscreen mode Exit fullscreen mode

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:

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
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

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().

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()
    }
  }
Enter fullscreen mode Exit fullscreen mode

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:

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

The current version of the library relies on ListenableFutures, which certainly are a modern programming paradigm. On the other hand, Kotlin Flows are used in so many other places. This makes me wonder why the team decided to not use them. It appears to be a planned feature for some time in the future, though.

Adding a document looks like this:

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
  )
}
Enter fullscreen mode Exit fullscreen mode

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:

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
  )
}
Enter fullscreen mode Exit fullscreen mode

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.

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)
}
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
tkuenneth
Thomas Künneth

Posted on September 9, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related