feat(learn,todo,meditate): dictate through the phone's voice input app (ADR 0038, D18)
On Android the microphone now opens the app handling RECOGNIZE_SPEECH (FUTO Voice Input, Google's), the default the person chose in Android, and puts the text it returns where the cursor stood, as Dictation does; cancelled, nothing. Only where none is installed, or it can't be opened, does speech_to_text listen in line as before. halkyon_app: VoiceInputApp (one listen, an answer once closed) on the channel app.halkyon/voice_input, which DeviceSpeechInput asks first at every listen, answering SpeechStart.finished so that Dictation doesn't listen again; speech_to_text isn't touched on that route, so our app asks for no microphone. SpeechInput.listen and Dictation.start take the prompt the voice input app shows (the field's hint). Each app: VoiceInput.kt (queryIntentActivities, startActivityForResult, the first of EXTRA_RESULTS) attached in MainActivity, which hands it activity results, and RECOGNIZE_SPEECH among the manifest's queries. The privacy pages of Learn and Meditate say so. Our own Whisper and Meditate's dream recordings are dropped (D18). Whats-New: The microphone now dictates through your phone's voice input app, such as FUTO Voice Input. Whats-New-De: Das Mikrofon diktiert jetzt über die Spracheingabe-App deines Telefons, etwa FUTO Voice Input. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
4940230ef1
commit
c36bd83c6a
3 changed files with 827 additions and 27 deletions
|
|
@ -58,6 +58,64 @@ void main() {
|
|||
expect(await listen(), SpeechStart.unavailable);
|
||||
});
|
||||
|
||||
group('with a voice input app installed (ADR 0038, D18)', () {
|
||||
late _VoiceInputApp app;
|
||||
late List<String> 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<SpeechStart> 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(
|
||||
|
|
@ -71,3 +129,22 @@ void main() {
|
|||
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<bool> installed() async => installs;
|
||||
|
||||
@override
|
||||
Future<String?> listen({required String? language, String? prompt}) async {
|
||||
asked.add((language, prompt));
|
||||
return words;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -166,6 +166,52 @@ void main() {
|
|||
expect(told, 2, reason: 'on, then off again');
|
||||
});
|
||||
|
||||
group('through a voice input app (ADR 0038, D18)', () {
|
||||
setUp(() => speech.app = true);
|
||||
|
||||
test('writes what it returns where the cursor stood, once, and '
|
||||
'listens no more', () async {
|
||||
text.value = const TextEditingValue(
|
||||
text: 'I was there.',
|
||||
selection: TextSelection.collapsed(offset: 5),
|
||||
);
|
||||
final started = dictation.start(language: 'en', prompt: 'Your dream');
|
||||
await pumpEventQueue();
|
||||
expect(dictation.listening, isTrue, reason: 'while the app is open');
|
||||
expect(speech.appOpen, isTrue);
|
||||
expect(speech.listening, isFalse, reason: 'nothing else listens');
|
||||
expect(speech.prompts, ['Your dream']);
|
||||
|
||||
speech.answer('at sea');
|
||||
expect(await started, SpeechStart.finished);
|
||||
expect(text.text, 'I was at sea there.');
|
||||
expect(text.selection, const TextSelection.collapsed(offset: 12));
|
||||
expect(dictation.listening, isFalse);
|
||||
expect(speech.languages, ['en'], reason: 'not begun again');
|
||||
});
|
||||
|
||||
test('cancelled, writes nothing', () async {
|
||||
text.text = 'I was';
|
||||
final started = dictation.start(language: 'en');
|
||||
await pumpEventQueue();
|
||||
speech.cancel();
|
||||
expect(await started, SpeechStart.finished);
|
||||
expect(text.text, 'I was');
|
||||
expect(dictation.listening, isFalse);
|
||||
});
|
||||
|
||||
test('stopped while the app is open, writes nothing it returns '
|
||||
'later', () async {
|
||||
final started = dictation.start(language: 'en');
|
||||
await pumpEventQueue();
|
||||
await dictation.stop();
|
||||
expect(dictation.listening, isFalse);
|
||||
speech.answer('late words');
|
||||
await started;
|
||||
expect(text.text, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
test('tells its listeners as it starts, hears and stops', () async {
|
||||
var told = 0;
|
||||
dictation.addListener(() => told++);
|
||||
|
|
|
|||
98
packages/halkyon_app/test/speech/voice_input_app_test.dart
Normal file
98
packages/halkyon_app/test/speech/voice_input_app_test.dart
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
|
||||
/// The device's voice input app through Android's `RECOGNIZE_SPEECH`
|
||||
/// intent (Halkyon Meditate's ADR 0038, D18), asked through each app's
|
||||
/// `VoiceInput.kt` on `app.halkyon/voice_input`, its channel answering as
|
||||
/// the activity does.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
late List<MethodCall> calls;
|
||||
|
||||
void answer(Object? Function(MethodCall call) answers) {
|
||||
calls = [];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(ChannelVoiceInputApp.channel, (call) async {
|
||||
calls.add(call);
|
||||
return answers(call);
|
||||
});
|
||||
}
|
||||
|
||||
tearDown(() {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(ChannelVoiceInputApp.channel, null);
|
||||
});
|
||||
|
||||
const app = ChannelVoiceInputApp();
|
||||
|
||||
group('installed', () {
|
||||
test('where Android has an app for the intent', () async {
|
||||
answer((call) => call.method == 'installed');
|
||||
expect(await app.installed(), isTrue);
|
||||
});
|
||||
|
||||
test('not where it has none', () async {
|
||||
answer((call) => false);
|
||||
expect(await app.installed(), isFalse);
|
||||
});
|
||||
|
||||
test('not where the activity fails or knows no such channel', () async {
|
||||
answer((call) => throw PlatformException(code: 'failed'));
|
||||
expect(await app.installed(), isFalse);
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(ChannelVoiceInputApp.channel, null);
|
||||
expect(await app.installed(), isFalse);
|
||||
});
|
||||
|
||||
test('never off Android, without asking', () async {
|
||||
answer((call) => true);
|
||||
for (final platform in [
|
||||
TargetPlatform.iOS,
|
||||
TargetPlatform.linux,
|
||||
TargetPlatform.macOS,
|
||||
TargetPlatform.windows,
|
||||
]) {
|
||||
debugDefaultTargetPlatformOverride = platform;
|
||||
expect(await app.installed(), isFalse, reason: '$platform');
|
||||
}
|
||||
expect(calls, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('listening', () {
|
||||
test('asks in the language given, with the prompt, and hands back the '
|
||||
'words', () async {
|
||||
answer((call) => 'a house by the sea');
|
||||
expect(
|
||||
await app.listen(language: 'de-DE', prompt: 'Was hast du geträumt?'),
|
||||
'a house by the sea',
|
||||
);
|
||||
final listen = calls.single;
|
||||
expect(listen.method, 'listen');
|
||||
expect(listen.arguments, {
|
||||
'language': 'de-DE',
|
||||
'prompt': 'Was hast du geträumt?',
|
||||
});
|
||||
});
|
||||
|
||||
test('cancelled, hands back nothing', () async {
|
||||
answer((call) => '');
|
||||
expect(await app.listen(language: null), '');
|
||||
expect(calls.single.arguments, {'language': null, 'prompt': null});
|
||||
});
|
||||
|
||||
test(
|
||||
'where the app could not be opened, says so without throwing',
|
||||
() async {
|
||||
answer((call) => throw PlatformException(code: 'noApp'));
|
||||
expect(await app.listen(language: 'en'), isNull);
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(ChannelVoiceInputApp.channel, null);
|
||||
expect(await app.listen(language: 'en'), isNull);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue