公開日 2026-08-10

permission_handler でAndroid権限を実践的に扱う(カメラ・ストレージ・Bluetooth)

Flutterで permission_handler を使い、Android のカメラ、ストレージ系、Bluetooth 権限について、要求タイミング、拒否時分岐、設定画面誘導までを最小構成で整理する。

目次

  1. 1. ゴールと非対象
  2. 対象読者
  3. この記事で到達する状態
  4. 非対象
  5. 2. 先に要求タイミングと状態分岐を決める
  6. 3. プロジェクトを作成し、Android 側の宣言を整える
  7. 3-1. Flutter の環境構築がまだなら先に済ませる
  8. 3-2. Flutter プロジェクトを作成する
  9. 3-3. エミュレーターを起動する
  10. 3-4. permission_handler を追加する
  11. 3-5. AndroidManifest.xml に必要権限を追加する
  12. コードのポイント
  13. 3-6. ストレージ系権限は Android 13 以降で考え方が変わる
  14. 4. lib/main.dart に権限ダッシュボードを作る
  15. コードのポイント
  16. 5. 拒否・永続拒否・設定誘導をどう分けるか
  17. 6. カメラ・ストレージ系・Bluetooth で実務上どこが違うか
  18. カメラ
  19. ストレージ系
  20. Bluetooth
  21. 7. まとめ

Flutterでデータをファイルに書き出す の次に整理しておきたいのが、Android 権限の扱いです。実務で止まりやすいのは、権限 API の名前そのものより、いつ聞くか、拒否されたあとに何を見せるか、永続拒否になったらどこへ案内するかの設計です。この記事では permission_handler を使い、カメラ、ストレージ系、Bluetooth の 3 系統を 1 画面で確認しながら、要求タイミング、拒否時の分岐、設定画面への誘導までを最小構成で整理します。

1. ゴールと非対象

対象読者

  • Flutter プロジェクトを作成して flutter run した経験がある人
  • バーコード、画像選択、Bluetooth など Android 権限が絡む機能へ進みたい人
  • 権限ダイアログを出すだけで終わらせず、拒否後の導線まで整えたい人

この記事で到達する状態

  • permission_handler で権限状態を確認できる
  • カメラ、ストレージ系、Bluetooth で「起動時に聞くか、操作時に聞くか」を判断できる
  • deniedpermanentlyDeniedshouldShowRequestRationale を UI に反映できる
  • 設定画面への誘導を openAppSettings() で実装できる

非対象

  • flutter_blue_plus による Bluetooth 実通信
  • 画像アップロードやファイル保存の実装そのもの
  • MethodChannel によるネイティブ SDK 連携
  • iOS の Info.plist 設定
  • manageExternalStorage が必要な特殊用途

今回は Android 権限の入口に絞ります。通信や SDK 連携を足す前に、拒否時の分岐を先に固めるのが目的です。

2. 先に要求タイミングと状態分岐を決める

まずは、どのタイミングで権限を聞くかを機能ごとに分けます。

機能先に聞くべきか基本方針理由
カメラでバーコードを読むいいえ読み取りボタンを押した時に要求する最初の画面で突然カメラ権限を求めると、何のためか伝わりにくい
写真や画像を読み込むいいえ画像選択や添付操作の直前に要求する画像機能を使わない利用者に不要なダイアログを見せずに済む
Bluetooth 機器を探すいいえスキャン開始や接続開始の直前に要求する近くの機器探索と結び付けて説明したほうが拒否率を下げやすい

起動直後に全部聞く設計は、短期的には実装が楽です。ですが、拒否されたあとに「何ができなくなったのか」が伝わりにくくなります。機能説明を先に見せ、実際にその機能を使う瞬間で要求したほうが、利用者には理由が伝わりやすく、読者も後続の機能実装へつなげやすくなります。

権限要求の流れは次の通りです。

