| 1 | --- |
| 2 | title: "Deine App als Assistent" |
| 3 | summary: "Was ich über Androids Assist Service gelernt habe und wie du deine App Gemini ersetzen lässt" |
| 4 | date: 2026-08-23 |
| 5 | --- |
| 6 | |
| 7 | > **Hinweis: |
| 8 | |
| 9 | Da ich an Sprachnachrichten von außerhalb der App gearbeitet habe, wie im [vorherigen Text](/writings/android-bt-sco-handshake) erwähnt: Dieses Feature funktioniert, indem die App deinen Google Assistant ersetzt, sodass Android statt Gemini eben deine App öffnet. |
| 10 | |
| 11 | Zeit, darüber zu labern, wie du deine App als Assist-App registrierst, damit du Gemini durch etwas anderes ersetzen kannst, einfach weil es geht (grinst in Richtung der iOS-Nutzer, die mit Siri festsitzen, lmao). |
| 12 | |
| 13 | Android lässt dich das tatsächlich machen, ohne Root oder Signature Permission oder sonst was. Du deklarierst ein paar Services, der Nutzer wählt deine App in den Einstellungen aus, fertig. Die Docs dazu sind allerdings ziemlich dünn, und wenn du etwas falsch machst, sagt dir das keiner, deine App taucht einfach nicht in der Liste auf und du sitzt da und rätselst. |
| 14 | |
| 15 | Damit deine App unter dem "Digitaler Assistent"-Menü auftaucht (keine Ahnung, wie andere Hersteller das nennen, bei mir zu Hause gibt es nur Samsung), brauchst du 3 Dinge: |
| 16 | |
| 17 | 1. einen `VoiceInteractionService` |
| 18 | 2. einen `VoiceInteractionSessionService` |
| 19 | 3. einen `RecognitionService` |
| 20 | |
| 21 | Zwei davon machen so gut wie nichts, du brauchst trotzdem alle drei, ich habe versucht, welche wegzulassen. |
| 22 | |
| 23 | Gehen wir sie einzeln durch. |
| 24 | |
| 25 | ### 1. VoiceInteractionService |
| 26 | |
| 27 | Das ist der eigentliche Service, der deine App als digitale Assistenten-App markiert. Danach sucht Android, wenn es diese Einstellungsliste zusammenbaut. Du musst hier keine Methode überschreiben, der Klassenrumpf ist leer. |
| 28 | |
| 29 | ### 2. VoiceInteractionSessionService |
| 30 | |
| 31 | Das ist der Service, den Android aufruft, wenn eine neue Voice Interaction Session erzeugt wird, was im Grunde heißt: Der Nutzer hat die Assistenten-Geste ausgelöst. Du musst die Methode `onNewSession` überschreiben und sie muss eine Instanz von `VoiceInteractionSession` zurückgeben. |
| 32 | |
| 33 | Was ist `VoiceInteractionSession`? |
| 34 | Sie repräsentiert eine aktive Voice-Interaction-Session, bekommt `Context` als Parameter und hat eine `onShow`-Methode. |
| 35 | |
| 36 | Wenn du deine eigene UI bauen willst, überschreibst du `onCreateContentView`, denn diese Methode kommt vor `onShow`. Und wenn du gar keine UI willst (mein Fall, das Handy liegt in der Hosentasche), kannst du |
| 37 | |
| 38 | Es gibt außerdem `onHandleAssist` und `onHandleScreenshot`, falls du den Inhalt des aktuellen Bildschirms oder einen Screenshot davon haben willst, so funktioniert das ganze "was ist gerade auf meinem Bildschirm"-Zeug. Du bekommst sie nur, wenn du es in der XML-Config anforderst, und der Nutzer kann es in den Einstellungen sowieso abschalten. Ich brauchte beides nicht, also habe ich sie ausgelassen. |
| 39 | |
| 40 | ### 3. RecognitionService |
| 41 | |
| 42 | Das ist ein Spracherkenner, falls du eine echte Assistenten-App baust. Er hat `onStartListening`, `onCancel` und |
| 43 | |
| 44 | Weglassen kannst du ihn trotzdem nicht, selbst wenn du nie irgendwas transkribierst, denn die Voice-Interaction-XML zwingt dich, einen Recognition Service zu benennen, das ist nicht optional. Lässt du ihn weg, taucht deine App einfach nicht in der Assistenten-Liste auf, kein Fehler, kein Log, nichts. |
| 45 | |
| 46 | # Setup |
| 47 | |
| 48 | Ich nenne meine App `NotGoogleAssistant` mit der Bundle-ID `fyi.procrastinator.not.google.assistant` |
| 49 | |
| 50 | 1. `VoiceInteractionSession` erweitern |
| 51 | |
| 52 | ```kotlin |
| 53 | |
| 54 | package fyi.procrastinator.not.google.assistant |
| 55 | |
| 56 | import android.content.Context |
| 57 | import android.os.Bundle |
| 58 | import android.service.voice.VoiceInteractionSession |
| 59 | |
| 60 | class NotGoogleAssistantVoiceInteractionSession(context:Context |
| 61 | VoiceInteractionSession(context){ |
| 62 | override fun onShow(args: |
| 63 | super.onShow(args, showFlags) |
| 64 | |
| 65 | /** |
| 66 | * Was auch immer beim Start der Voice Interaction Session passieren soll, kommt hier rein. |
| 67 | * Zum Beispiel ein akustisches Signal abspielen oder keine Ahnung, den Nutzer rickrollen. |
| 68 | * Oder du erweiterst einfach `Activity` und startest diese Activity |
| 69 | */ |
| 70 | |
| 71 | hide() |
| 72 | } |
| 73 | |
| 74 | |
| 75 | } |
| 76 | ``` |
| 77 | |
| 78 | Zwei Dinge an dieser Klasse, über die ich gestolpert bin: |
| 79 | |
| 80 | `hide()` beendet die Session, alles also, was du in `onShow` angestoßen hast und von dem du erwartest, dass es weiterläuft, lebt auf geliehene Zeit. Meine Aufnahme dauert eine Weile, deshalb habe ich sie in einen Foreground Service verschoben, statt sie hier drin zu lassen. |
| 81 | |
| 82 | Und `onShow` läuft auf dem Main Thread. Blockierst du ihn, fühlt sich die Geste kaputt an und der Nutzer gibt deiner App die Schuld, zu Recht. |
| 83 | |
| 84 | 2. `VoiceInteractionSessionService` anlegen |
| 85 | |
| 86 | ```kotlin |
| 87 | package fyi.procrastinator.not.google.assistant |
| 88 | |
| 89 | import android.os.Bundle |
| 90 | import android.service.voice.VoiceInteractionSession |
| 91 | import android.service.voice.VoiceInteractionSessionService |
| 92 | |
| 93 | class NotGoogleAssistantVoiceInteractionSessionService : VoiceInteractionSessionService |
| 94 | override fun onNewSession(args: |
| 95 | NotGoogleAssistantVoiceInteractionSession(this) |
| 96 | } |
| 97 | |
| 98 | ``` |
| 99 | |
| 100 | 3. `VoiceInteractionService` erweitern |
| 101 | |
| 102 | ```kotlin |
| 103 | package fyi.procrastinator.not.google.assistant |
| 104 | |
| 105 | import android.service.voice.VoiceInteractionService |
| 106 | |
| 107 | class NotGoogleAssistantVoiceInteractionService : VoiceInteractionService |
| 108 | |
| 109 | ``` |
| 110 | |
| 111 | 4. Einen Stub-`RecognitionService` anlegen |
| 112 | Da diese App keine echte Spracherkennung braucht, sondern nur Audio entgegennehmen muss, baue ich einen Stub-`RecognitionService` |
| 113 | |
| 114 | ```kotlin |
| 115 | |
| 116 | package fyi.procrastinator.not.google.assistant |
| 117 | |
| 118 | import android.content.Intent |
| 119 | import android.speech.RecognitionService |
| 120 | import android.speech.SpeechRecognizer |
| 121 | |
| 122 | class NotGoogleAssistantRecognitionService : RecognitionService |
| 123 | override fun onStartListening(recognizerIntent: |
| 124 | listener?.error(SpeechRecognizer.ERROR_CLIENT) |
| 125 | } |
| 126 | |
| 127 | override fun onCancel(listener: |
| 128 | |
| 129 | override fun onStopListening(listener: |
| 130 | } |
| 131 | ``` |
| 132 | |
| 133 | Jetzt zum pikanten Android-Manifest-Kram. |
| 134 | |
| 135 | ```xml |
| 136 | |
| 137 | <service |
| 138 | android:name=" |
| 139 | android:exported=" |
| 140 | android:permission=" |
| 141 | <meta-data |
| 142 | android:name=" |
| 143 | android:resource=" |
| 144 | <intent-filter> |
| 145 | <action android:name |
| 146 | </intent-filter> |
| 147 | </service> |
| 148 | <service |
| 149 | android:name=" |
| 150 | android:exported=" |
| 151 | android:permission=" |
| 152 | <service |
| 153 | android:name=" |
| 154 | android:exported=" |
| 155 | android:permission=" |
| 156 | <intent-filter> |
| 157 | <action android:name |
| 158 | </intent-filter> |
| 159 | <meta-data |
| 160 | android:name=" |
| 161 | android:resource=" |
| 162 | </service> |
| 163 | ``` |
| 164 | |
| 165 | Alle drei brauchen `android:exported="true"`, weil sich der Systemprozess an sie bindet und nicht du. Die `android:permission`-Zeilen bedeuten, dass sich nur etwas mit |
| 166 | |
| 167 | Und der Session Service hat keinen Intent Filter, das ist so richtig, er wird über die XML-Config weiter unten erreicht und nicht über eine Action. Ich hatte ihm beim ersten Mal einen gegeben, weil es ohne falsch aussah. Er bewirkt nichts. |
| 168 | |
| 169 | Die Ressourcen-XML: |
| 170 | |
| 171 | 1. `voice_interaction_service.xml` |
| 172 | |
| 173 | ```xml |
| 174 | <?xml version="1.0" encoding |
| 175 | <voice-interaction-service xmlns:android |
| 176 | android:sessionService=" |
| 177 | android:recognitionService=" |
| 178 | android:supportsAssist=" |
| 179 | android:supportsLaunchVoiceAssistFromKeyguard= |
| 180 | ``` |
| 181 | |
| 182 | Vorsicht bei diesen beiden Klassennamen, das sind voll qualifizierte Strings, der Compiler prüft sie also nicht und Lint auch nicht. Vertippst du dich bei einem, läuft der Build trotzdem durch und deine App taucht einfach nicht in den Einstellungen auf. Ich hatte `NotGoogleAssitant` statt `NotGoogleAssistant` getippt und viel zu lange gebraucht, bis es mir aufgefallen ist. |
| 183 | |
| 184 | `supportsAssist="true"` ist das, was dich überhaupt erst als Assistent qualifiziert statt nur als irgendein Voice-Interaction-Ding, ohne das tauchst du im Auswahldialog nicht auf. |
| 185 | |
| 186 | `supportsLaunchVoiceAssistFromKeyguard="true"` sorgt dafür, dass die Geste auch bei gesperrtem Handy funktioniert, was ich brauchte, weil der ganze Sinn ja war, dass das Handy in der Hosentasche bleibt. Gut zu wissen: Das heißt nicht, dass das Handy entsperrt ist, alles dort läuft also auf dem Sperrbildschirm und der Dateizugriff kann bis zum ersten Entsperren weiterhin blockiert sein. |
| 187 | |
| 188 | 2. `recognition_service.xml` |
| 189 | |
| 190 | ```xml |
| 191 | <?xml version="1.0" encoding |
| 192 | <recognition-service xmlns:android |
| 193 | ``` |
| 194 | |
| 195 | Leeres Tag, es muss nur existieren, damit die Meta-Data auf irgendetwas zeigen kann. |
| 196 | |
| 197 | ## Das Ganze scharf schalten |
| 198 | |
| 199 | Die App zu installieren macht dich noch nicht zum Assistenten, der Nutzer muss dich auswählen. Bei Samsung ist das Einstellungen > Apps > Standard-Apps > Digitaler Assistent. Auf Stock-Android ist es Einstellungen > Apps > Assistent & Spracheingabe. Andere Hersteller packen es natürlich woandershin. |
| 200 | |
| 201 | Statt eine 5-Schritte-Anleitung ins Onboarding zu schreiben, kannst du sie einfach direkt dorthin schicken: |
| 202 | |
| 203 | ```kotlin |
| 204 | fun openAssistantSettings(context: Context |
| 205 | val intent = Intent(Settings.ACTION_VOICE_INPUT_SETTINGS) |
| 206 | .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) |
| 207 | runCatching { context.startActivity(intent) } |
| 208 | .onFailure { |
| 209 | // manche Hersteller haben diesen Screen nicht, also zurück auf die oberste Ebene |
| 210 | context.startActivity( |
| 211 | Intent(Settings.ACTION_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) |
| 212 | ) |
| 213 | } |
| 214 | } |
| 215 | ``` |
| 216 | |
| 217 | Behalt das `runCatching` drin, es gibt immer irgendein Gerät, auf dem es diesen Screen nicht gibt. |
| 218 | |
| 219 | Und um zu prüfen, ob gerade du ausgewählt bist, praktisch für einen "wähl mich als deinen Assistenten"-Hinweis, ohne die zu nerven, die das längst gemacht haben: |
| 220 | |
| 221 | ```kotlin |
| 222 | fun isDefaultAssistant(context: Context |
| 223 | val current = Settings.Secure.getString(context.contentResolver, "voice_interaction_service" |
| 224 | return current?.startsWith(context.packageName) == true |
| 225 | } |
| 226 | ``` |
| 227 | |
| 228 | Dieser Key ist keine öffentliche API, sondern nur ein Settings-String, der seit Ewigkeiten stabil ist. Nimm ihn für die UI, häng keine echte Logik davon ab. Kommt null zurück, zeig einfach den Hinweis. |
| 229 | |
| 230 | ## Zeug, das mich Zeit gekostet hat |
| 231 | |
| 232 | **App taucht gar nicht in der Liste auf.** Fast immer ein vertippter Klassenname in `voice_interaction_service.xml`. Ansonsten ein fehlendes ` |
| 233 | |
| 234 | **App ist in der Liste, aber sie auszuwählen bewirkt nichts.** Der Session Service ist nicht auflösbar, oder ihm fehlt `BIND_VOICE_INTERACTION`, sodass sich das System weigert, sich daran zu binden. |