Flutter Voice AI: Fix Mic SecurityException on Android 14+
Your voice feature works in debug. Then a tester taps the mic, swipes to WhatsApp, and the app dies. Logcat shows something close to this (wording varies by OS version and OEM):
java.lang.SecurityException: Starting FGS with type microphone
callerApp=ProcessRecord{...} targetSDK=36 requires permissions:
all of the following: [android.permission.FOREGROUND_SERVICE_MICROPHONE]
any of the following: [android.permission.RECORD_AUDIO]
So you add FOREGROUND_SERVICE_MICROPHONE to the manifest, rebuild — and it crashes in exactly the same place. That is the part most guides skip.
The permission is not what's missing
When your app targets Android 14 (API 34) or higher, the system runs three checks the moment you create a microphone foreground service:
- Is
FOREGROUND_SERVICE_MICROPHONEdeclared in the manifest? - Is
android:foregroundServiceType="microphone"on the<service>tag? - Does your app hold
RECORD_AUDIOright now, at this instant?
Check 3 is the one that bites. RECORD_AUDIO is a while-in-use permission — your app only holds it while it is in the foreground. Once backgrounded, it effectively no longer has the permission, so code that tries to create a microphone foreground service there hits a system that sees no RECORD_AUDIO and throws SecurityException.
The Android docs are blunt: you cannot create a microphone foreground service while your app is in the background, and you cannot launch one from a BOOT_COMPLETED receiver.
This is also why the bug looks different on older devices. Before Android 14, the system let you create the service and simply fed it silence — the classic "mic is open but nobody is there" bug. On Android 14 and up you get a hard crash instead.
One distinction gets conflated constantly in Stack Overflow answers: this SecurityException is not ForegroundServiceStartNotAllowedException. That one is the general Android 12+ restriction on background starts. You are hitting the stricter while-in-use rule layered on top.
So the fix is ordering, not manifest entries
The microphone foreground service must already be running before the app leaves the foreground. Start it on the mic tap — while your Activity is still resident and RECORD_AUDIO is genuinely held — and it survives the move to background. Start it after, and you crash. The sequence that works:
mic tap → request RECORD_AUDIO → start the microphone FGS → open the WidgetChat voice session
Every step happens while your UI is on screen. Here is how to build it.
Step 1 — declare the type in AndroidManifest.xml
In android/app/src/main/AndroidManifest.xml, above the <application> tag:
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
And inside <application>:
<service
android:name="com.pravera.flutter_foreground_task.service.ForegroundService"
android:foregroundServiceType="microphone"
android:exported="false"
tools:replace="android:foregroundServiceType" />
Two notes that save an afternoon. Do not rename the service — flutter_foreground_task looks for that exact class name. And the plugin ships its own <service> declaration with dataSync|remoteMessaging, so the manifest merger will reject your microphone value unless you override it; that is what tools:replace is for. It needs xmlns:tools="http://schemas.android.com/tools" on the root <manifest> tag.
Step 2 — start the service, correctly reading the result
Using flutter_foreground_task 11.0.1 (11.0.0 raised the floor to Flutter 3.44 / Dart 3.12) and permission_handler: ^13.0.1:
import 'package:flutter/widgets.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import 'package:permission_handler/permission_handler.dart';
@pragma('vm:entry-point')
void startVoiceCallback() {
FlutterForegroundTask.setTaskHandler(VoiceCallTaskHandler());
}
class VoiceService {
static const int serviceId = 4021;
static bool _initialized = false;
static void _initOnce() {
if (_initialized) return;
FlutterForegroundTask.init(
androidNotificationOptions: AndroidNotificationOptions(
channelId: 'widgetchat_voice',
channelName: 'Voice call',
channelDescription: 'Shown while a voice call is active.',
onlyAlertOnce: true,
),
iosNotificationOptions: const IOSNotificationOptions(
showNotification: false,
playSound: false,
),
foregroundTaskOptions: ForegroundTaskOptions(
eventAction: ForegroundTaskEventAction.repeat(30000),
allowWakeLock: true,
autoRunOnBoot: false, // a mic FGS may not start from BOOT_COMPLETED
),
);
_initialized = true;
}
/// Call this from the mic button's onTap — never from a background callback.
static Future<bool> start() async {
_initOnce();
if (!await Permission.microphone.request().isGranted) return false;
await Permission.notification.request(); // Android 13+, for the FGS notification
if (await FlutterForegroundTask.isRunningService) return true;
final ServiceRequestResult result =
await FlutterForegroundTask.startService(
serviceId: serviceId,
serviceTypes: const [ForegroundServiceTypes.microphone],
notificationTitle: 'Voice call in progress',
notificationText: 'Tap to return to the conversation',
callback: startVoiceCallback,
);
if (result is ServiceRequestFailure) {
debugPrint('Mic foreground service failed: ${result.error}');
return false;
}
return true;
}
static Future<void> stop() => FlutterForegroundTask.stopService();
}
Watch the return value. startService() returns a sealed ServiceRequestResult, implemented by ServiceRequestSuccess and ServiceRequestFailure. There is no .success boolean — snippets doing return result.success; will not compile against 11.x. Type-check it as above, or use exhaustive pattern matching:
return switch (result) {
ServiceRequestSuccess() => true,
ServiceRequestFailure(:final error) => _logAndFail(error),
};
ServiceRequestFailure carries a single error of type Object — log it, because that is where the underlying SecurityException surfaces if anything is still misconfigured.
Step 3 — a task handler that keeps the call alive
class VoiceCallTaskHandler extends TaskHandler {
DateTime? _startedAt;
@override
Future<void> onStart(DateTime timestamp, TaskStarter starter) async {
_startedAt = timestamp;
}
@override
void onRepeatEvent(DateTime timestamp) {
final elapsed = timestamp.difference(_startedAt ?? timestamp);
FlutterForegroundTask.updateService(
notificationTitle: 'Voice call in progress',
notificationText: '${elapsed.inMinutes} min',
);
}
@override
Future<void> onDestroy(DateTime timestamp, bool isTimeout) async {}
@override
void onNotificationPressed() {
FlutterForegroundTask.launchApp('/');
}
}
Step 4 — end the session yourself with AppLifecycleState
The service keeps the mic alive when the user backgrounds the app mid-call — what you want for a quick app switch. What you do not want is a session quietly burning through your voice-minute pool because someone walked away an hour ago. Decide that policy in Dart instead of letting the OS decide it for you:
class _VoiceButtonState extends State<VoiceButton>
with WidgetsBindingObserver {
bool _inCall = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (!_inCall) return;
if (state == AppLifecycleState.detached) {
_endCall(); // app is going away — release the mic now
}
// paused / hidden are fine: the FGS is what keeps the call running
}
Future<void> _onMicTap() async {
if (!await VoiceService.start()) return; // FGS first, while foregrounded
setState(() => _inCall = true);
// ...then open the WidgetChat voice session from your widget's mic entry point
}
Future<void> _endCall() async {
setState(() => _inCall = false);
await VoiceService.stop();
}
}
Rule of thumb: paused/hidden means keep going — that is the whole point of the service — while detached means tear down immediately.
FlutterFlow: same fix, no android/ folder
FlutterFlow developers hit this hardest, because the usual advice — "edit your AndroidManifest.xml" — assumes a folder you cannot see. You do not need to export the project; FlutterFlow exposes manifest editing directly:
Custom Code → Configuration Files → AndroidManifest.xml
From there you get three insertion points — Activity Tags, Application Tags, and App Component Tags — plus a Manual Edit Mode behind the lock button for full-file editing. Use App Component Tags to insert the <service> block from Step 1, since that goes inside <application>. The <uses-permission> lines live at manifest level, so those need Manual Edit Mode (or the microphone toggle in your app's permission settings for RECORD_AUDIO specifically).
Then paste the Dart from Steps 2–3 into a Custom Action called startMicService, add flutter_foreground_task and permission_handler as pub dependencies on it, and call it from the mic button's On Tap, immediately before the action that opens the voice session. Ordering in the action chain is the entire fix — if startMicService runs after the session opens, or from a background trigger, you are back to the crash.
The text side of the same widget
Voice and text share one conversation, and the text path is a plain SSE stream you can call from any HTTP client — no proprietary SDK:
final request = http.Request(
'POST',
Uri.parse('https://api.widgetchat.app/v1/chat/stream'),
)
..headers['Content-Type'] = 'application/json'
..headers['Accept'] = 'text/event-stream'
..body = jsonEncode(payload); // fields + auth per your project's dashboard setup
final response = await http.Client().send(request);
await for (final line in response.stream
.transform(utf8.decoder)
.transform(const LineSplitter())) {
if (line.startsWith('data:')) {
setState(() => _reply += line.substring(5).trim()); // token-by-token
}
}
Three things that will still bite you
Don't start it from boot. Apps targeting Android 14+ may not launch a microphone foreground service from a BOOT_COMPLETED receiver (Android 15 added the same rule for camera). Keep autoRunOnBoot: false.
Battery-optimization exemption is a real escape hatch, but a bad default. Users who disable battery optimization are exempt from background-start restrictions — as are high-priority FCM messages, exact alarms, and a few other documented cases. Requesting that exemption to paper over a start-ordering bug is how you get flagged in review. Fix the ordering.
Android 16 tightened what the service can do. On devices running Android 16 or higher — regardless of target SDK — background jobs started from a foreground service must obey their normal runtime quotas, including anything scheduled via WorkManager. Do the audio work in the service; don't fan out jobs from it.
And the deadline forcing this on everyone: as of August 31, 2026, new apps and updates on Google Play must target Android 16 (API 36), with extensions available through November 1, 2026. If your voice feature has been relying on pre-14 leniency, this is the release where it stops working.
Try WidgetChat free
WidgetChat drops a real-time AI support assistant into your Flutter or FlutterFlow app — tap the mic and users get a live speech-to-speech call with barge-in, live captions, and product cards on screen while it talks. Same widget, same conversation, same dashboard as text chat, across iOS, Android, and web. Provider API keys stay server-side, and you can tune voice name, max session length, and captions default in the dashboard's Voice section.
Get the mic ordering right and the rest is a drop-in. Try WidgetChat free.



Comments
Comments are coming soon. We'd love to hear your thoughts!