fix(app,learn,meditate): a device without speech recognition says so, and how to get one

On GrapheneOS, which installs no recogniser, the microphone did nothing
and stayed listening: speech_to_text's initialize throws a
PlatformException (recognizerNotAvailable) there rather than answering
false, and DeviceSpeechInput let it through. A listen now answers how it
went (SpeechStart: listening, noRecognizer, unavailable) and never
throws; Learn's field and Meditate's dream say that no speech
recognition is installed and name two to install (FUTO Voice Input,
Google's speech services), in halkyon_app's texts. It also passes
androidNoBluetooth, as the apps declare no Bluetooth permission the
plugin would otherwise ask for.

Whats-New: Where the device has no speech recognition, the microphone says so and how to get one.
Whats-New-De: Wo das Gerät keine Spracherkennung hat, sagt das Mikrofon das und wie du eine bekommst.

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-04 20:45:55 +01:00
commit 2d27451cb8
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 254 additions and 64 deletions

View file

@ -372,6 +372,7 @@ class _SpeakOrSendState() extends State<SpeakOrSend> {
Future<void> _listen() async {
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context);
final words = HalkyonAppLocalizations.of(context);
final before = widget.controller.text.trimRight();
_before = before.isEmpty ? '' : '$before ';
setState(() => _listening = true);
@ -392,10 +393,15 @@ class _SpeakOrSendState() extends State<SpeakOrSend> {
}
},
);
if (!listens && mounted) {
if (!listens.listens && mounted) {
setState(() => _listening = false);
messenger.showSnackBar(
SnackBar(content: Text(l10n.assistantSpeechUnavailable)),
SnackBar(
content: Text(switch (listens) {
SpeechStart.noRecognizer => words.speechNoRecognizer,
_ => l10n.assistantSpeechUnavailable,
}),
),
);
}
}

View file

@ -380,6 +380,23 @@ void main() {
expect(onDevice, ['what does 火 mean']);
});
testWidgets('says so where no speech recognition is installed, and how '
'to get one, never stuck listening (GrapheneOS, 4 Nov 2026)', (
tester,
) async {
speech.hasRecognizer = false;
await showApp(tester);
await unfold(tester);
await tester.tap(find.byTooltip('Speak your question'));
await tester.pump();
expect(
find.textContaining('no speech recognition installed'),
findsOneWidget,
);
expect(find.byTooltip('Stop listening'), findsNothing);
expect(find.byTooltip('Speak your question'), findsOneWidget);
});
testWidgets("says so where the device can't listen, and shows no "
'microphone where nothing recognises speech (D67)', (tester) async {
speech.listens = false;

View file

@ -8,11 +8,11 @@ class const NoSpeechInput() implements SpeechInput {
bool get supported => false;
@override
Future<bool> listen({
Future<SpeechStart> listen({
required String? language,
required ValueChanged<String> onWords,
required VoidCallback onDone,
}) async => false;
}) async => SpeechStart.unavailable;
@override
Future<void> stop() async {}

View file

@ -91,13 +91,18 @@ class _DreamScreenState() extends State<DreamScreen> {
/// it), saying so where the device can't.
Future<void> _dictate() async {
final l10n = AppLocalizations.of(context);
final words = HalkyonAppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
if (!await _dictation.start(
final problem = switch (await _dictation.start(
language: Localizations.localeOf(context).toLanguageTag(),
)) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.dreamSpeechUnavailable)),
);
SpeechStart.listening => null,
// A phone without Google's services has no recogniser to listen with.
SpeechStart.noRecognizer => words.speechNoRecognizer,
SpeechStart.unavailable => l10n.dreamSpeechUnavailable,
};
if (problem != null) {
messenger.showSnackBar(SnackBar(content: Text(problem)));
}
}

View file

@ -1067,6 +1067,17 @@ void main() {
expect(find.byTooltip('Dictate'), findsOneWidget);
});
testWidgets('where no speech recognition is installed, it says so and '
'how to get one', (tester) async {
speech.hasRecognizer = false;
await newDream(tester);
await tester.tap(find.byTooltip('Dictate'));
await tester.pumpAndSettle();
expect(find.textContaining('FUTO Voice Input'), findsOneWidget);
expect(find.byTooltip('Dictate'), findsOneWidget, reason: 'not stuck');
});
testWidgets('where the device recognises no speech, there is no '
'microphone, here or on Today', (tester) async {
speech.supported = false;