flowchart TD
  A[利用者が機能ボタンを押す] --> B[現在の権限状態を確認]
  B --> C{granted か}
  C -->|はい| D[機能を実行する]
  C -->|いいえ| E{permanentlyDenied か}
  E -->|はい| F[設定画面を開く導線を出す]
  E -->|いいえ| G{rationale が必要か}
  G -->|はい| H[なぜ必要かを説明してから request]
  G -->|いいえ| I[そのまま request]
  H --> J[request の結果を再取得]
  I --> J
  J --> K{granted になったか}
  K -->|はい| D
  K -->|いいえ| L[拒否状態の説明を残す]

ここで見るべきなのは「許可されたか」だけではありません。

  • granted: そのまま機能を実行する
  • denied: まだ再要求の余地があるので、理由説明と再試行導線を置く
  • permanentlyDenied: 同じダイアログを出し続けず、設定画面へ案内する

この分け方を先に決めておくと、後からバーコード読み取りや Bluetooth 接続を足しても UI が崩れにくくなります。

3. プロジェクトを作成し、Android 側の宣言を整える

3-1. Flutter の環境構築がまだなら先に済ませる

環境構築がまだの場合は Windows 11で始めるFlutter開発環境 を先に参照してください。

3-2. Flutter プロジェクトを作成する

次のコマンドでプロジェクトを作成します。

flutter create my_permission_app
cd my_permission_app

3-3. エミュレーターを起動する

利用可能なエミュレーター一覧を確認します。

flutter emulators

表示された ID を指定して起動します。

flutter emulators --launch <emulator_id>

3-4. permission_handler を追加する

プロジェクト直下で次のコマンドを実行します。

flutter pub add permission_handler

既存プロジェクトへ追加する場合は、android/app/build.gradlecompileSdkVersion が 33 以上かも確認します。Permission.photos など Android 13 系の権限を使うためです。

3-5. AndroidManifest.xml に必要権限を追加する

android/app/src/main/AndroidManifest.xml<manifest> 直下へ、次の宣言を追加します。

<uses-permission android:name="android.permission.CAMERA" />

<uses-permission
    android:name="android.permission.READ_EXTERNAL_STORAGE"
    android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />

<uses-permission
    android:name="android.permission.BLUETOOTH"
    android:maxSdkVersion="30" />
<uses-permission
    android:name="android.permission.BLUETOOTH_ADMIN"
    android:maxSdkVersion="30" />
<uses-permission
    android:name="android.permission.BLUETOOTH_SCAN"
    android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

コードのポイント

① カメラは CAMERA を一行で宣言する

<uses-permission android:name="android.permission.CAMERA" />

カメラ用途は CAMERA の宣言が基本です。ここで決まるのは実行時権限だけで、Google Play の配布対象は別です。カメラなし端末にも配布したい場合だけ、<uses-feature android:name="android.hardware.camera" android:required="false" /> を追加します。

② 画像読込は Android バージョンで宣言を分ける

<uses-permission
    android:name="android.permission.READ_EXTERNAL_STORAGE"
    android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />

maxSdkVersion="32" により READ_EXTERNAL_STORAGE は Android 12 以下にだけ適用されます。Android 13 以降では READ_MEDIA_IMAGES が画像読込の正規宣言です。両方を書いておくと、端末バージョンごとに必要な宣言だけが参照されます。

BLUETOOTH_SCANneverForLocation を付ける理由

<uses-permission
    android:name="android.permission.BLUETOOTH_SCAN"
    android:usesPermissionFlags="neverForLocation" />

「位置情報を推定する用途では使わない」と明示する宣言です。位置情報権限を外しやすくなる一方で、一部の BLE ビーコンはスキャン結果から除外されます。ビーコン位置推定のように位置情報と結び付く用途では、この属性を付けずに審査要件を別途確認してください。

3-6. ストレージ系権限は Android 13 以降で考え方が変わる

