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:
parent
00212bf49c
commit
2d27451cb8
10 changed files with 254 additions and 64 deletions
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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++);
|
||||
|
|
|
|||
Loading…
Reference in a new issue