Flutter permission_handler: Request and Check Permissions

Coding Liquids tutorial cover featuring Sagnik Bhattacharya for checking and requesting app permissions.
Coding Liquids tutorial cover featuring Sagnik Bhattacharya for checking and requesting app permissions.

A camera button should not crash, stall, or repeatedly nag someone who has already refused access. In this tutorial, you will build a permission gate that checks the current camera status, requests access at the point of use, and presents a settings route when the operating system will no longer show a prompt.

Follow me on Instagram@sagnikteaches

The implementation distinguishes granted, denied, permanently denied, and restricted states. It also covers requesting camera and microphone access together, Android manifest declarations, iOS usage strings, and the Podfile macros required by permission_handler.

Connect on LinkedInSagnik Bhattacharya

Add the package with flutter pub add permission_handler, then rebuild the native application rather than relying on hot reload. Permission behaviour must be checked on a device or emulator with resettable app permissions; a desktop or web run does not exercise the same Android and iOS flows.

Subscribe on YouTube@codingliquids
The Complete Flutter Guide course thumbnail

The Complete Flutter Guide: Build Android, iOS and Web apps

Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.

Enrol now

Declare camera access before writing the Flutter gate

permission_handler exposes a common Dart API, but it cannot add native entitlements or usage descriptions for you. On Android, place the camera permission directly inside the root <manifest> element of android/app/src/main/AndroidManifest.xml:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.CAMERA" />

    <application
        android:label="permission_example"
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher">
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:launchMode="singleTop"
            android:theme="@style/LaunchTheme"
            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
            android:hardwareAccelerated="true"
            android:windowSoftInputMode="adjustResize">
            <meta-data
                android:name="io.flutter.embedding.android.NormalTheme"
                android:resource="@style/NormalTheme" />
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        <meta-data
            android:name="flutterEmbedding"
            android:value="2" />
    </application>
</manifest>

Declaring CAMERA does not grant it. Android still presents a runtime prompt when request() is called. If recording video with sound is part of the same feature, also declare android.permission.RECORD_AUDIO; do not request microphone access merely because a camera preview is visible.

Give iOS a precise reason and enable its native handler

iOS terminates an app that attempts to access protected data without the corresponding usage-description key. Add a sentence to ios/Runner/Info.plist that describes the feature rather than using a vague phrase such as “Camera required”:

<key>NSCameraUsageDescription</key>
<string>Take a profile photograph to display on your account.</string>

The permission_handler package also uses preprocessor macros to include selected iOS permission implementations. In ios/Podfile, retain the existing Flutter post-install call and add PERMISSION_CAMERA=1 to each build configuration:

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)',
        'PERMISSION_CAMERA=1',
      ]
    end
  end
end

After changing the Podfile, run flutter clean, fetch packages, and rebuild the iOS app. If the feature will request Permission.microphone too, add NSMicrophoneUsageDescription and PERMISSION_MICROPHONE=1. A macro without a matching usage string can still leave the native configuration invalid.

Check Permission.camera.status before prompting

A status check is useful when deciding which interface to display. It does not show the operating-system prompt, so the app can reveal a short explanation before asking. The following complete app checks on launch but requests only after the user presses the button:

import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';

void main() {
  runApp(const MaterialApp(home: CameraPermissionPage()));
}

class CameraPermissionPage extends StatefulWidget {
  const CameraPermissionPage({super.key});

  @override
  State<CameraPermissionPage> createState() =>
      _CameraPermissionPageState();
}

class _CameraPermissionPageState extends State<CameraPermissionPage>
    with WidgetsBindingObserver {
  PermissionStatus? _status;
  bool _checking = false;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
    _refreshStatus();
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.resumed) {
      _refreshStatus();
    }
  }

  Future<void> _refreshStatus() async {
    setState(() => _checking = true);
    final status = await Permission.camera.status;

    if (!mounted) return;
    setState(() {
      _status = status;
      _checking = false;
    });
  }

  Future<void> _requestCamera() async {
    setState(() => _checking = true);
    final status = await Permission.camera.request();

    if (!mounted) return;
    setState(() {
      _status = status;
      _checking = false;
    });
  }

  Future<void> _openSettings() async {
    final opened = await openAppSettings();

    if (!opened && mounted) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Could not open app settings.')),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    final status = _status;

    return Scaffold(
      appBar: AppBar(title: const Text('Camera access')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Center(
          child: _checking || status == null
              ? const CircularProgressIndicator()
              : PermissionPanel(
                  status: status,
                  onRequest: _requestCamera,
                  onOpenSettings: _openSettings,
                ),
        ),
      ),
    );
  }
}

class PermissionPanel extends StatelessWidget {
  const PermissionPanel({
    required this.status,
    required this.onRequest,
    required this.onOpenSettings,
    super.key,
  });

  final PermissionStatus status;
  final VoidCallback onRequest;
  final VoidCallback onOpenSettings;

