Fix Flutter permanentlyDenied Mic Permission on iOS
You ship voice chat. On the simulator it works. On TestFlight, the mic button opens an "enable microphone in Settings" sheet, the user goes to Settings, sees the toggle already on, comes back, and gets the same sheet. Permission.microphone.status keeps returning PermissionStatus.permanentlyDenied.
There are exactly two things that produce this, and neither is fixed by adding NSMicrophoneUsageDescription again. Here is what the iOS side of permission_handler actually does, and the gate order that works.
What the plugin really returns on iOS
permission_handler 13.x delegates iOS mic checks to AudioVideoPermissionStrategy, which maps AVCaptureDevice.authorizationStatus(for: .audio) like this:
iOS AVAuthorizationStatus |
Dart PermissionStatus |
|---|---|
notDetermined (never asked) |
denied |
restricted (Screen Time, MDM) |
restricted |
denied (user tapped Don't Allow) |
permanentlyDenied |
authorized |
granted |
Two consequences that break most gates:
denieddoes not mean the user refused. Before the first prompt, iOS isnotDeterminedand the plugin reportsdenied. Code likeif (await Permission.microphone.isDenied) { showGoToSettingsDialog(); }fires on a brand-new install and the user never sees the OS prompt at all. This is the single most common cause of "flutter voice chat mic permission not working".permanentlyDeniedis not Android-only. On iOS there is no second chance:requestAccessForMediaTypereturns instantly once the user has answered, so a refusal is permanent until they change it in Settings.request()also short-circuits, it checks status first and returns without prompting if the status is anything other thandenied.
The real cause of permanentlyDenied while Settings says "on"
If you see permanentlyDenied before the first prompt has ever appeared, it is not a user decision. It is a build configuration problem. The iOS plugin compiles each permission behind a preprocessor macro. When PERMISSION_MICROPHONE is off, AudioVideoPermissionStrategy is compiled as a subclass of UnknownPermissionStrategy, whose behaviour is hard-coded:
- (PermissionStatus)checkPermissionStatus:(PermissionGroup)permission {
return PermissionStatusDenied;
}
- (void)requestPermission:... {
completionHandler(PermissionStatusPermanentlyDenied);
}
So request() returns permanentlyDenied immediately, with no system dialog, on a device where the mic toggle is genuinely on (another framework, a webview or a WebRTC pod, already triggered the real prompt). That is the exact symptom.
How the macro gets turned off depends on how you link the plugin:
CocoaPods. The macro list lives in your Podfile post_install block. Someone pasted the README block and left 'PERMISSION_MICROPHONE=0', or your post_install is missing the block entirely.
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
'$(inherited)',
## dart: PermissionGroup.microphone
'PERMISSION_MICROPHONE=1',
]
end
end
end
After editing: flutter clean && rm -rf ios/Pods ios/Podfile.lock && cd ios && pod install. The macro is baked into the compiled pod, so a warm build will happily keep the old value, which is why this so often looks like "only broken in TestFlight".
Swift Package Manager (Flutter 3.24+). There is no Podfile block. Package.swift decides which permissions to compile in by reading your app's Info.plist: microphone is enabled only if NSMicrophoneUsageDescription is present. Flavored apps are the trap here, if your release build uses Info-prod.plist and the key only exists in Info-dev.plist for that configuration, debug works and the App Store build returns permanentlyDenied. After fixing a key, clear the package cache once (rm -rf ~/Library/Developer/Xcode/DerivedData) so Package.swift is re-evaluated.
Info.plist
One key, in every Info.plist your build configurations point at:
<key>NSMicrophoneUsageDescription</key>
<string>WidgetChat uses your microphone so you can talk to our support assistant.</string>
Do not add NSSpeechRecognitionUsageDescription or request Permission.speech unless you run Apple's on-device recogniser yourself. WidgetChat's live voice chat is speech-to-speech on the server side, so the mic key is the only one you need, and a second prompt just costs you grants.
AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
On Android permanentlyDenied is a real user state (two refusals, or "Don't allow"), so the same gate has to behave differently per platform state, not per platform.
The correct gate order
The rule: never read status to decide whether to ask. Ask, then read the result.
import 'package:permission_handler/permission_handler.dart';
enum MicGateResult { granted, softDenied, sendToSettings, restricted }
class MicGate {
/// Call this at tap time, never on page load or in initState.
static Future<MicGateResult> ensureMic() async {
// request() is safe to call unconditionally: if the OS status is already
// granted/denied-for-good it returns immediately without a prompt.
final status = await Permission.microphone.request();
switch (status) {
case PermissionStatus.granted:
case PermissionStatus.limited:
case PermissionStatus.provisional:
return MicGateResult.granted;
// iOS: user tapped "Don't Allow". Android: "Don't allow" twice.
case PermissionStatus.permanentlyDenied:
return MicGateResult.sendToSettings;
// Screen Time / MDM / parental controls. Settings will NOT help.
case PermissionStatus.restricted:
return MicGateResult.restricted;
// Android only in practice: dismissed the sheet. Just let them retry.
case PermissionStatus.denied:
return MicGateResult.softDenied;
}
}
}
Three things this gets right that the usual snippet does not: it never inspects status before requesting, it separates restricted (offering Settings is a dead end, the toggle is greyed out) from permanentlyDenied, and it treats plain denied as "try again later" rather than a wall.
A drop-in mic button that survives the Settings round-trip
Changing the microphone switch in Settings terminates or suspends your app, so the state you cached is stale when the user returns. Re-check on resume and clear the banner yourself, otherwise the user grants permission and still stares at your error state.
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
class VoiceMicGate extends StatefulWidget {
const VoiceMicGate({super.key, required this.onGranted, required this.child});
/// Start the WidgetChat voice session here (open the widget on its mic entry
/// point). Only called once the OS has actually granted the microphone.
final VoidCallback onGranted;
final Widget child; // your mic button
@override
State<VoiceMicGate> createState() => _VoiceMicGateState();
}
class _VoiceMicGateState extends State<VoiceMicGate>
with WidgetsBindingObserver {
MicGateResult? _blocked;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
// Note: no permission check here. That is the bug we are avoiding.
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed && _blocked != null) {
Permission.microphone.status.then((s) {
if (mounted && s.isGranted) setState(() => _blocked = null);
});
}
}
Future<void> _handleTap() async {
final result = await MicGate.ensureMic();
if (!mounted) return;
if (result == MicGateResult.granted) {
setState(() => _blocked = null);
widget.onGranted();
return;
}
setState(() => _blocked = result);
final messenger = ScaffoldMessenger.of(context);
switch (result) {
case MicGateResult.sendToSettings:
messenger.showSnackBar(SnackBar(
content: const Text('Microphone access is off for this app.'),
action: SnackBarAction(
label: 'Open Settings',
onPressed: openAppSettings, // only ever from permanentlyDenied
),
));
case MicGateResult.restricted:
messenger.showSnackBar(const SnackBar(
content: Text(
'Microphone use is blocked by Screen Time or a device policy.'),
));
case MicGateResult.softDenied:
messenger.showSnackBar(const SnackBar(
content: Text('Tap the mic again to allow voice chat.'),
));
case MicGateResult.granted:
break;
}
}
@override
Widget build(BuildContext context) =>
GestureDetector(onTap: _handleTap, child: widget.child);
}
Wrap whatever opens WidgetChat's voice call in it:
VoiceMicGate(
onGranted: () => openWidgetChatVoice(context), // your existing widget launch
child: const CircleAvatar(radius: 28, child: Icon(Icons.mic)),
)
The FlutterFlow equivalent
FlutterFlow's built-in Request Permissions action is fine, the mistake is where people put it. Attached to On Page Load, it burns the one prompt iOS gives you before the user has any idea why, and a refusal is then permanentlyDenied forever.
- App Settings → Permissions: switch Microphone on and write the usage description. This is what generates
NSMicrophoneUsageDescriptionand the Podfile macro in the exported project. If you have been editing a downloaded Podfile by hand, check it survived the last export. - Put the permission step on the mic button's On Tap, immediately before the action that opens the WidgetChat widget, not on page load.
- For the three-way branch, add a custom action (Custom Code → Actions, with
permission_handleradded under Pub Dependencies):
// Custom Action: ensureMicForVoice
// Returns: 'granted' | 'soft_denied' | 'settings' | 'restricted'
import 'package:permission_handler/permission_handler.dart';
Future<String> ensureMicForVoice() async {
final status = await Permission.microphone.request();
if (status.isGranted || status.isLimited) return 'granted';
if (status.isPermanentlyDenied) return 'settings';
if (status.isRestricted) return 'restricted';
return 'soft_denied';
}
Then an Action Block: call ensureMicForVoice, store it in a local variable, and branch on it. Only the settings branch gets a second custom action calling openAppSettings(). On the FlutterFlow side, remember that Test Mode runs in a browser where the permission comes from the browser prompt, not from iOS, so this branch can only be validated on a real device build.
Quick triage
permanentlyDeniedon a fresh install, before any prompt appeared → macro orInfo.plistkey missing for that build configuration. Not a user problem.deniedon a fresh install → normal. That isnotDetermined. Callrequest().- Works in debug, fails on TestFlight → flavored
Info.plist, stale Pods, or stale DerivedData. - Toggle on in Settings but still blocked after returning → you are not re-checking on resume.
- Greyed-out toggle in Settings →
restricted. Sending the user to Settings is pointless; say so in the copy.
Then let them actually talk
Once the gate is honest, the rest is already built. WidgetChat's live voice chat runs a real-time speech-to-speech call inside the same widget you embed for text chat: it listens, replies out loud in a natural voice, supports barge-in so users can cut it off mid-sentence, shows live captions, and can put product cards on screen while it speaks. Same conversation, same dashboard. Your provider API keys stay server-side rather than shipping in the app bundle, and you configure the voice, max session length and captions default per project in the dashboard's Voice section. Voice minutes come from a monthly pool on your plan. Text chat still streams token by token over SSE from POST https://api.widgetchat.app/v1/chat/stream, so no proprietary SDK is involved on either path, iOS, Android or web.
Try WidgetChat free and put a talking support assistant in your Flutter or FlutterFlow app today.





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