@tduncan / Cortana / commits / 31f6b8c

fix(android): keep voice assistant mic alive in background on Android 14+

Adds a microphone-typed foreground service so SpeechRecognizer capture survives when the app is backgrounded or invoked from the lock screen via the system ASSIST intent. Previously ChatForegroundService only declared foregroundServiceType="specialUse", which Android 14+ denies mic access to the moment the app leaves the foreground — breaking the in-app voice mode and the system-assistant integration on devices like the Galaxy S23 Ultra. - Declare FOREGROUND_SERVICE_MICROPHONE permission. - Merge `microphone` into ChatForegroundService's foregroundServiceType alongside the existing `specialUse` so inference flows are unchanged. - Add `startForegroundMic` / `stopForegroundMic` MethodChannel methods. - Extend ChatForegroundService with a typed startService overload that switches the startForeground flag between SPECIAL_USE and MICROPHONE based on an intent extra; notification text reads "Listening…" or "Generating response…" accordingly. - Wire ChatBackgroundService.startMic()/stopMic() from voice_mode_provider startListening(), stopListeningAndSend(), and endSession(), so the mic FGS is acquired before SpeechRecognizer consumes the mic and released immediately when listening ends. Manifest, permission, and Kotlin service signatures touched — please call out in the PR description per AGENTS.md.

