feat(learn,todo,meditate): dictate through the phone's voice input app (ADR 0038, D18)

On Android the microphone now opens the app handling
RECOGNIZE_SPEECH (FUTO Voice Input, Google's), the default the person
chose in Android, and puts the text it returns where the cursor stood,
as Dictation does; cancelled, nothing. Only where none is installed, or
it can't be opened, does speech_to_text listen in line as before.

halkyon_app: VoiceInputApp (one listen, an answer once closed) on the
channel app.halkyon/voice_input, which DeviceSpeechInput asks first at
every listen, answering SpeechStart.finished so that Dictation doesn't
listen again; speech_to_text isn't touched on that route, so our app
asks for no microphone. SpeechInput.listen and Dictation.start take the
prompt the voice input app shows (the field's hint).

Each app: VoiceInput.kt (queryIntentActivities, startActivityForResult,
the first of EXTRA_RESULTS) attached in MainActivity, which hands it
activity results, and RECOGNIZE_SPEECH among the manifest's queries.
The privacy pages of Learn and Meditate say so.

Our own Whisper and Meditate's dream recordings are dropped (D18).

Whats-New: The microphone now dictates through your phone's voice input app, such as FUTO Voice Input.
Whats-New-De: Das Mikrofon diktiert jetzt über die Spracheingabe-App deines Telefons, etwa FUTO Voice Input.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-07 23:45:20 +01:00
commit c36bd83c6a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 827 additions and 27 deletions

File diff suppressed because one or more lines are too long

View file

@ -96,5 +96,10 @@
<intent>
<action android:name="android.speech.RecognitionService"/>
</intent>
<!-- The voice input app dictation opens first (Meditate's ADR 0038, D18),
FUTO Voice Input or Google's, which records itself. -->
<intent>
<action android:name="android.speech.action.RECOGNIZE_SPEECH"/>
</intent>
</queries>
</manifest>

View file

@ -13,6 +13,9 @@ import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
/** The voice input app dictation opens (ADR 0038, D18). */
private val voiceInput = VoiceInput(this)
private var askedToNotify = false
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
@ -21,6 +24,7 @@ class MainActivity : FlutterActivity() {
PomodoroService.appBack()
ScreenGuard.attach(this, messenger)
SensitiveClipboard.attach(this, messenger)
voiceInput.attach(messenger)
// A question searched with the device's own web search (its default
// browser or search app answers ACTION_WEB_SEARCH); false where none does,
// and the app opens a search engine's address instead.
@ -107,6 +111,12 @@ class MainActivity : FlutterActivity() {
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (!voiceInput.onActivityResult(requestCode, resultCode, data)) {
super.onActivityResult(requestCode, resultCode, data)
}
}
private companion object {
const val NOTIFY_REQUEST = 1
}

View file

@ -0,0 +1,84 @@
package app.halkyon.learn
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.speech.RecognizerIntent
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodChannel
/**
* The device's voice input app for halkyon_app's ChannelVoiceInputApp
* (Halkyon Meditate's ADR 0038, D18): whichever app handles
* ACTION_RECOGNIZE_SPEECH (FUTO Voice Input, Google's) opens over the app,
* records and returns text alone, so this app asks for no microphone.
* `installed` says whether one is (the manifest's <queries> lets Android 11
* on show it); `listen` opens it and answers once it is closed: its first
* result, empty where cancelled, an error where it couldn't be opened. The
* activity hands its results to [onActivityResult].
*/
class VoiceInput(private val activity: Activity) {
private var pending: MethodChannel.Result? = null
fun attach(messenger: BinaryMessenger) {
MethodChannel(messenger, "app.halkyon/voice_input").setMethodCallHandler { call, result ->
when (call.method) {
"installed" -> result.success(installed())
"listen" -> {
if (pending != null) {
result.error("busy", "The voice input app is open already", null)
return@setMethodCallHandler
}
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
call.argument<String>("language")?.let { intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, it) }
call.argument<String>("prompt")?.let { intent.putExtra(RecognizerIntent.EXTRA_PROMPT, it) }
try {
pending = result
activity.startActivityForResult(intent, REQUEST)
} catch (error: ActivityNotFoundException) {
pending = null
result.error("noApp", error.message, null)
} catch (error: SecurityException) {
pending = null
result.error("refused", error.message, null)
}
}
else -> result.notImplemented()
}
}
}
private fun installed(): Boolean {
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
val packages = activity.packageManager
val found = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packages.queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(0))
} else {
@Suppress("DEPRECATION")
packages.queryIntentActivities(intent, 0)
}
return found.isNotEmpty()
}
/** Whether [requestCode] was the voice input app's, its text handed on. */
fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode != REQUEST) return false
val result = pending ?: return true
pending = null
val text = if (resultCode == Activity.RESULT_OK) {
data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)?.firstOrNull()
} else {
null
}
result.success(text ?: "")
return true
}
private companion object {
/** Any number the app's other activity results don't use. */
const val REQUEST = 0x766f
}
}

