@tduncan / Cortana / commits / fa66bcf

feat: improve microphone service initialization with better permission handling, race condition management, and sequencing logic

abdulmominsakib committed Sep 8, 2026 at 10:18 UTC fa66bcffa4e7c9a8211511bc7f612252d087745d
7 files changed +275 -23
android/app/src/main/AndroidManifest.xml
-1
@@ -123,7 +123,6 @@
123 </intent>
124 <intent>
125 <action android:name="android.speech.RecognitionService" />
126 - <data android:mimeType="text/plain" />
126 </intent>
127 <intent>
128 <action android:name="android.intent.action.SEND" />
android/app/src/main/kotlin/pro/momin/localmind/ChatForegroundService.kt
+21
@@ -1,12 +1,15 @@
1 package pro.momin.localmind
2
3 import android.app.*
4 +import android.Manifest
5 import android.content.Context
6 import android.content.Intent
7 +import android.content.pm.PackageManager
8 import android.content.pm.ServiceInfo
9 import android.os.Build
10 import android.os.IBinder
11 import androidx.core.app.NotificationCompat
12 +import androidx.core.content.ContextCompat
13
14 class ChatForegroundService : Service() {
15 companion object {
@@ -21,6 +24,15 @@ class ChatForegroundService : Service() {
24 }
25
26 fun startService(context: Context, type: String) {
27 + if (type == TYPE_MICROPHONE &&
28 + ContextCompat.checkSelfPermission(
29 + context,
30 + Manifest.permission.RECORD_AUDIO
31 + ) != PackageManager.PERMISSION_GRANTED
32 + ) {
33 + throw SecurityException("RECORD_AUDIO permission has not been granted")
34 + }
35 +
36 val intent = Intent(context, ChatForegroundService::class.java)
37 .putExtra(EXTRA_TYPE, type)
38 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -46,6 +58,15 @@ class ChatForegroundService : Service() {
58 )
59
60 val type = intent?.getStringExtra(EXTRA_TYPE) ?: TYPE_SPECIAL_USE
61 + if (type == TYPE_MICROPHONE &&
62 + ContextCompat.checkSelfPermission(
63 + this,
64 + Manifest.permission.RECORD_AUDIO
65 + ) != PackageManager.PERMISSION_GRANTED
66 + ) {
67 + stopSelf(startId)
68 + return START_NOT_STICKY
69 + }
70 val notificationText = if (type == TYPE_MICROPHONE) {
71 "Listening…"
72 } else {
android/app/src/main/kotlin/pro/momin/localmind/MainActivity.kt
+16 -2
@@ -49,8 +49,22 @@ class MainActivity : AudioServiceActivity() {
49 result.success(null)
50 }
51 "startForegroundMic" -> {
52 - ChatForegroundService.startService(this, "microphone")
53 - result.success(null)
52 + try {
53 + ChatForegroundService.startService(this, "microphone")
54 + result.success(null)
55 + } catch (error: SecurityException) {
56 + result.error(
57 + "microphone_permission_required",
58 + error.message ?: "Microphone permission is required.",
59 + null
60 + )
61 + } catch (error: IllegalStateException) {
62 + result.error(
63 + "microphone_service_unavailable",
64 + error.message ?: "The microphone service could not start.",
65 + null
66 + )
67 + }
68 }
69 "stopForegroundMic" -> {
70 ChatForegroundService.stopService(this)
lib/core/services/chat_background_service.dart
+4 -2
@@ -40,16 +40,18 @@ class ChatBackgroundService {
40 /// survives when the app is backgrounded (Android 14+ requirement).
41 /// Does not toggle Wakelock — STT is short-lived and the overlay is
42 /// already on-screen when listening starts.
43 - Future<void> startMic() async {
44 - if (_isMicActive) return;
43 + Future<bool> startMic() async {
44 + if (_isMicActive) return true;
45 try {
46 Log.info('Starting background mic service');
47 if (Platform.isAndroid) {
48 await _channel.invokeMethod('startForegroundMic');
49 }
50 _isMicActive = true;
51 + return true;
52 } catch (e) {
53 Log.error('Failed to start background mic service: $e');
54 + return false;
55 }
56 }
57
lib/features/stt/providers/stt_providers.dart
+14 -4
@@ -39,6 +39,7 @@ class SttState {
39 class SttNotifier extends Notifier<SttState> {
40 late SpeechToText _speech;
41 bool _isInit = false;
42 + Future<bool>? _initialization;
43 // Monotonically increasing counter; status callbacks from a previous
44 // session cannot clobber state set by a newer one.
45 int _session = 0;
@@ -59,8 +60,12 @@ class SttNotifier extends Notifier<SttState> {
60 return const SttState();
61 }
62
62 - Future<bool> initSpeech() async {
63 - if (_isInit) return state.isAvailable;
63 + Future<bool> initSpeech() {
64 + if (_isInit) return Future.value(state.isAvailable);
65 + return _initialization ??= _initializeSpeech();
66 + }
67 +
68 + Future<bool> _initializeSpeech() async {
69 try {
70 final available = await _speech.initialize(
71 onError: (val) {
@@ -81,16 +86,21 @@ class SttNotifier extends Notifier<SttState> {
86 state = state.copyWith(isListening: false);
87 }
88 },
89 + options: [SpeechToText.androidNoBluetooth],
90 );
91 if (!ref.mounted) return false;
86 - _isInit = true;
87 - state = state.copyWith(isAvailable: available);
92 + // A denied permission must remain retryable after the user changes it
93 + // in Android Settings. Do not cache an unsuccessful initialization.
94 + _isInit = available;
95 + state = state.copyWith(isAvailable: available, clearError: available);
96 return available;
97 } catch (e) {
98 if (!ref.mounted) return false;
99 Log.error('STT initialization failed: $e');
100 state = state.copyWith(isAvailable: false, error: e.toString());
101 return false;
102 + } finally {
103 + _initialization = null;
104 }
105 }
106
lib/features/voice_mode/providers/voice_mode_provider.dart
+49 -13
@@ -86,6 +86,7 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
86 /// listeners don't fire after the session has been torn down.
87 bool _active = false;
88 bool _isSendingTranscript = false;
89 + int _listenAttempt = 0;
90
91 /// Whether the "generating" feedback cue has already fired for the
92 /// current streamed response. Reset on session start; consumed once
@@ -151,6 +152,7 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
152 /// Start listening for user speech.
153 Future<void> startListening() async {
154 if (!_active || !ref.mounted) return;
155 + final attempt = ++_listenAttempt;
156 if (!_ensureChatTarget()) return;
157
158 _isSendingTranscript = false;
@@ -165,12 +167,39 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
167
168 ref.read(voiceFeedbackProvider).playListening();
169
170 + final stt = ref.read(sttProvider.notifier);
171 + final available = await stt.initSpeech();
172 + if (!_isCurrentListenAttempt(attempt)) return;
173 + if (!available) {
174 + state = state.copyWith(
175 + phase: VoiceModePhase.error,
176 + error: 'Microphone permission is required for voice mode.',
177 + micLevel: 0,
178 + );
179 + ref.read(voiceFeedbackProvider).playDisconnected();
180 + return;
181 + }
182 +
183 // Hold a microphone-typed foreground service so that voice capture
184 // survives when the app is backgrounded on Android 14+. Must run
170 - // before SpeechRecognizer.startListening() begins consuming the mic.
171 - await ref.read(chatBackgroundServiceProvider).startMic();
185 + // after permission has been granted and while this activity is visible.
186 + final background = ref.read(chatBackgroundServiceProvider);
187 + final micStarted = await background.startMic();
188 + if (!_isCurrentListenAttempt(attempt)) {
189 + if (micStarted) await background.stopMic();
190 + return;
191 + }
192 + if (!micStarted) {
193 + state = state.copyWith(
194 + phase: VoiceModePhase.error,
195 + error:
196 + 'Could not start microphone access. Open LocalMind and try again.',
197 + micLevel: 0,
198 + );
199 + ref.read(voiceFeedbackProvider).playDisconnected();
200 + return;
201 + }
202
173 - final stt = ref.read(sttProvider.notifier);
203 await stt.startListening(
204 onResult: (words) {
205 if (!_active || !ref.mounted) return;
@@ -202,6 +231,7 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
231 /// Stop listening and send the captured transcript to the LLM.
232 Future<void> stopListeningAndSend() async {
233 if (!_active || !ref.mounted) return;
234 + _listenAttempt++;
235
236 final stt = ref.read(sttProvider.notifier);
237 await stt.stopListening();
@@ -255,9 +285,21 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
285 /// End the entire voice session and reset everything.
286 Future<void> endSession() async {
287 _active = false;
288 + _listenAttempt++;
289 _isSendingTranscript = false;
290 _generatingFired = false;
291
292 + // Cancel speech recognition before releasing the microphone service so
293 + // the recognizer never continues using an already-stopped FGS.
294 + try {
295 + if (ref.mounted) {
296 + final stt = ref.read(sttProvider.notifier);
297 + await stt.cancelListening();
298 + }
299 + } catch (e) {
300 + Log.error('Voice mode STT cancel error: $e');
301 + }
302 +
303 // Always release the mic FGS, even if listening never started cleanly.
304 try {
305 await ref.read(chatBackgroundServiceProvider).stopMic();
@@ -269,16 +311,6 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
311 ref.read(voiceFeedbackProvider).playDisconnected();
312 }
313
272 - // Stop STT if still listening.
273 - try {
274 - if (ref.mounted) {
275 - final stt = ref.read(sttProvider.notifier);
276 - await stt.cancelListening();
277 - }
278 - } catch (e) {
279 - Log.error('Voice mode STT cancel error: $e');
280 - }
281 -
314 // Stop TTS if still speaking.
315 try {
316 if (ref.mounted) {
@@ -417,6 +449,10 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
449 );
450 return false;
451 }
452 +
453 + bool _isCurrentListenAttempt(int attempt) {
454 + return _active && ref.mounted && attempt == _listenAttempt;
455 + }
456 }
457
458 String _mapSttError(String error) {
test/features/voice_mode/voice_mode_model_guard_test.dart
+171 -1
@@ -1,5 +1,7 @@
1 import 'package:flutter_riverpod/flutter_riverpod.dart';
2 import 'package:flutter_test/flutter_test.dart';
3 +import 'package:localmind/core/providers/chat_background_service_provider.dart';
4 +import 'package:localmind/core/services/chat_background_service.dart';
5 import 'package:localmind/features/chat/providers/chat_notifier.dart';
6 import 'package:localmind/features/chat/providers/model_selection_providers.dart';
7 import 'package:localmind/core/models/enums.dart';
@@ -9,6 +11,7 @@ import 'package:localmind/features/servers/providers/server_providers.dart';
11 import 'package:localmind/features/stt/providers/stt_providers.dart';
12 import 'package:localmind/features/tts/providers/tts_providers.dart';
13 import 'package:localmind/features/voice_mode/providers/voice_mode_provider.dart';
14 +import 'package:localmind/services/voice_feedback_service.dart';
15
16 void main() {
17 TestWidgetsFlutterBinding.ensureInitialized();
@@ -39,6 +42,116 @@ void main() {
42 await notifier.startListening();
43 expect(stt.startCount, 0);
44 });
45 +
46 + test(
47 + 'voice mode initializes STT before starting the microphone service',
48 + () async {
49 + final events = <String>[];
50 + final stt = _RecordingSttNotifier(events: events);
51 + final background = _RecordingChatBackgroundService(events: events);
52 + final container = _readyVoiceContainer(stt, background);
53 + addTearDown(container.dispose);
54 +
55 + container.read(voiceModeProvider.notifier).startSession();
56 + await _flushAsyncWork();
57 +
58 + expect(events, ['initSpeech', 'startMic', 'startListening']);
59 + },
60 + );
61 +
62 + test(
63 + 'voice mode does not start the microphone service when permission is denied',
64 + () async {
65 + final events = <String>[];
66 + final stt = _RecordingSttNotifier(events: events, initAvailable: false);
67 + final background = _RecordingChatBackgroundService(events: events);
68 + final container = _readyVoiceContainer(stt, background);
69 + addTearDown(container.dispose);
70 +
71 + container.read(voiceModeProvider.notifier).startSession();
72 + await _flushAsyncWork();
73 +
74 + expect(events, ['initSpeech']);
75 + expect(
76 + container.read(voiceModeProvider).error,
77 + 'Microphone permission is required for voice mode.',
78 + );
79 + },
80 + );
81 +
82 + test(
83 + 'voice mode cancels STT before stopping the microphone service',
84 + () async {
85 + final events = <String>[];
86 + final stt = _RecordingSttNotifier(events: events);
87 + final background = _RecordingChatBackgroundService(events: events);
88 + final container = _readyVoiceContainer(stt, background);
89 + addTearDown(container.dispose);
90 +
91 + final notifier = container.read(voiceModeProvider.notifier);
92 + notifier.startSession();
93 + await _flushAsyncWork();
94 + events.clear();
95 +
96 + await notifier.endSession();
97 +
98 + expect(events, ['cancelListening', 'stopMic']);
99 + },
100 + );
101 +
102 + test('voice mode reports a microphone service start failure', () async {
103 + final events = <String>[];
104 + final stt = _RecordingSttNotifier(events: events);
105 + final background = _RecordingChatBackgroundService(
106 + events: events,
107 + startResult: false,
108 + );
109 + final container = _readyVoiceContainer(stt, background);
110 + addTearDown(container.dispose);
111 +
112 + container.read(voiceModeProvider.notifier).startSession();
113 + await _flushAsyncWork();
114 +
115 + expect(events, ['initSpeech', 'startMic']);
116 + expect(container.read(voiceModeProvider).phase, VoiceModePhase.error);
117 + });
118 +}
119 +
120 +ProviderContainer _readyVoiceContainer(
121 + _RecordingSttNotifier stt,
122 + _RecordingChatBackgroundService background,
123 +) {
124 + return ProviderContainer(
125 + overrides: [
126 + sttProvider.overrideWith(() => stt),
127 + chatBackgroundServiceProvider.overrideWithValue(background),
128 + activeChatTargetProvider.overrideWithValue(
129 + ActiveChatTarget(
130 + server: Server(
131 + id: 'remote',
132 + name: 'Remote',
133 + type: ServerType.ollama,
134 + host: '127.0.0.1',
135 + port: 11434,
136 + createdAt: DateTime.utc(2026, 8, 13),
137 + lastConnectedAt: DateTime.utc(2026, 8, 13),
138 + status: ConnectionStatus.connected,
139 + ),
140 + selectedModel: null,
141 + effectiveModelId: 'default',
142 + modelLabel: 'Default model',
143 + ),
144 + ),
145 + ttsProvider.overrideWith(_StubTtsNotifier.new),
146 + chatProvider.overrideWith(_StubChatNotifier.new),
147 + voiceFeedbackProvider.overrideWithValue(_NoopVoiceFeedbackService()),
148 + ],
149 + );
150 +}
151 +
152 +Future<void> _flushAsyncWork() async {
153 + await Future<void>.delayed(Duration.zero);
154 + await Future<void>.delayed(Duration.zero);
155 }
156
157 class _ConnectedServerNotifier extends ActiveServerNotifier {
@@ -61,10 +174,20 @@ class _EmptyEngineNotifier extends OnDeviceEngineNotifier {
174 }
175
176 class _RecordingSttNotifier extends SttNotifier {
177 + _RecordingSttNotifier({this.events = const [], this.initAvailable = true});
178 +
179 + final List<String> events;
180 + final bool initAvailable;
181 int startCount = 0;
182
183 @override
67 - SttState build() => const SttState(isAvailable: true);
184 + SttState build() => SttState(isAvailable: initAvailable);
185 +
186 + @override
187 + Future<bool> initSpeech() async {
188 + events.add('initSpeech');
189 + return initAvailable;
190 + }
191
192 @override
193 Future<void> startListening({
@@ -72,8 +195,55 @@ class _RecordingSttNotifier extends SttNotifier {
195 void Function(String)? onFinal,
196 SoundLevelChange? onSoundLevelChange,
197 }) async {
198 + events.add('startListening');
199 startCount++;
200 }
201 +
202 + @override
203 + Future<void> cancelListening() async {
204 + events.add('cancelListening');
205 + }
206 +}
207 +
208 +class _RecordingChatBackgroundService extends ChatBackgroundService {
209 + _RecordingChatBackgroundService({
210 + required this.events,
211 + this.startResult = true,
212 + });
213 +
214 + final List<String> events;
215 + final bool startResult;
216 +
217 + @override
218 + Future<bool> startMic() async {
219 + events.add('startMic');
220 + return startResult;
221 + }
222 +
223 + @override
224 + Future<void> stopMic() async {
225 + events.add('stopMic');
226 + }
227 +}
228 +
229 +class _NoopVoiceFeedbackService implements VoiceFeedbackService {
230 + @override
231 + Future<void> dispose() async {}
232 +
233 + @override
234 + Future<void> playConnected() async {}
235 +
236 + @override
237 + Future<void> playDisconnected() async {}
238 +
239 + @override
240 + Future<void> playGenerating() async {}
241 +
242 + @override
243 + Future<void> playListening() async {}
244 +
245 + @override
246 + Future<void> preload() async {}
247 }
248
249 class _StubTtsNotifier extends TtsNotifier {