Flutterでデータをファイルに書き出す の次に整理しておきたいのが、Android 権限の扱いです。実務で止まりやすいのは、権限 API の名前そのものより、いつ聞くか、拒否されたあとに何を見せるか、永続拒否になったらどこへ案内するかの設計です。この記事では permission_handler を使い、カメラ、ストレージ系、Bluetooth の 3 系統を 1 画面で確認しながら、要求タイミング、拒否時の分岐、設定画面への誘導までを最小構成で整理します。
1. ゴールと非対象
対象読者
- Flutter プロジェクトを作成して
flutter runした経験がある人 - バーコード、画像選択、Bluetooth など Android 権限が絡む機能へ進みたい人
- 権限ダイアログを出すだけで終わらせず、拒否後の導線まで整えたい人
この記事で到達する状態
permission_handlerで権限状態を確認できる- カメラ、ストレージ系、Bluetooth で「起動時に聞くか、操作時に聞くか」を判断できる
denied、permanentlyDenied、shouldShowRequestRationaleを 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.gradle の compileSdkVersion が 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_SCAN に neverForLocation を付ける理由
<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 で状態を保持し、shouldShowRationale と permanentlyDenied の 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 枚は同じ画面の上半分と下半分です。
コードのポイント
① 権限ごとの状態を 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.photos か Permission.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() を呼ぶ |
実務では、denied と permanentlyDenied を同じ扱いにすると止まりやすくなります。
deniedの段階では、まだ「何に使うのか」が伝わっていない可能性があるpermanentlyDeniedの段階では、同じリクエストを繰り返しても OS ダイアログが出ない
denied では再要求の余地を残し、permanentlyDenied では設定画面導線を必ず見せます。今回のサンプルでは、理由説明の余地があるときだけダイアログを出し、永続拒否になったら 設定を開く ボタンを追加しています。
下の 3 枚は、理由説明が必要な 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 へ進めば、拒否時の導線で詰まりにくいです。