widget_chat is live on pub.dev — drop-in AI chat for Flutter, FlutterFlow, React & Web. Start free →

← Back to Blog
Fix Flutter permanentlyDenied Mic Permission on iOS

Fix Flutter permanentlyDenied Mic Permission on iOS

flutterflutterflowiospermissionsvoice-ai

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:

  1. denied does not mean the user refused. Before the first prompt, iOS is notDetermined and the plugin reports denied. Code like if (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".
  2. permanentlyDenied is not Android-only. On iOS there is no second chance: requestAccessForMediaType returns 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 than denied.

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.

  1. App Settings → Permissions: switch Microphone on and write the usage description. This is what generates NSMicrophoneUsageDescription and the Podfile macro in the exported project. If you have been editing a downloaded Podfile by hand, check it survived the last export.
  2. Put the permission step on the mic button's On Tap, immediately before the action that opens the WidgetChat widget, not on page load.
  3. For the three-way branch, add a custom action (Custom Code → Actions, with permission_handler added 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

  • permanentlyDenied on a fresh install, before any prompt appeared → macro or Info.plist key missing for that build configuration. Not a user problem.
  • denied on a fresh install → normal. That is notDetermined. Call request().
  • 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.

permission_handler on pub.dev, the package whose iOS status mapping causes the permanentlyDenied confusion.

WidgetChat: embeddable AI support chat with live voice for Flutter and FlutterFlow apps.

Author

About the author

Widget Chat is a team of developers and designers passionate about creating the best AI chatbot experience for Flutter, web, and mobile apps.

Comments

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