  @override
  Widget build(BuildContext context) {
    if (status.isGranted) {
      return const Text('Camera access is ready.');
    }

    if (status.isPermanentlyDenied) {
      return Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          const Text(
            'Camera access is blocked. Enable it in the app settings.',
            textAlign: TextAlign.center,
          ),
          const SizedBox(height: 12),
          FilledButton(
            onPressed: onOpenSettings,
            child: const Text('Open settings'),
          ),
        ],
      );
    }

    if (status.isRestricted) {
      return const Text(
        'Camera access is restricted by this device or account.',
        textAlign: TextAlign.center,
      );
    }

    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        const Text(
          'Allow camera access to take your profile photograph.',
          textAlign: TextAlign.center,
        ),
        const SizedBox(height: 12),
        FilledButton(
          onPressed: onRequest,
          child: const Text('Allow camera'),
        ),
      ],
    );
  }
}

The asynchronous methods check mounted because the page may be removed while the native prompt or settings app is visible. The loading flag also prevents several overlapping requests from rapid taps.

The Complete Flutter Guide course thumbnail

The Complete Flutter Guide: Build Android, iOS and Web apps

Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.

Enrol now

Treat PermissionStatus as a state machine

PermissionStatus communicates more than a yes-or-no result. Each relevant state needs a deliberate product response:

  • granted: the protected feature may proceed. Check again when the feature is opened later because access can be revoked in system settings.
  • denied: access is unavailable now, but a request may still be possible. Keep the feature usable enough to explain its value and offer another appropriate action.
  • permanentlyDenied: the operating system will not display another permission prompt for this app. Present a settings button instead of calling request() repeatedly.
  • restricted: policy controls prevent the user from granting access, commonly through iOS parental or device-management restrictions. Opening settings may not let the user change it, so explain the limitation without promising a fix.

The package also defines statuses for permission types with platform-specific behaviour, including limited and provisional access. Do not collapse every unknown status into “denied” if you later adapt the gate for photos or notifications.

Handle permanently denied access without trapping the user

A permanent denial is not an exceptional crash condition. Keep the rest of the app available, explain exactly where the permission is used, and make opening settings an explicit user choice. openAppSettings() returns a Future<bool>; the Boolean reports whether the package could launch the settings page, not whether the user enabled anything.

The lifecycle observer in the example re-runs Permission.camera.status when the app resumes. That matters because control returns to Flutter after the user visits settings, and the original status object does not update itself. Never assume access was granted simply because settings opened successfully.

A permanently denied result can differ across operating-system versions and user journeys. Branch on the result returned by request() rather than maintaining your own denial counter.

Request camera and microphone together for video capture

When one user action genuinely requires several capabilities, permission_handler accepts a list and returns a map keyed by each requested permission. The permissions may produce different outcomes, so checking only one map entry creates a partial-authorisation bug:

import 'package:permission_handler/permission_handler.dart';

Future<bool> requestVideoCapturePermissions() async {
  final statuses = await [
    Permission.camera,
    Permission.microphone,
  ].request();

  final cameraGranted =
      statuses[Permission.camera] == PermissionStatus.granted;
  final microphoneGranted =
      statuses[Permission.microphone] == PermissionStatus.granted;

  return cameraGranted && microphoneGranted;
}

Before using this function, declare both permissions on Android and configure both iOS usage strings and Podfile macros. If silent video is a valid fallback, evaluate the entries separately and let the user continue with camera access alone. Avoid requesting unrelated permissions in one launch-time bundle: people make better decisions when the request follows a clear feature action.

Separate permission approval from resource availability

A granted permission does not prove that a camera exists, is functioning, or is free to use. The camera package can still report no available cameras, an initialisation failure, or a device-level error. Permission handling should therefore guard entry to the feature, while camera discovery and capture code retain their own error paths.

The reverse distinction matters as well: declaring camera hardware as required can exclude devices from an Android store listing. If the wider app remains useful without a camera, describe hardware as optional in the manifest and disable the capture control when discovery returns no cameras.

<uses-feature
    android:name="android.hardware.camera.any"
    android:required="false" />

This declaration concerns hardware filtering; it does not replace android.permission.CAMERA.

Avoid the permission loops that frustrate users

Several common mistakes make a technically functional request flow unreliable:

  • Requesting every permission during startup, before the person has seen the feature or its explanation.
  • Calling request() again after isPermanentlyDenied instead of offering openAppSettings().
  • Adding Dart code but omitting AndroidManifest.xml, Info.plist, or the relevant iOS Podfile macro.
  • Continuing into camera code for any status other than granted.
  • Assuming a previously granted status remains valid for the lifetime of the installation.
  • Using one generic explanation for camera, microphone, photos, and location access.
  • Ignoring the Boolean returned by openAppSettings() or treating it as proof of authorisation.

Keep permission requests close to the action they enable. That makes the operating-system prompt understandable and gives a denial a clear, limited consequence.

Verify every branch, not only the first prompt

Test on both supported platforms because simulator behaviour and status transitions are not identical. Start with a clean installation, confirm that the explanatory screen appears before the system prompt, then grant access and verify that the camera feature opens. Reset the app permission, deny it, and confirm that the app remains stable.

Next, reach the permanently denied path where the platform supports it. Check that the button opens the app’s settings, change the permission there, return to the app, and confirm that the resumed lifecycle refreshes the screen. On iOS, also test a restricted device or managed test configuration when that audience is relevant.

Finally, inspect a release build. Native permission configuration is compiled into the application, so a debug session that was not fully rebuilt can conceal Podfile or manifest mistakes. The expected result is a feature that proceeds only for granted, offers settings for permanentlyDenied, explains restricted, and leaves a recoverable route after an ordinary denial.

Further reads

Keep going with these related tutorials from this site.