ここは実務で詰まりやすいポイントです。permission_handler の FAQ にある通り、Permission.storage は Android 13 以降では従来どおりに機能しません。扱うメディアごとの切り替えが必要です。

今回のサンプルでは、画像読込の入口として次のように扱います。

  • Android 13 以降: Permission.photos
  • Android 12 以下: Permission.storage

「写真を 1 枚選ぶだけ」であれば、権限自体を減らせる Photo Picker を検討する余地もあります。ただし、この記事では権限状態の分岐を見ることが主題なので、まずは permission_handler 側の扱いを優先します。

4. lib/main.dart に権限ダッシュボードを作る

lib/main.dart は次の内容で作成します。権限状態の取得・要求・ログ表示を 1 ファイルにまとめた構成で、PermissionSnapshot で状態を保持し、shouldShowRationalepermanentlyDenied の 2 分岐が核心になります。

import 'dart:io';

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

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Android Permission Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
      ),
      home: const PermissionDashboardPage(),
    );
  }
}

enum PermissionUseCase {
  camera,
  media,
  bluetooth,
}

class PermissionSnapshot {
  const PermissionSnapshot({
    required this.status,
    required this.shouldShowRationale,
    required this.detail,
  });

  final PermissionStatus status;
  final bool shouldShowRationale;
  final String detail;
}

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

  @override
  State<PermissionDashboardPage> createState() => _PermissionDashboardPageState();
}

class _PermissionDashboardPageState extends State<PermissionDashboardPage> {
  final Map<PermissionUseCase, PermissionSnapshot> _snapshots =
      <PermissionUseCase, PermissionSnapshot>{};
  final List<String> _logs = <String>[];

  bool _isRefreshing = false;

  @override
  void initState() {
    super.initState();
    _refreshAll();
  }

  Future<void> _refreshAll() async {
    setState(() {
      _isRefreshing = true;
    });

    final Map<PermissionUseCase, PermissionSnapshot> next =
        <PermissionUseCase, PermissionSnapshot>{};

    for (final PermissionUseCase useCase in PermissionUseCase.values) {
      next[useCase] = await _loadSnapshot(useCase);
    }

    if (!mounted) {
      return;
    }

    setState(() {
      _snapshots
        ..clear()
        ..addAll(next);
      _isRefreshing = false;
    });
  }

  Future<PermissionSnapshot> _loadSnapshot(PermissionUseCase useCase) async {
    final List<Permission> permissions = _permissionsFor(useCase);
    final List<PermissionStatus> statuses = <PermissionStatus>[];
    bool shouldShowRationale = false;

    for (final Permission permission in permissions) {
      final PermissionStatus status = await permission.status;
      statuses.add(status);
      shouldShowRationale =
          shouldShowRationale || await permission.shouldShowRequestRationale;
    }

    final PermissionStatus merged = _mergeStatuses(
      statuses,
      grantIfAny: useCase == PermissionUseCase.media,
    );

    return PermissionSnapshot(
      status: merged,
      shouldShowRationale: shouldShowRationale,
      detail: _detailFor(useCase),
    );
  }

  List<Permission> _permissionsFor(PermissionUseCase useCase) {
    switch (useCase) {
      case PermissionUseCase.camera:
        return <Permission>[Permission.camera];
      case PermissionUseCase.media:
        return <Permission>[Permission.photos, Permission.storage];
      case PermissionUseCase.bluetooth:
        return <Permission>[
          Permission.bluetoothScan,
          Permission.bluetoothConnect,
        ];
    }
  }

  String _detailFor(PermissionUseCase useCase) {
    switch (useCase) {
      case PermissionUseCase.camera:
        return 'Permission.camera を確認します。バーコード読み取りや撮影開始の直前に要求します。';
      case PermissionUseCase.media:
        return 'Android 13 以降は Permission.photos、Android 12 以下は Permission.storage を見ます。';
      case PermissionUseCase.bluetooth:
        return 'Permission.bluetoothScan と Permission.bluetoothConnect をまとめて確認します。';
    }
  }

