I asked my watch to play rain sounds, and nothing happened. For a long time I filed that away as a recognition problem — the microphone in a noisy room, my own mumbling, something out of my hands.
I only went back through the logs because September 4 was getting close. The utterance had arrived. My app had been called. What ended the story was a branch I had written months earlier: when no track matched the query, I logged one line and returned.
On a phone that is survivable. The person is holding the screen, and silence is at least visible. On a wrist or in a car, silence is the entire response.
What changes on September 4 is the listener
Starting September 4, Google Assistant begins its retirement on Android phones and tablets, on Wear OS watches, and in Android Auto. Gemini takes its place.
Two details matter more than the headline. The rollout is gradual, so it will take weeks to reach everyone. And once a device has moved, there is no supported way back.
For those of us shipping apps, the important part is narrower than it first sounds. What is being replaced is the layer that interprets speech, not the entry point your app receives. A voice request to play something still arrives through the same media session callback it always did.
What shifts is the shape of the string that lands there. "Rain sounds" phrased one way and phrased another way exercise your matching logic differently. Any app that never decided how to behave on a miss will simply go quiet more often in the weeks right after the switch.
Screenless surfaces leave you one exit
Looking back at my phone implementation, I reported failure with a toast or a snackbar. One short line when nothing matched. That was the whole design.
Neither surface allows it. You cannot throw arbitrary toasts onto a car display while someone is driving, and text on a watch face assumes a glance that the person is not going to give you — the whole point of asking out loud was that their hands and eyes were busy.
What you get instead is the playback state itself. Set STATE_ERROR on PlaybackStateCompat along with a message, and the Android Auto and Wear OS media surfaces pick it up and render it in whatever way suits the moment.
So the job is not to display anything. A response here is something you return as session state, not something you draw. Until that landed for me, every failure branch in my code contained a log statement and nothing else.
Rewriting "show the error" into "return the error"
The change happened inside the callback that receives voice-initiated searches. It used to return early on a miss. Now it reports.
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
if (query.isNullOrBlank()) {
// This means "play something." More on it in the next section.
playFallback()
return
}
val track = library.findBest(query)
if (track == null) {
session.setPlaybackState(
PlaybackStateCompat.Builder()
.setState(PlaybackStateCompat.STATE_ERROR, 0L, 0f)
.setErrorMessage(
PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED,
"I couldn't find anything close to \"$query\""
)
.build()
)
return
}
play(track)
}The part I would not skip is echoing the query back. "Nothing found" tells the person nothing about why. Once they hear what was actually understood, they can tell the difference between rephrasing and giving up — between a recognition slip and a library that genuinely does not contain what they want.
Before any of this matters, confirm the callback is reachable at all. Unless PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH is included in the state's setActions, voice-initiated search never reaches your app in the first place. You can polish the error path all you like and still watch nothing happen. That oversight cost me the longest stretch of confused debugging in this whole exercise.
You also do not need the hardware to check your work. Android Auto runs on a development machine through the Desktop Head Unit, and a Wear OS emulator paired to a phone will take voice-initiated playback well enough to see whether a miss ends in silence. Neither is identical to the real thing, but both answer the question you actually have.
Pick the error code deliberately. I settled on ERROR_CODE_NOT_SUPPORTED for a missing track and ERROR_CODE_APP_ERROR for a network failure, mostly so that later log reading stays honest.
There is a second tier for failures you cannot fix from inside the callback. An expired sign-in is the obvious one, and for that you can attach a resolution the person can act on.
val resolution = Bundle().apply {
putString(
"android.media.extras.ERROR_RESOLUTION_ACTION_LABEL",
"Sign in"
)
putParcelable(
"android.media.extras.ERROR_RESOLUTION_ACTION_INTENT",
signInPendingIntent
)
}
session.setPlaybackState(
PlaybackStateCompat.Builder()
.setState(PlaybackStateCompat.STATE_ERROR, 0L, 0f)
.setErrorMessage(
PlaybackStateCompat.ERROR_CODE_AUTHENTICATION_EXPIRED,
"Your session has expired"
)
.setExtras(resolution)
.build()
)Those keys also exist as constants in androidx.media.utils.MediaConstants, so reach for those if raw strings bother you. I left the literals in place here because they make it much easier to grep for which key is doing the work when something does not surface.
An empty query is not a failure
The case that held me up longest was the one where the query arrives empty.
Nothing went wrong there. An empty query is the request "play something." Returning an error turns a reasonable ask into a refusal, and the person has no idea what they did to deserve it.
So I made it always produce sound: the last thing that was playing, and failing that, the first item of a default list. When I do not have enough information to choose well, I choose anyway rather than offering options — asking someone to pick from a menu is exactly the wrong thing to do when they cannot look.
A non-empty query that matches nothing is the opposite situation, and it deserves the honest answer. Folding both into one branch left me with two mediocre behaviours instead of two clear ones.
Three things worth deciding before the switch
Here is what I would settle in the remaining weeks, narrowed to three.
| Decision | The concrete work | What it costs you to defer |
|---|---|---|
| How failure is reported | Walk every voice-reachable branch and replace log-only exits with STATE_ERROR | You get "it stopped responding" reports right after the switch with nothing to narrow them down |
| The default for empty queries | Choose between last-played and a default list, and pick a track for when neither exists | A reasonable request ends in silence, and people conclude the app is broken |
| A baseline to compare against | Start counting voice-initiated plays and unmatched queries now, before any device moves | The migration is one-way, so the earlier state cannot be measured after the fact |
Only the third is really about preparation rather than code. If it feels worse afterwards and you have nothing from before, you are left arguing with an impression. This week is the last comfortable moment to start counting.
The mechanics of detecting and recording voice-initiated launches are in Google Assistant's replacement is one-way, so the baseline has to be taken now. If you would rather start from an inventory of what still assumes Assistant, How many places in your own app still assume Assistant is the better entry point.
One thing to do next
Find a single voice-reachable callback that currently ends in a bare return, and give it a STATE_ERROR and a sentence. One branch is enough to start.
That is where I began. Hearing a short reply come back from my watch was the moment I understood how many times it had answered with nothing at all.