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

View file

@ -515,5 +515,6 @@
"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.",
"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.",
"publicProfileVerifyHint": "A link gets a checkmark once its page links back to your profile page with rel=\"me\", as Mastodon checks links.",
"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:
/// **'Links back to this profile'**
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

View file

@ -2011,4 +2011,8 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
@override
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
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`).

View file

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

View file

@ -1,6 +1,23 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.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;
/// Halkyon Meditate's ADR 0038, D15), through the device's own speech
/// 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
/// 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]
/// is called, then calls [onDone]. False, and nothing heard, where the
/// device can't listen now: no recogniser, or the microphone refused.
Future<bool> listen({
/// is called, then calls [onDone]. Where the device can't listen now,
/// nothing is heard and [onDone] isn't called, and the answer says why:
/// no recogniser installed, or another reason, as the microphone refused.
/// Never throws.
Future<SpeechStart> listen({
required String? language,
required ValueChanged<String> onWords,
required VoidCallback onDone,
@ -65,33 +84,49 @@ class DeviceSpeechInput({
};
@override
Future<bool> listen({
Future<SpeechStart> listen({
required String? language,
required ValueChanged<String> onWords,
required VoidCallback onDone,
}) async {
if (!supported ||
!await _speech.initialize(
onStatus: (status) {
if (status == SpeechToText.doneStatus ||
status == SpeechToText.notListeningStatus) {
_done();
}
},
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;
}
if (!supported) {
return SpeechStart.unavailable;
}
final bool initialized;
try {
initialized = await _speech.initialize(
onStatus: (status) {
if (status == SpeechToText.doneStatus ||
status == SpeechToText.notListeningStatus) {
_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;
_heard = false;
@ -99,26 +134,35 @@ class DeviceSpeechInput({
preferOnDevice &&
!_onDeviceFailed &&
defaultTargetPlatform == TargetPlatform.android;
await _speech.listen(
onResult: (result) {
_heard = _heard || result.recognizedWords.isNotEmpty;
onWords(result.recognizedWords);
if (result.finalResult) {
_done();
}
},
listenOptions: SpeechListenOptions(
localeId: language == null ? null : await _localeOf(language),
listenMode: ListenMode.dictation,
cancelOnError: true,
onDevice: _onDevice,
pauseFor: pauseFor,
listenFor: listenFor,
),
);
return true;
try {
await _speech.listen(
onResult: (result) {
_heard = _heard || result.recognizedWords.isNotEmpty;
onWords(result.recognizedWords);
if (result.finalResult) {
_done();
}
},
listenOptions: SpeechListenOptions(
localeId: language == null ? null : await _localeOf(language),
listenMode: ListenMode.dictation,
cancelOnError: true,
onDevice: _onDevice,
pauseFor: pauseFor,
listenFor: listenFor,
),
);
} 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() {
final done = _onDone;
_onDone = null;

View file

@ -11,6 +11,10 @@ class FakeSpeechInput() implements SpeechInput {
/// were refused.
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.
final languages = <String?>[];
@ -32,18 +36,21 @@ class FakeSpeechInput() implements SpeechInput {
}
@override
Future<bool> listen({
Future<SpeechStart> listen({
required String? language,
required ValueChanged<String> onWords,
required VoidCallback onDone,
}) async {
languages.add(language);
if (!hasRecognizer) {
return SpeechStart.noRecognizer;
}
if (!listens) {
return false;
return SpeechStart.unavailable;
}
_onWords = onWords;
_onDone = onDone;
return true;
return SpeechStart.listening;
}
@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, '
'and shows them', () async {
expect(await dictation.start(language: 'de'), isTrue);
expect(await dictation.start(language: 'de'), SpeechStart.listening);
expect(dictation.listening, isTrue);
expect(speech.languages, ['de']);
@ -152,10 +152,20 @@ void main() {
test('where the device cannot listen, it says so and stays off', () async {
speech.listens = false;
expect(await dictation.start(language: 'en'), isFalse);
expect(await dictation.start(language: 'en'), SpeechStart.unavailable);
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 {
var told = 0;
dictation.addListener(() => told++);