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 {