  PermissionStatus _mergeStatuses(
    List<PermissionStatus> statuses, {
    bool grantIfAny = false,
  }) {
    if (statuses.isEmpty) {
      return PermissionStatus.denied;
    }

    final bool hasGranted = statuses.any(
      (PermissionStatus status) =>
          status.isGranted || status.isLimited || status.isProvisional,
    );
    final bool allGranted = statuses.every(
      (PermissionStatus status) =>
          status.isGranted || status.isLimited || status.isProvisional,
    );

    if ((grantIfAny && hasGranted) || allGranted) {
      return PermissionStatus.granted;
    }

    if (statuses.any((PermissionStatus status) => status.isPermanentlyDenied)) {
      return PermissionStatus.permanentlyDenied;
    }

    if (statuses.any((PermissionStatus status) => status.isRestricted)) {
      return PermissionStatus.restricted;
    }

    if (statuses.any((PermissionStatus status) => status.isLimited)) {
      return PermissionStatus.limited;
    }

    return PermissionStatus.denied;
  }

  Future<void> _request(PermissionUseCase useCase) async {
    if (kIsWeb || !Platform.isAndroid) {
      _appendLog('Android 以外ではこのサンプルを対象外にしています。');
      return;
    }

    final PermissionSnapshot before = await _loadSnapshot(useCase);
    if (!mounted) {
      return;
    }

    setState(() {
      _snapshots[useCase] = before;
    });

    if (before.status.isGranted) {
      _appendLog('${_titleFor(useCase)} は既に許可されています。');
      return;
    }

    if (before.status.isPermanentlyDenied) {
      _appendLog('${_titleFor(useCase)} は永続拒否です。設定画面へ誘導してください。');
      return;
    }

    if (before.shouldShowRationale) {
      final bool shouldContinue = await _showRationaleDialog(useCase);
      if (!shouldContinue) {
        _appendLog('${_titleFor(useCase)} の再要求をキャンセルしました。');
        return;
      }
    }

    await _permissionsFor(useCase).request();
    final PermissionSnapshot after = await _loadSnapshot(useCase);

    if (!mounted) {
      return;
    }

    setState(() {
      _snapshots[useCase] = after;
    });

    if (after.status.isGranted) {
      _appendLog('${_titleFor(useCase)} が許可されました。');
    } else if (after.status.isPermanentlyDenied) {
      _appendLog('${_titleFor(useCase)} が永続拒否になりました。設定画面を案内してください。');
    } else {
      _appendLog('${_titleFor(useCase)} はまだ許可されていません。状態: ${_statusLabel(after.status)}');
    }
  }

  Future<void> _openSettings(PermissionUseCase useCase) async {
    final bool opened = await openAppSettings();
    _appendLog(
      opened
          ? '${_titleFor(useCase)} の設定画面を開きました。戻ったら状態を再確認してください。'
          : '設定画面を開けませんでした。',
    );
  }

  Future<bool> _showRationaleDialog(PermissionUseCase useCase) async {
    final bool? result = await showDialog<bool>(
      context: context,
      builder: (BuildContext context) {
        return AlertDialog(
          title: Text('${_titleFor(useCase)} の利用理由'),
          content: Text(_rationaleMessageFor(useCase)),
          actions: <Widget>[
            TextButton(
              onPressed: () => Navigator.pop(context, false),
              child: const Text('あとで'),
            ),
            FilledButton(
              onPressed: () => Navigator.pop(context, true),
              child: const Text('続けて要求する'),
            ),
          ],
        );
      },
    );

    return result ?? false;
  }

