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
15 changed files with 254 additions and 64 deletions
|
|
@ -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,
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 {}
|
||||||
|
|
|
||||||
|
|
@ -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)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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."}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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`).
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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, '
|
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++);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue