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
15 changed files with 254 additions and 64 deletions

View file

@ -372,6 +372,7 @@ class _SpeakOrSendState() extends State<SpeakOrSend> {
Future<void> _listen() async { Future<void> _listen() async {
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final words = HalkyonAppLocalizations.of(context);
final before = widget.controller.text.trimRight(); final before = widget.controller.text.trimRight();
_before = before.isEmpty ? '' : '$before '; _before = before.isEmpty ? '' : '$before ';
setState(() => _listening = true); setState(() => _listening = true);
@ -392,10 +393,15 @@ class _SpeakOrSendState() extends State<SpeakOrSend> {
} }
}, },
); );
if (!listens && mounted) { if (!listens.listens && mounted) {
setState(() => _listening = false); setState(() => _listening = false);
messenger.showSnackBar( 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']); 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 " testWidgets("says so where the device can't listen, and shows no "
'microphone where nothing recognises speech (D67)', (tester) async { 'microphone where nothing recognises speech (D67)', (tester) async {
speech.listens = false; speech.listens = false;

View file

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

View file

@ -91,13 +91,18 @@ class _DreamScreenState() extends State<DreamScreen> {
/// it), saying so where the device can't. /// it), saying so where the device can't.
Future<void> _dictate() async { Future<void> _dictate() async {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final words = HalkyonAppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
if (!await _dictation.start( final problem = switch (await _dictation.start(
language: Localizations.localeOf(context).toLanguageTag(), language: Localizations.localeOf(context).toLanguageTag(),
)) { )) {
messenger.showSnackBar( SpeechStart.listening => null,
SnackBar(content: Text(l10n.dreamSpeechUnavailable)), // 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); 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 ' testWidgets('where the device recognises no speech, there is no '
'microphone, here or on Today', (tester) async { 'microphone, here or on Today', (tester) async {
speech.supported = false; speech.supported = false;

View file

@ -515,5 +515,6 @@
"publicProfileUnreachable": "Dein öffentliches Profil ist gerade nicht erreichbar.", "publicProfileUnreachable": "Dein öffentliches Profil ist gerade nicht erreichbar.",
"publicProfileVerifyHint": "Ein Link bekommt ein Häkchen, sobald seine Seite mit rel=\"me\" auf deine Profilseite zurückverlinkt, wie Mastodon Links prüft.", "publicProfileVerifyHint": "Ein Link bekommt ein Häkchen, sobald seine Seite mit rel=\"me\" auf deine Profilseite zurückverlinkt, wie Mastodon Links prüft.",
"profileAccount": "Konto", "profileAccount": "Konto",
"profileSheetLinkVerified": "Verlinkt zurück auf dieses Profil" "profileSheetLinkVerified": "Verlinkt zurück auf dieses Profil",
"speechNoRecognizer": "Auf diesem Gerät ist keine Spracherkennung installiert. Installiere eine, etwa FUTO Voice Input oder Googles Sprachdienste, und versuche es dann noch einmal."
} }

View file

@ -1583,5 +1583,7 @@
"publicProfileUnreachable": "Your public profile can't be reached right now.", "publicProfileUnreachable": "Your public profile can't be reached right now.",
"publicProfileVerifyHint": "A link gets a checkmark once its page links back to your profile page with rel=\"me\", as Mastodon checks links.", "publicProfileVerifyHint": "A link gets a checkmark once its page links back to your profile page with rel=\"me\", as Mastodon checks links.",
"profileAccount": "Account", "profileAccount": "Account",
"profileSheetLinkVerified": "Links back to this profile" "profileSheetLinkVerified": "Links back to this profile",
"speechNoRecognizer": "This device has no speech recognition installed. Install one, such as FUTO Voice Input or Google's speech services, then try again.",
"@speechNoRecognizer": {"description": "Shown when the microphone is tapped on a device without any speech recognition app (as GrapheneOS without Google's services), with a hint how to get one."}
} }

View file

@ -3198,6 +3198,12 @@ abstract class HalkyonAppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Links back to this profile'** /// **'Links back to this profile'**
String get profileSheetLinkVerified; String get profileSheetLinkVerified;
/// Shown when the microphone is tapped on a device without any speech recognition app (as GrapheneOS without Google's services), with a hint how to get one.
///
/// In en, this message translates to:
/// **'This device has no speech recognition installed. Install one, such as FUTO Voice Input or Google\'s speech services, then try again.'**
String get speechNoRecognizer;
} }
class _HalkyonAppLocalizationsDelegate class _HalkyonAppLocalizationsDelegate

View file

@ -2011,4 +2011,8 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
@override @override
String get profileSheetLinkVerified => 'Verlinkt zurück auf dieses Profil'; String get profileSheetLinkVerified => 'Verlinkt zurück auf dieses Profil';
@override
String get speechNoRecognizer =>
'Auf diesem Gerät ist keine Spracherkennung installiert. Installiere eine, etwa FUTO Voice Input oder Googles Sprachdienste, und versuche es dann noch einmal.';
} }

View file

@ -1979,6 +1979,10 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@override @override
String get profileSheetLinkVerified => 'Links back to this profile'; String get profileSheetLinkVerified => 'Links back to this profile';
@override
String get speechNoRecognizer =>
'This device has no speech recognition installed. Install one, such as FUTO Voice Input or Google\'s speech services, then try again.';
} }
/// The translations for English, as used in the United Kingdom (`en_GB`). /// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -82,11 +82,11 @@ class Dictation(
/// The words of the listen under way so far. /// The words of the listen under way so far.
String get heard => _heard; String get heard => _heard;
/// Starts listening in [language] (the device's own where null); false, /// Starts listening in [language] (the device's own where null); off
/// and off again, where the device can't listen. /// again where the device can't listen, the answer saying why.
Future<bool> start({required String? language}) async { Future<SpeechStart> start({required String? language}) async {
if (_on) { if (_on) {
return true; return SpeechStart.listening;
} }
_on = true; _on = true;
_silences = 0; _silences = 0;
@ -95,7 +95,7 @@ class Dictation(
return await _listenOnce(); return await _listenOnce();
} }
Future<bool> _listenOnce() async { Future<SpeechStart> _listenOnce() async {
final listen = ++_listen; final listen = ++_listen;
final insertion = DictationInsertion.at(_text.value); final insertion = DictationInsertion.at(_text.value);
_heard = ''; _heard = '';
@ -114,7 +114,7 @@ class Dictation(
} }
}, },
); );
if (!listens && listen == _listen) { if (!listens.listens && listen == _listen) {
_off(); _off();
} }
return listens; return listens;

View file

@ -1,6 +1,23 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:speech_to_text/speech_to_text.dart'; import 'package:speech_to_text/speech_to_text.dart';
/// How asking a [SpeechInput] to listen went.
enum SpeechStart() {
/// It listens.
listening,
/// No speech recognition is installed, as on a phone without Google's
/// services (GrapheneOS, LineageOS): the person can install one.
noRecognizer,
/// It can't listen now, as with the microphone refused.
unavailable;
/// Whether it listens.
bool get listens => this == listening;
}
/// Turns what the person says into words (Halkyon Learn's ADR 0021, D67; /// Turns what the person says into words (Halkyon Learn's ADR 0021, D67;
/// Halkyon Meditate's ADR 0038, D15), through the device's own speech /// Halkyon Meditate's ADR 0038, D15), through the device's own speech
/// recognition: Android's (which may send the sound to Google or the /// recognition: Android's (which may send the sound to Google or the
@ -14,9 +31,11 @@ abstract interface class SpeechInput() {
/// Listens in [language] (a BCP 47 tag; the device's own language where /// Listens in [language] (a BCP 47 tag; the device's own language where
/// it has none of that language), handing on all it heard so far to /// it has none of that language), handing on all it heard so far to
/// [onWords] as it hears it, until it stops hearing anything or [stop] /// [onWords] as it hears it, until it stops hearing anything or [stop]
/// is called, then calls [onDone]. False, and nothing heard, where the /// is called, then calls [onDone]. Where the device can't listen now,
/// device can't listen now: no recogniser, or the microphone refused. /// nothing is heard and [onDone] isn't called, and the answer says why:
Future<bool> listen({ /// no recogniser installed, or another reason, as the microphone refused.
/// Never throws.
Future<SpeechStart> listen({
required String? language, required String? language,
required ValueChanged<String> onWords, required ValueChanged<String> onWords,
required VoidCallback onDone, required VoidCallback onDone,
@ -65,33 +84,49 @@ class DeviceSpeechInput({
}; };
@override @override
Future<bool> listen({ Future<SpeechStart> listen({
required String? language, required String? language,
required ValueChanged<String> onWords, required ValueChanged<String> onWords,
required VoidCallback onDone, required VoidCallback onDone,
}) async { }) async {
if (!supported || if (!supported) {
!await _speech.initialize( return SpeechStart.unavailable;
onStatus: (status) { }
if (status == SpeechToText.doneStatus || final bool initialized;
status == SpeechToText.notListeningStatus) { try {
_done(); initialized = await _speech.initialize(
} onStatus: (status) {
}, if (status == SpeechToText.doneStatus ||
onError: (error) { status == SpeechToText.notListeningStatus) {
// A pause isn't a failure of the device's recogniser.
if (_onDevice &&
!_heard &&
!const {
'error_no_match',
'error_speech_timeout',
}.contains(error.errorMsg)) {
_onDeviceFailed = true;
}
_done(); _done();
}, }
)) { },
return false; onError: (error) {
// A pause isn't a failure of the device's recogniser.
if (_onDevice &&
!_heard &&
!const {
'error_no_match',
'error_speech_timeout',
}.contains(error.errorMsg)) {
_onDeviceFailed = true;
}
_done();
},
// Learn's and Meditate's apps declare no Bluetooth permission, which
// the plugin would otherwise ask for beside the microphone's.
options: [SpeechToText.androidNoBluetooth],
);
} on PlatformException catch (error) {
// Android without a recogniser installed (GrapheneOS has none) throws
// rather than answering false (found on 4 Nov 2026: the microphone did
// nothing, and stayed listening).
return error.code == _recognizerNotAvailable
? SpeechStart.noRecognizer
: SpeechStart.unavailable;
}
if (!initialized) {
return SpeechStart.unavailable;
} }
_onDone = onDone; _onDone = onDone;
_heard = false; _heard = false;
@ -99,26 +134,35 @@ class DeviceSpeechInput({
preferOnDevice && preferOnDevice &&
!_onDeviceFailed && !_onDeviceFailed &&
defaultTargetPlatform == TargetPlatform.android; defaultTargetPlatform == TargetPlatform.android;
await _speech.listen( try {
onResult: (result) { await _speech.listen(
_heard = _heard || result.recognizedWords.isNotEmpty; onResult: (result) {
onWords(result.recognizedWords); _heard = _heard || result.recognizedWords.isNotEmpty;
if (result.finalResult) { onWords(result.recognizedWords);
_done(); if (result.finalResult) {
} _done();
}, }
listenOptions: SpeechListenOptions( },
localeId: language == null ? null : await _localeOf(language), listenOptions: SpeechListenOptions(
listenMode: ListenMode.dictation, localeId: language == null ? null : await _localeOf(language),
cancelOnError: true, listenMode: ListenMode.dictation,
onDevice: _onDevice, cancelOnError: true,
pauseFor: pauseFor, onDevice: _onDevice,
listenFor: listenFor, pauseFor: pauseFor,
), listenFor: listenFor,
); ),
return true; );
} on Exception {
// `ListenFailedException`, or the locales' PlatformException.
_onDone = null;
return SpeechStart.unavailable;
}
return SpeechStart.listening;
} }
/// The code `speech_to_text` fails with where Android has no recogniser.
static const _recognizerNotAvailable = 'recognizerNotAvailable';
void _done() { void _done() {
final done = _onDone; final done = _onDone;
_onDone = null; _onDone = null;

View file

@ -11,6 +11,10 @@ class FakeSpeechInput() implements SpeechInput {
/// were refused. /// were refused.
bool listens = true; bool listens = true;
/// Whether a speech recogniser is installed: false as on a phone without
/// Google's services.
bool hasRecognizer = true;
/// The languages asked to listen in, in order. /// The languages asked to listen in, in order.
final languages = <String?>[]; final languages = <String?>[];
@ -32,18 +36,21 @@ class FakeSpeechInput() implements SpeechInput {
} }
@override @override
Future<bool> listen({ Future<SpeechStart> listen({
required String? language, required String? language,
required ValueChanged<String> onWords, required ValueChanged<String> onWords,
required VoidCallback onDone, required VoidCallback onDone,
}) async { }) async {
languages.add(language); languages.add(language);
if (!hasRecognizer) {
return SpeechStart.noRecognizer;
}
if (!listens) { if (!listens) {
return false; return SpeechStart.unavailable;
} }
_onWords = onWords; _onWords = onWords;
_onDone = onDone; _onDone = onDone;
return true; return SpeechStart.listening;
} }
@override @override

View file

@ -0,0 +1,73 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
/// The device's speech recognition through `speech_to_text`, its platform
/// channel answering as Android does: a phone without a recogniser
/// installed (GrapheneOS has none) fails with a PlatformException, which
/// left the microphone doing nothing and stuck listening (4 Nov 2026).
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('plugin.csdcorp.com/speech_to_text');
late List<MethodCall> calls;
void answer(Object? Function(MethodCall call) answers) {
calls = [];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
calls.add(call);
return answers(call);
});
}
tearDown(
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null),
);
Future<SpeechStart> listen() => DeviceSpeechInput().listen(
language: 'en',
onWords: (_) {},
onDone: () => fail('nothing was heard'),
);
test('without a recogniser installed, it says so, asking for no '
'Bluetooth permission', () async {
answer(
(call) => throw PlatformException(
code: 'recognizerNotAvailable',
message: 'Speech recognition not available on this device',
),
);
expect(await listen(), SpeechStart.noRecognizer);
final initialize = calls.single;
expect(initialize.method, 'initialize');
expect(
(initialize.arguments as Map<Object?, Object?>)['noBluetooth'],
isTrue,
);
});
test('any other failure, it says it cannot listen now', () async {
answer((call) => throw PlatformException(code: 'missingContext'));
expect(await listen(), SpeechStart.unavailable);
});
test('the microphone refused, it says it cannot listen now', () async {
answer((call) => call.method == 'initialize' ? false : null);
expect(await listen(), SpeechStart.unavailable);
});
// Last: `speech_to_text` keeps one instance, initialised from here on.
test('a listen that fails to start says it cannot listen now', () async {
answer(
(call) => switch (call.method) {
'initialize' => true,
'locales' => <Object?>[],
'listen' => throw PlatformException(code: 'listenFailed'),
_ => null,
},
);
expect(await listen(), SpeechStart.unavailable);
});
}

View file

@ -86,7 +86,7 @@ void main() {
test('listens in the language asked, writing the words as they come, ' test('listens in the language asked, writing the words as they come, '
'and shows them', () async { 'and shows them', () async {
expect(await dictation.start(language: 'de'), isTrue); expect(await dictation.start(language: 'de'), SpeechStart.listening);
expect(dictation.listening, isTrue); expect(dictation.listening, isTrue);
expect(speech.languages, ['de']); expect(speech.languages, ['de']);
@ -152,10 +152,20 @@ void main() {
test('where the device cannot listen, it says so and stays off', () async { test('where the device cannot listen, it says so and stays off', () async {
speech.listens = false; speech.listens = false;
expect(await dictation.start(language: 'en'), isFalse); expect(await dictation.start(language: 'en'), SpeechStart.unavailable);
expect(dictation.listening, isFalse); expect(dictation.listening, isFalse);
}); });
test('where no recogniser is installed, it says so and stays off, '
'never stuck listening', () async {
speech.hasRecognizer = false;
var told = 0;
dictation.addListener(() => told++);
expect(await dictation.start(language: 'en'), SpeechStart.noRecognizer);
expect(dictation.listening, isFalse);
expect(told, 2, reason: 'on, then off again');
});
test('tells its listeners as it starts, hears and stops', () async { test('tells its listeners as it starts, hears and stops', () async {
var told = 0; var told = 0;
dictation.addListener(() => told++); dictation.addListener(() => told++);