  void _appendLog(String message) {
    final DateTime now = DateTime.now();
    final String hh = now.hour.toString().padLeft(2, '0');
    final String mm = now.minute.toString().padLeft(2, '0');
    final String ss = now.second.toString().padLeft(2, '0');

    if (!mounted) {
      return;
    }

    setState(() {
      _logs.insert(0, '[$hh:$mm:$ss] $message');
      if (_logs.length > 12) {
        _logs.removeLast();
      }
    });
  }

  String _titleFor(PermissionUseCase useCase) {
    switch (useCase) {
      case PermissionUseCase.camera:
        return 'カメラ権限';
      case PermissionUseCase.media:
        return 'ストレージ系権限';
      case PermissionUseCase.bluetooth:
        return 'Bluetooth 権限';
    }
  }

  String _subtitleFor(PermissionUseCase useCase) {
    switch (useCase) {
      case PermissionUseCase.camera:
        return 'バーコード読み取りや撮影開始の直前に要求するのが基本です。';
      case PermissionUseCase.media:
        return '画像添付や写真選択の操作時に要求します。';
      case PermissionUseCase.bluetooth:
        return 'スキャン開始や接続開始の直前に要求します。';
    }
  }

  String _rationaleMessageFor(PermissionUseCase useCase) {
    switch (useCase) {
      case PermissionUseCase.camera:
        return '読み取りボタンを押した時だけカメラを使います。許可されないとバーコードや QR コードを読み取れません。';
      case PermissionUseCase.media:
        return '画像添付の時だけ共有メディアを読みます。許可されないと写真を選択できません。';
      case PermissionUseCase.bluetooth:
        return '周辺機器を探して接続する時だけ使います。許可されないとスキャナーやプリンターを見つけられません。';
    }
  }

  Color _statusColor(PermissionStatus status, ColorScheme colorScheme) {
    if (status.isGranted) {
      return colorScheme.primaryContainer;
    }

    if (status.isPermanentlyDenied) {
      return colorScheme.errorContainer;
    }

    return colorScheme.surfaceContainerHighest;
  }

  String _statusLabel(PermissionStatus status) {
    if (status.isGranted) {
      return 'granted';
    }
    if (status.isPermanentlyDenied) {
      return 'permanentlyDenied';
    }
    if (status.isRestricted) {
      return 'restricted';
    }
    if (status.isLimited) {
      return 'limited';
    }
    return 'denied';
  }

  @override
  Widget build(BuildContext context) {
    final ColorScheme colorScheme = Theme.of(context).colorScheme;

    return Scaffold(
      appBar: AppBar(
        title: const Text('Android 権限ダッシュボード'),
        actions: <Widget>[
          IconButton(
            onPressed: _isRefreshing ? null : _refreshAll,
            icon: const Icon(Icons.refresh),
            tooltip: '状態を再取得',
          ),
        ],
      ),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: <Widget>[
          Card(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Text(
                    '起動時ではなく、機能を使う操作の直前に要求する前提で確認します。',
                    style: Theme.of(context).textTheme.titleMedium,
                  ),
                  const SizedBox(height: 8),
                  const Text(
                    '拒否後は理由説明を出し、永続拒否なら設定画面へ誘導します。',
                  ),
                ],
              ),
            ),
          ),
          const SizedBox(height: 16),
          for (final PermissionUseCase useCase in PermissionUseCase.values)
            Padding(
              padding: const EdgeInsets.only(bottom: 12),
              child: _PermissionCard(
                title: _titleFor(useCase),
                subtitle: _subtitleFor(useCase),
                snapshot: _snapshots[useCase],
                statusLabel: _snapshots[useCase] == null
                    ? 'loading'
                    : _statusLabel(_snapshots[useCase]!.status),
                backgroundColor: _statusColor(
                  _snapshots[useCase]?.status ?? PermissionStatus.denied,
                  colorScheme,
                ),
                onRequest: () => _request(useCase),
                onSettings: () => _openSettings(useCase),
              ),
            ),
          const SizedBox(height: 12),
          Text(
            'イベントログ',
            style: Theme.of(context).textTheme.titleMedium,
          ),
          const SizedBox(height: 8),
          Card(
            child: Padding(
              padding: const EdgeInsets.all(12),
              child: _logs.isEmpty
                  ? const Text('まだ操作ログはありません。')
                  : Column(
                      children: _logs
                          .map(
                            (String log) => Align(
                              alignment: Alignment.centerLeft,
                              child: Padding(
                                padding: const EdgeInsets.only(bottom: 6),
                                child: Text(log),
                              ),
                            ),
                          )
                          .toList(),
                    ),
            ),
          ),
        ],
      ),
    );
  }
}