View file

@ -394,8 +394,11 @@ class _SpeakOrSendState() extends State<SpeakOrSend> {
final words = HalkyonAppLocalizations.of(context);
final problem = switch (await _dictation.start(
language: Localizations.localeOf(context).toLanguageTag(),
// Shown by the voice input app, where it shows a prompt (Meditate's ADR 0038, D18).
prompt: l10n.assistantSpeak,
)) {
SpeechStart.listening => null,
// Listening in line, or the voice input app has listened.
SpeechStart.listening || SpeechStart.finished => null,
// A phone without Google's services has no recogniser installed.
SpeechStart.noRecognizer => words.speechNoRecognizer,
SpeechStart.unavailable => l10n.assistantSpeechUnavailable,

View file

@ -406,6 +406,31 @@ void main() {
expect(find.byTooltip('Search'), findsOneWidget);
});
testWidgets('with a voice input app installed, the microphone opens it, '
'and what it returns goes into the field, once; cancelled, nothing '
"(Meditate's ADR 0038, D18)", (tester) async {
speech.app = true;
await showApp(tester);
await unfold(tester);
await tester.tap(find.byTooltip('Speak your question'));
await tester.pump();
expect(speech.appOpen, isTrue);
expect(speech.listening, isFalse, reason: 'nothing else listens');
expect(speech.prompts, ['Speak your question']);
speech.cancel();
await tester.pumpAndSettle();
expect(find.byTooltip('Speak your question'), findsOneWidget);
await tester.tap(find.byTooltip('Speak your question'));
await tester.pump();
speech.answer('what does 火 mean');
await tester.pumpAndSettle();
expect(find.text('what does 火 mean'), findsOneWidget);
expect(speech.languages, hasLength(2), reason: 'once a tap');
expect(find.byTooltip('Search'), findsOneWidget);
});
testWidgets('says so where no speech recognition is installed, and how '
'to get one, never stuck listening (GrapheneOS, 4 Nov 2026)', (
tester,

View file

@ -90,9 +90,11 @@ const List<PrivacyPagePoint> _privacyPagePoints = [
'further, what it looks up there, and the files you attach: never '
'your progress, and a private studyset only once you agreed to send '
'it to that assistant. A question you speak is written by your '
'device’s own speech recognition, which on Android may send the '
'sound to Google or the phone’s maker; it never reaches the '
'instance.',
'device’s own speech recognition: on Android the voice input app '
'installed (FUTO Voice Input, for one), which hands Learn the text '
'alone and decides itself whether the sound leaves the device, or '
'else Android’s recogniser, which may send the sound to Google or '
'the phone’s maker; it never reaches the instance.',
german:
'Deine Unterhaltungen mit Assistenten werden aufbewahrt, um sie '
'zwischen '
@ -104,9 +106,12 @@ const List<PrivacyPagePoint> _privacyPagePoints = [
'suchen lässt, was er dort nachschlägt, und die Dateien, die du '
'anhängst: nie deinen Fortschritt, und ein privates Lernset erst, '
'nachdem du zugestimmt hast, es diesem Assistenten zu senden. Eine '
'Frage, die du sprichst, schreibt die Spracherkennung deines Geräts, '
'die auf Android den Ton an Google oder den Hersteller des Telefons '
'senden kann; die Instanz erreicht er nie.',
'Frage, die du sprichst, schreibt die Spracherkennung deines Geräts: '
'auf Android die installierte Spracheingabe-App (etwa FUTO Voice '
'Input), die Learn nur den Text gibt und selbst entscheidet, ob der '
'Ton das Gerät verlässt, sonst Androids Spracherkennung, die den Ton '
'an Google oder den Hersteller des Telefons senden kann; die Instanz '
'erreicht er nie.',
),
(
english: