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 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 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)['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); }); group('with a voice input app installed (ADR 0038, D18)', () { late _VoiceInputApp app; late List heard; late SpeechInput speech; setUp(() { // speech_to_text answering as with the microphone refused, which // leaves its one instance uninitialised for the tests after these. answer((call) => call.method == 'initialize' ? false : null); app = _VoiceInputApp(); heard = []; speech = DeviceSpeechInput(voiceInputApp: app); }); Future listen() => speech.listen( language: 'de-DE', prompt: 'Was hast du geträumt?', onWords: heard.add, onDone: () => fail('the app has listened, once'), ); test('hands listening to it, in the language and with the prompt ' 'given, and the words it returns on, once', () async { app.words = 'Ich war am Meer'; expect(await listen(), SpeechStart.finished); expect(app.asked, [('de-DE', 'Was hast du geträumt?')]); expect(heard, ['Ich war am Meer']); }); test('asks speech_to_text nothing, so our app never asks for the ' 'microphone: the voice input app records', () async { app.words = 'Ich war am Meer'; await listen(); expect(calls, isEmpty); }); test('cancelled in it, hands nothing on and listens no more', () async { app.words = ''; expect(await listen(), SpeechStart.finished); expect(heard, isEmpty); expect(calls, isEmpty); }); test('where it could not be opened, listens in line instead', () async { app.words = null; expect(await listen(), SpeechStart.unavailable); expect(app.asked, hasLength(1)); expect(calls.first.method, 'initialize'); }); test('none installed, listens in line', () async { app.installs = false; expect(await listen(), SpeechStart.unavailable); expect(app.asked, isEmpty); expect(calls.first.method, 'initialize'); }); }); // 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' => [], 'listen' => throw PlatformException(code: 'listenFailed'), _ => null, }, ); expect(await listen(), SpeechStart.unavailable); }); } /// A voice input app the test sets up: installed or not, returning [words] /// ('' as cancelled, null as failing to open). class _VoiceInputApp() implements VoiceInputApp { bool installs = true; String? words = ''; /// The language and prompt of each listen, in order. final asked = <(String?, String?)>[]; @override Future installed() async => installs; @override Future listen({required String? language, String? prompt}) async { asked.add((language, prompt)); return words; } }