abdulmominsakib committed Sep 6, 2026 at 06:17 UTC 31f6b8c10819851a6a97d2a41d1b398af96ca7ce
5 files changed +85 -7
android/app/src/main/AndroidManifest.xml
+3 -2
@@ -8,6 +8,7 @@
8 <uses-permission android:name="android.permission.WAKE_LOCK" />
9 <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
10 <uses-permission android:name="android.permission.RECORD_AUDIO" />
11 + <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
12 <uses-permission android:name="android.permission.READ_CALENDAR" />
13 <uses-permission android:name="android.permission.WRITE_CALENDAR" />
14 <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
@@ -81,10 +82,10 @@
82 android:name=".ChatForegroundService"
83 android:enabled="true"
84 android:exported="false"
84 - android:foregroundServiceType="specialUse">
85 + android:foregroundServiceType="specialUse|microphone">
86 <property
87 android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
87 - android:value="Used to keep the local AI model inference process alive when the user switches apps, preventing the OS from terminating the heavy background computation." />
88 + android:value="Used to keep the local AI model inference process alive when the user switches apps, and to keep microphone capture active for the assistant voice session when invoked from the lock screen or another app." />
89 </service>
90
91 <service
android/app/src/main/kotlin/pro/momin/localmind/ChatForegroundService.kt
+24 -5
@@ -12,9 +12,17 @@ class ChatForegroundService : Service() {
12 companion object {
13 private const val CHANNEL_ID = "chat_generation_channel"
14 private const val NOTIFICATION_ID = 1001
15 -
15 + private const val EXTRA_TYPE = "type"
16 + private const val TYPE_SPECIAL_USE = "specialUse"
17 + private const val TYPE_MICROPHONE = "microphone"
18 +
19 fun startService(context: Context) {
20 + startService(context, TYPE_SPECIAL_USE)
21 + }
22 +
23 + fun startService(context: Context, type: String) {
24 val intent = Intent(context, ChatForegroundService::class.java)
25 + .putExtra(EXTRA_TYPE, type)
26 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
27 context.startForegroundService(intent)
28 } else {
@@ -30,16 +38,23 @@ class ChatForegroundService : Service() {
38
39 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
40 createNotificationChannel()
33 -
41 +
42 val notificationIntent = Intent(this, MainActivity::class.java)
43 val pendingIntent = PendingIntent.getActivity(
44 this, 0, notificationIntent,
45 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
46 )
47
48 + val type = intent?.getStringExtra(EXTRA_TYPE) ?: TYPE_SPECIAL_USE
49 + val notificationText = if (type == TYPE_MICROPHONE) {
50 + "Listening…"
51 + } else {
52 + "Generating response…"
53 + }
54 +
55 val notification = NotificationCompat.Builder(this, CHANNEL_ID)
56 .setContentTitle("LocalMind")
42 - .setContentText("Generating response…")
57 + .setContentText(notificationText)
58 .setSmallIcon(android.R.drawable.stat_notify_chat)
59 .setOngoing(true)
60 .setContentIntent(pendingIntent)
@@ -47,11 +62,15 @@ class ChatForegroundService : Service() {
62 .build()
63
64 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
50 - startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
65 + val fgType = when (type) {
66 + TYPE_MICROPHONE -> ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
67 + else -> ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
68 + }
69 + startForeground(NOTIFICATION_ID, notification, fgType)
70 } else {
71 startForeground(NOTIFICATION_ID, notification)
72 }
54 -
73 +
74 return START_STICKY
75 }
76
android/app/src/main/kotlin/pro/momin/localmind/MainActivity.kt
+8
@@ -48,6 +48,14 @@ class MainActivity : AudioServiceActivity() {
48 ChatForegroundService.stopService(this)
49 result.success(null)
50 }
51 + "startForegroundMic" -> {
52 + ChatForegroundService.startService(this, "microphone")
53 + result.success(null)
54 + }
55 + "stopForegroundMic" -> {
56 + ChatForegroundService.stopService(this)
57 + result.success(null)
58 + }
59 else -> {
60 result.notImplemented()
61 }
lib/core/services/chat_background_service.dart
+33
@@ -6,6 +6,7 @@ import '../logger/app_logger.dart';
6 class ChatBackgroundService {
7 static const _channel = MethodChannel('localmind/chat_background');
8 bool _isActive = false;
9 + bool _isMicActive = false;
10
11 Future<void> start() async {
12 if (_isActive) return;
@@ -34,4 +35,36 @@ class ChatBackgroundService {
35 Log.error('Failed to stop background chat service: $e');
36 }
37 }
38 +
39 + /// Start a microphone-typed foreground service so that voice capture
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;
45 + try {
46 + Log.info('Starting background mic service');
47 + if (Platform.isAndroid) {
48 + await _channel.invokeMethod('startForegroundMic');
49 + }
50 + _isMicActive = true;
51 + } catch (e) {
52 + Log.error('Failed to start background mic service: $e');
53 + }
54 + }
55 +
56 + /// Stop the microphone-typed foreground service. Safe to call multiple
57 + /// times and from `endSession()`.
58 + Future<void> stopMic() async {
59 + if (!_isMicActive) return;
60 + try {
61 + Log.info('Stopping background mic service');
62 + if (Platform.isAndroid) {
63 + await _channel.invokeMethod('stopForegroundMic');
64 + }
65 + _isMicActive = false;
66 + } catch (e) {
67 + Log.error('Failed to stop background mic service: $e');
68 + }
69 + }
70 }
lib/features/voice_mode/providers/voice_mode_provider.dart
+17
@@ -3,6 +3,7 @@ import 'dart:async';
3 import 'package:flutter_riverpod/flutter_riverpod.dart';
4
5 import '../../../core/logger/app_logger.dart';
6 +import '../../../core/providers/chat_background_service_provider.dart';
7 import '../../../services/voice_feedback_service.dart';
8 import '../../chat/providers/chat_providers.dart';
9 import '../../stt/providers/stt_providers.dart';
@@ -164,6 +165,11 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
165
166 ref.read(voiceFeedbackProvider).playListening();
167
168 + // Hold a microphone-typed foreground service so that voice capture
169 + // 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();
172 +
173 final stt = ref.read(sttProvider.notifier);
174 await stt.startListening(
175 onResult: (words) {
@@ -199,6 +205,10 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
205
206 final stt = ref.read(sttProvider.notifier);
207 await stt.stopListening();
208 + // Release the microphone-typed foreground service as soon as capture
209 + // is done. The transcript is in hand and the LLM will be polled by
210 + // the inference FGS path, not the mic one.
211 + await ref.read(chatBackgroundServiceProvider).stopMic();
212 if (!_active || !ref.mounted) return;
213
214 final transcript = state.transcript.trim();
@@ -248,6 +258,13 @@ class VoiceModeNotifier extends Notifier<VoiceModeState> {
258 _isSendingTranscript = false;
259 _generatingFired = false;
260
261 + // Always release the mic FGS, even if listening never started cleanly.
262 + try {
263 + await ref.read(chatBackgroundServiceProvider).stopMic();
264 + } catch (e) {
265 + Log.error('Voice mode mic service stop error: $e');
266 + }
267 +
268 if (ref.mounted) {
269 ref.read(voiceFeedbackProvider).playDisconnected();
270 }