class _PermissionCard extends StatelessWidget {
  const _PermissionCard({
    required this.title,
    required this.subtitle,
    required this.snapshot,
    required this.statusLabel,
    required this.backgroundColor,
    required this.onRequest,
    required this.onSettings,
  });

  final String title;
  final String subtitle;
  final PermissionSnapshot? snapshot;
  final String statusLabel;
  final Color backgroundColor;
  final VoidCallback onRequest;
  final VoidCallback onSettings;

  @override
  Widget build(BuildContext context) {
    final bool permanentlyDenied = snapshot?.status.isPermanentlyDenied ?? false;

    return Card(
      color: backgroundColor,
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Text(title, style: Theme.of(context).textTheme.titleMedium),
            const SizedBox(height: 6),
            Text(subtitle),
            const SizedBox(height: 12),
            Text('現在の状態: $statusLabel'),
            if (snapshot != null) ...<Widget>[
              const SizedBox(height: 8),
              Text(snapshot!.detail),
              if (snapshot!.shouldShowRationale) ...<Widget>[
                const SizedBox(height: 8),
                const Text('この状態では、再要求前に理由説明を見せるのが自然です。'),
              ],
            ],
            const SizedBox(height: 12),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: <Widget>[
                FilledButton.icon(
                  onPressed: onRequest,
                  icon: const Icon(Icons.verified_user),
                  label: const Text('権限を要求'),
                ),
                if (permanentlyDenied)
                  OutlinedButton.icon(
                    onPressed: onSettings,
                    icon: const Icon(Icons.settings),
                    label: const Text('設定を開く'),
                  ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

コードを貼り付けたら、次のコマンドで起動します。

flutter run

起動直後は、説明カードと権限カード 3 枚、イベントログが 1 画面に並びます。下の 2 枚は同じ画面の上半分と下半分です。

権限ダッシュボードの上部。説明カード、カメラ権限、ストレージ系権限が表示されている。 権限ダッシュボードの下部。Bluetooth 権限カードとイベントログが表示されている。

コードのポイント

① 権限ごとの状態を PermissionSnapshot にまとめる

class PermissionSnapshot {
  final PermissionStatus status;
  final bool shouldShowRationale;
  final String detail;
}

権限状態・rationale フラグ・説明文を 1 つのオブジェクトで保持します。Map<PermissionUseCase, PermissionSnapshot> で管理することで、カードごとの再描画が setState 一回で済みます。

② ストレージ系はいずれか一方が許可されれば通す

final PermissionStatus merged = _mergeStatuses(
  statuses,
  grantIfAny: useCase == PermissionUseCase.media,
);

grantIfAny: true を渡すと、Permission.photosPermission.storage のどちらか一方が granted であれば全体を granted と見なします。Android バージョンによって有効な権限が異なるため、どちらかが通れば機能を実行できる設計にしています。

shouldShowRationale が真なら説明ダイアログを先に出す

if (before.shouldShowRationale) {
  final bool shouldContinue = await _showRationaleDialog(useCase);
  if (!shouldContinue) {
    return;
  }
}

一度拒否された後に再要求する場合、OS は「なぜ必要か説明しなさい」というシグナルを返します。このシグナルを無視して request() を直接呼ぶと、理由を説明しないまま同じダイアログを出すことになります。

④ 永続拒否なら 設定を開く ボタンを追加する

if (permanentlyDenied)
  OutlinedButton.icon(
    onPressed: onSettings,
    icon: const Icon(Icons.settings),
    label: const Text('設定を開く'),
  ),

permanentlyDenied の状態では request() を呼んでも OS ダイアログが出ません。そこで 設定を開く ボタンを追加し、次に取るべき行動を明確にしています。この記事のサンプルは状態確認用なので 権限を要求 ボタンも残していますが、実案件では設定誘導だけに絞っても構いません。

5. 拒否・永続拒否・設定誘導をどう分けるか

権限周りで詰まりやすいのは、拒否後の扱いです。最低でも次の 3 パターンに分けます。

状態UI の基本方針この記事のサンプルでの扱い
grantedそのまま機能を実行するログへ許可済みと出し、次の処理へ進める前提にする
deniedなぜ必要かを説明し、再要求の余地を残すshouldShowRequestRationale が真なら説明ダイアログを出す
permanentlyDenied再要求ボタン連打ではなく設定画面へ誘導する設定を開く ボタンを表示し、openAppSettings() を呼ぶ

実務では、deniedpermanentlyDenied を同じ扱いにすると止まりやすくなります。

  • denied の段階では、まだ「何に使うのか」が伝わっていない可能性がある
  • permanentlyDenied の段階では、同じリクエストを繰り返しても OS ダイアログが出ない

denied では再要求の余地を残し、permanentlyDenied では設定画面導線を必ず見せます。今回のサンプルでは、理由説明の余地があるときだけダイアログを出し、永続拒否になったら 設定を開く ボタンを追加しています。

下の 3 枚は、理由説明が必要な denied、設定誘導が必要な permanentlyDenied、許可済みの granted を順に示したものです。

カメラ権限カードが denied で、再要求前に理由説明を見せる旨が表示されている。 カメラ権限カードが permanentlyDenied で、設定を開くボタンが追加されている。 カメラ権限カードが granted になっている。

6. カメラ・ストレージ系・Bluetooth で実務上どこが違うか

同じ permission_handler でも、運用上の判断は権限ごとに少し違います。

カメラ

カメラは用途が見えやすいので、読み取り開始や撮影開始の直前に要求する形が素直です。Flutterで業務用バーコード読み取りアプリを作る(最小構成) と組み合わせる場合も、画面表示の時点で聞くより、読み取り開始ボタンに結び付けたほうが理由を説明しやすくなります。

ストレージ系

ストレージ系は Android 13 以降で特に注意が必要です。

  • Permission.storage をそのまま使い続けると、Android 13 以降で期待どおりに通らない
  • 画像だけなら Permission.photos のように粒度を絞る
  • 写真選択だけで済むなら Photo Picker で権限自体を減らせる場合がある

この切り分けを先に理解しておくと、後続の画像アップロード記事で「とりあえずストレージ権限」を避けやすくなります。

Bluetooth

Bluetooth は権限名だけでなく、Android バージョン差異も意識する必要があります。今回のサンプルは Android 12 以降の Permission.bluetoothScan / Permission.bluetoothConnect を前提にした確認用です。Android 12 以降では Nearby devices 権限としてまとめて扱われるため、OS 上は 1 回のダイアログに見えることがあります。実際に機器をスキャンして接続する記事では、ライブラリ側の要件や古い Android 端末での差分を追加で整理することになります。

ここで先にやっておきたいのは、Bluetooth 実装そのものではありません。拒否後に「スキャン開始前にもう一度説明するのか」「設定画面へ送るのか」を UI として持っておくことです。

7. まとめ

permission_handler を使って、Android 権限の扱いを 1 画面で確認できる最小構成を作りました。ここまでで、権限状態の確認、操作時要求、拒否時の理由説明、永続拒否から設定画面誘導までの基本パターンが揃います。

次に進めるなら、まずは Flutterで業務用バーコード読み取りアプリを作る(最小構成) と組み合わせてカメラ権限の流れを固めること。そのうえで Bluetooth 実装や MethodChannel へ進めば、拒否時の導線で詰まりにくいです。

シリーズ 32/38

このシリーズ

Flutter導入と基礎

  1. 1. Windows 11で始めるFlutter開発環境:Android Emulatorで動かすまで
  2. 2. Flutter + FVM で開発環境のバージョンを固定する
  3. 3. Flutterで画像・SVG・アイコンを管理する(flutter_gen最小構成)
  4. 4. Flutterで最初に詰まりやすいDartの書き方:final・const・null safety・async/await を最初に整理する
  5. 5. DartのStream入門(非同期データの流れをつかむ)
  6. 6. FlutterのWidgetライフサイクル入門(initState / dispose で詰まらないために)
  7. 7. FlutterでBuildContextとKeyを理解する
  8. 8. Flutterのレイアウト入門(Column / Row / Stack の使い分け)
  9. 9. Flutterのテーマ設計入門(ThemeData + Theme Extension)
  10. 10. FlutterでMediaQueryとLayoutBuilderを使って画面サイズに対応する(スマホ・タブレット両対応)
  11. 11. FlutterのContainerとSizedBoxを使いこなす(余白・サイズ・装飾の基本)
  12. 12. FlutterのListViewとGridViewで一覧画面を作る(基本パターン)
  13. 13. Flutterのダイアログ・スナックバー・ボトムシートを使う(確認・通知UIの基本)
  14. 14. FlutterのTabBarとBottomNavigationBarで複数画面を切り替える
  15. 15. Flutterでカスタムウィジェットを作る入門(StatelessWidget の分割と再利用)
  16. 16. Flutterのルーティング入門(Navigator と go_router の使い分け)
  17. 17. FlutterからREST APIを呼ぶ最小構成(JSON通信 + エラー処理)
  18. 18. Flutterでjson_serializable + build_runnerを使ってJSONモデルを型安全に扱う
  19. 19. Flutterの状態管理入門(Riverpod最小構成)
  20. 20. Flutterでローディング・空状態・エラー表示を整える
  21. 21. Dart 3のsealed classとパターンマッチングで分岐を安全に書く
  22. 22. Flutterでgo_routerの認証ガードを実装する(redirect最小構成)
  23. 23. Flutterで端末設定と利用者設定を保存する(SharedPreferencesとsecure storageの使い分け)
  24. 24. Flutterでログイン状態を保持する(JWT + secure storage 最小構成)
  25. 25. Flutterアプリを日本語化する(l10n + arb 最小構成)
  26. 26. Flutterで業務用バーコード読み取りアプリを作る(最小構成)
  27. 27. Flutterでスキャン入力を受けて処理する
  28. 28. FlutterでGS1-128バーコードを解析する
  29. 29. Flutterで単一画面の入力フローを作る
  30. 30. Flutterで複数画像の添付UIを作る
  31. 31. Flutterでデータをファイルに書き出す
  32. 32. permission_handler でAndroid権限を実践的に扱う(カメラ・ストレージ・Bluetooth) 現在の記事
  33. 33. Flutterアプリのネイティブ設定を整える(アプリ名 / アイコン / スプラッシュ / 署名)
  34. 34. Flutterの環境切替と配布前チェック(flavor / release build / 権限確認)
  35. 35. FlutterのWidgetテスト入門(画面ロジックを壊さない最小構成)
  36. 36. FlutterのIntegration Test入門(ログインから一覧表示まで確認する)
  37. 37. Flutterアプリを社内配布する(Android APK サイドロード + MDM 概要)
  38. 38. Sentryでクラッシュとエラーを検知する(Flutter最小構成)