| 1 | --- |
| 2 | title: "Your App as the Assistant" |
| 3 | summary: "Stuff I learnt about Android's Assist Service and how you can make your app replace Gemini" |
| 4 | date: 2026-08-23 |
| 5 | --- |
| 6 | |
| 7 | Since I worked on out of the app voice notes as mentioned in [previous writing,](/writings/android-bt-sco-handshake) that feature is powered by replacing your google assistant with the app so that android opens your app instead of Gemini. |
| 8 | |
| 9 | Time to yap about how to make your app advertise itself as an Assist app so you can replace Gemini with something else just cus you can(smirking at iOS users stuck with Siri lmao). |
| 10 | |
| 11 | Android actually lets you do this without any rooting or signature permission or whatever. You declare some services, user picks your app in settings, done. The docs on it are pretty thin though and when you get it wrong nothing tells you, your app just isn't in the list and you sit there wondering. |
| 12 | |
| 13 | To make your app to show up under the "Digital Assistant menu"(idk what other phones call it but I have only Samsung in my household), you will need 3 things: |
| 14 | |
| 15 | 1. a `VoiceInteractionService` |
| 16 | 2. a `VoiceInteractionSessionService` |
| 17 | 3. a `RecognitionService` |
| 18 | |
| 19 | Two of those barely do anything but you still need all three, I tried skipping. |
| 20 | |
| 21 | Lets go over them one by one. |
| 22 | |
| 23 | ### 1. VoiceInteractionService |
| 24 | |
| 25 | This is the actual service which marks your app as a digital assistant app. It's what android looks for when it builds that settings list. You don't need to override any method here, the class body is empty. |
| 26 | |
| 27 | ### 2. VoiceInteractionSessionService |
| 28 | |
| 29 | This is the service the android will invoke when a new Voice Interaction Session is created, which basically means the user did the assistant gesture. You need to override its `onNewSession` method and it must return an instance of `VoiceInteractionSession`. |
| 30 | |
| 31 | What is `VoiceInteractionSession`? |
| 32 | It represents an active voice interaction session and gets `Context` as param and has an `onShow` method. The |
| 33 | |
| 34 | If you want to create your own ui, you override `onCreateContentView` method as this method is before `onShow`. And if you don't want any ui at all (my case, phone is in the pocket) you can call |
| 35 | |
| 36 | There's also `onHandleAssist` and `onHandleScreenshot` if you want the contents of the current screen or a screenshot of it, that's how the "what's on my screen" stuff works. You only get them if you ask for it in the xml config and the user can turn it off in settings anyway. I didn't need either so I skipped them. |
| 37 | |
| 38 | ### 3. RecognitionService |
| 39 | |
| 40 | Its a speech recognizer if you are building an actual assistant app. It has `onStartListening`, `onCancel` and |
| 41 | |
| 42 | You can't skip it even if you never transcribe anything because the voice interaction xml makes you name a recognition service, it's not optional. Leave it out and your app just doesn't appear in the assistant list, no error, no log, nothing. |
| 43 | |
| 44 | # Setup |
| 45 | |
| 46 | I will call my app `NotGoogleAssistant` with bundle id of `fyi.procrastinator.not.google.assistant` |
| 47 | |
| 48 | 1. Extend `VoiceInteractionSession` |
| 49 | |
| 50 | ```kotlin |
| 51 | |
| 52 | package fyi.procrastinator.not.google.assistant |
| 53 | |
| 54 | import android.content.Context |
| 55 | import android.os.Bundle |
| 56 | import android.service.voice.VoiceInteractionSession |
| 57 | |
| 58 | class NotGoogleAssistantVoiceInteractionSession(context:Context |
| 59 | VoiceInteractionSession(context){ |
| 60 | override fun onShow(args: |
| 61 | super.onShow(args, showFlags) |
| 62 | |
| 63 | /** |
| 64 | * Whatever you want to do when the Voice Interaction Session starts, do it here. |
| 65 | * Like playing audio cue or something or idk rickroll the user. |
| 66 | * Or you can always extend `Activity` and start that activity |
| 67 | */ |
| 68 | |
| 69 | hide() |
| 70 | } |
| 71 | |
| 72 | |
| 73 | } |
| 74 | ``` |
| 75 | |
| 76 | Two things about this class that got me: |
| 77 | |
| 78 | `hide()` kills the session, so anything you kicked off in `onShow` and expected to keep running is on borrowed time. My recording takes a while so I moved it into a foreground service instead of keeping it in here. |
| 79 | |
| 80 | And `onShow` is on the main thread. Block it and the gesture feels broken and the user blames your app, fairly. |
| 81 | |
| 82 | 2. Create `VoiceInteractionSessionService` |
| 83 | |
| 84 | ```kotlin |
| 85 | package fyi.procrastinator.not.google.assistant |
| 86 | |
| 87 | import android.os.Bundle |
| 88 | import android.service.voice.VoiceInteractionSession |
| 89 | import android.service.voice.VoiceInteractionSessionService |
| 90 | |
| 91 | class NotGoogleAssistantVoiceInteractionSessionService : VoiceInteractionSessionService |
| 92 | override fun onNewSession(args: |
| 93 | NotGoogleAssistantVoiceInteractionSession(this) |
| 94 | } |
| 95 | |
| 96 | ``` |
| 97 | |
| 98 | 3. Extend `VoiceInteractionService` |
| 99 | |
| 100 | ```kotlin |
| 101 | package fyi.procrastinator.not.google.assistant |
| 102 | |
| 103 | import android.service.voice.VoiceInteractionService |
| 104 | |
| 105 | class NotGoogleAssistantVoiceInteractionService : VoiceInteractionService |
| 106 | |
| 107 | ``` |
| 108 | |
| 109 | 4. Create a stub `RecognitionService` |
| 110 | Since this app does not really need voice recognition, just need to receive audio - I will add a stub `RecognitionService` |
| 111 | |
| 112 | ```kotlin |
| 113 | |
| 114 | package fyi.procrastinator.not.google.assistant |
| 115 | |
| 116 | import android.content.Intent |
| 117 | import android.speech.RecognitionService |
| 118 | import android.speech.SpeechRecognizer |
| 119 | |
| 120 | class NotGoogleAssistantRecognitionService : RecognitionService |
| 121 | override fun onStartListening(recognizerIntent: |
| 122 | listener?.error(SpeechRecognizer.ERROR_CLIENT) |
| 123 | } |
| 124 | |
| 125 | override fun onCancel(listener: |
| 126 | |
| 127 | override fun onStopListening(listener: |
| 128 | } |
| 129 | ``` |
| 130 | |
| 131 | Time to do the spicy android manifest stuff now. |
| 132 | |
| 133 | ```xml |
| 134 | |
| 135 | <service |
| 136 | android:name=" |
| 137 | android:exported=" |
| 138 | android:permission=" |
| 139 | <meta-data |
| 140 | android:name=" |
| 141 | android:resource=" |
| 142 | <intent-filter> |
| 143 | <action android:name |
| 144 | </intent-filter> |
| 145 | </service> |
| 146 | <service |
| 147 | android:name=" |
| 148 | android:exported=" |
| 149 | android:permission=" |
| 150 | <service |
| 151 | android:name=" |
| 152 | android:exported=" |
| 153 | android:permission=" |
| 154 | <intent-filter> |
| 155 | <action android:name |
| 156 | </intent-filter> |
| 157 | <meta-data |
| 158 | android:name=" |
| 159 | android:resource=" |
| 160 | </service> |
| 161 | ``` |
| 162 | |
| 163 | All three need `android:exported="true"` because it's the system process binding to them, not you. The `android:permission` lines mean only something holding |
| 164 | |
| 165 | Also the session service has no intent filter and that's correct, it gets reached through the xml config below and not through an action. I added one the first time because it looked wrong without one. It does nothing. |
| 166 | |
| 167 | The resource xml: |
| 168 | |
| 169 | 1. `voice_interaction_service.xml` |
| 170 | |
| 171 | ```xml |
| 172 | <?xml version="1.0" encoding |
| 173 | <voice-interaction-service xmlns:android |
| 174 | android:sessionService=" |
| 175 | android:recognitionService=" |
| 176 | android:supportsAssist=" |
| 177 | android:supportsLaunchVoiceAssistFromKeyguard= |
| 178 | ``` |
| 179 | |
| 180 | Careful with those two class names, they're fully qualified strings so the compiler doesn't check them and neither does lint. If you typo one the build still succeeds and your app just doesn't show up in settings. I typed `NotGoogleAssitant` instead of `NotGoogleAssistant` and spent way too long on it before I noticed. |
| 181 | |
| 182 | `supportsAssist="true"` is what makes you eligible to be the assistant instead of just some voice interaction thing, without it you won't show up in the picker. |
| 183 | |
| 184 | `supportsLaunchVoiceAssistFromKeyguard="true"` lets the gesture work while the phone is locked, which I needed since the whole point was phone stays in pocket. Worth knowing it does not mean the phone is unlocked, so anything in there is running on the lock screen and file access can still be blocked until first unlock. |
| 185 | |
| 186 | 2. `recognition_service.xml` |
| 187 | |
| 188 | ```xml |
| 189 | <?xml version="1.0" encoding |
| 190 | <recognition-service xmlns:android |
| 191 | ``` |
| 192 | |
| 193 | Empty tag, it just needs to exist so the meta-data has something to point at. |
| 194 | |
| 195 | ## Actually turning it on |
| 196 | |
| 197 | Installing the app doesn't make you the assistant, the user has to pick you. On Samsung it's Settings > Apps > Default apps > Digital assistant app. On stock android it's Settings > Apps > Assistant & voice input. Other OEMs put it somewhere else obviously. |
| 198 | |
| 199 | Instead of writing a 5 step guide in onboarding you can just send them there: |
| 200 | |
| 201 | ```kotlin |
| 202 | fun openAssistantSettings(context: Context |
| 203 | val intent = Intent(Settings.ACTION_VOICE_INPUT_SETTINGS) |
| 204 | .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) |
| 205 | runCatching { context.startActivity(intent) } |
| 206 | .onFailure { |
| 207 | // some OEMs don't have this screen, fall back to the top level |
| 208 | context.startActivity( |
| 209 | Intent(Settings.ACTION_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) |
| 210 | ) |
| 211 | } |
| 212 | } |
| 213 | ``` |
| 214 | |
| 215 | Keep the `runCatching`, there's always some device where that screen doesn't exist. |
| 216 | |
| 217 | And to check if you're currently the one selected, useful for showing a "set me as your assistant" prompt without annoying people who already did: |
| 218 | |
| 219 | ```kotlin |
| 220 | fun isDefaultAssistant(context: Context |
| 221 | val current = Settings.Secure.getString(context.contentResolver, "voice_interaction_service" |
| 222 | return current?.startsWith(context.packageName) == true |
| 223 | } |
| 224 | ``` |
| 225 | |
| 226 | That key isn't public API, it's just a settings string that's been stable forever. Use it for ui, don't gate real logic on it. If it comes back null just show the prompt. |
| 227 | |
| 228 | ## Stuff that wasted my time |
| 229 | |
| 230 | **App not in the list at all.** Almost always a typo'd class name in `voice_interaction_service.xml`. Otherwise a missing ` |
| 231 | |
| 232 | **App in the list but selecting it does nothing.** Session service isn't resolvable, or it's missing `BIND_VOICE_INTERACTION` so the system refuses to bind to it. |