Thomas Künneth
Posted on September 9, 2021
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'
}
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"
}
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
)
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
)
}
}
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()
}
}
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
)
}
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 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
)
}
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
)
}
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)
}
So, the mechanics are:
- obtain a
ListenableFuture
usingFutures.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.
Posted on September 9, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.