Flutter で NFC タグの読み取り・書き込みを行う（iOS）

Flutter での NFC の読み書きについての日本語の記事があまりなかったので、備忘録も兼ねてまとめてみました。

今回は諸事情により iOS アプリの開発を前提としています。

# 環境
- M1 MacBook Air (2020)
    - macOS Sonoma 14.4
- Flutter 3.19.2
- Tools
    - Dart 3.3.0
    - DevTools 2.31.1
- nfc_manager 3.4.0

# 前提条件
Flutter プロジェクトの作成が済んでいること

# NFC Manager のインストール
[NFC Manager](https://pub.dev/packages/nfc_manager) を利用することで、 NFC の読み取り・書き込み機能を追加することができます。
```shell
flutter pub add nfc_manager
```

# Entitlements の追加
アプリの Capability（ネイティブ機能）を管理する Entitlements に Near Field Communication Tag Reader Session Formats Entitlements を追加します。
Xcode で Runner > Signing & Capabilities > + Capability から Near Field Communication Tag Reading を追加すれば OK です。
また、直接 Runner.entitlements ファイルに以下を追記することでも同等の操作が可能です。
```xml
<key>com.apple.developer.nfc.readersession.formats</key>
	<array>
		<string>TAG</string>
	</array>
```

# Info.plist の更新
ios/Info.plist に NFCReaderUsageDescription の項目を追加します。
string 部分は空文字列でなければ任意の文字列で良いです。
```xml
<key>NFCReaderUsageDescription</key>
<string>For NFC Reading / Writing</string>
```
また、 com.apple.developer.nfc.readersession.iso7816.select-identifiers と com.apple.developer.nfc.readersession.felica.systemcodes については、[ドキュメント](https://pub.dev/documentation/nfc_manager/latest/)には必要に応じて追加すると書かれていますが、 FeliCa を利用しなくてもこれを記述しないと正しく動作しなかったため、 Info.plist に追記します。
iso7816.select-identifiers の値は読み取るカードの種類（運転免許証、マイナンバーカード、学生証など）によって異なりますが、今回は "00000000000000" と指定しました（おそらく、特定のカードに対応するコード以外を入力すれば任意のカードを読み取れるようになります）。
systemcode についても使用目的や事業者によって異なりますが、今回は NFC Forum が規定する NFC Data Exchange Format (NDEF) が格納されたシステムのコード "12FC" を記述します[^1]。
```xml
<key>com.apple.developer.nfc.readersession.iso7816.select-identifiers</key>
<array>
    <string>00000000000000</string>
</array>
<key>com.apple.developer.nfc.readersession.felica.systemcodes</key>
<array>
    <string>12FC</string>
</array>
```

# NFC Manager のセットアップ
まずは NFC の読み取り・書き込み処理を記述するファイルでプラグインをインポートします。
```dart
import 'package:nfc_manager/nfc_manager.dart';
```
実際に NFC 機能を利用する際、端末側で機能が利用可能かどうかをチェックします。
```dart
bool isNfcAvailable = await NfcManager.instance.isAvailable();
```
利用可能な場合は、 NFC のセッションを開始します。
```dart
NfcManager.instance.startSession(
    onDiscovered: (NfcTag tag) async {
        // 読み取り・書き込み処理
    },
);
```
処理が終わったら、セッションを終了します。
```dart
NfcManager.instance.stopSession();
```

# NFC タグのインスタンスの作成
NFC タグを読み取り・書き込みする際、どの規格（データ形式）を利用するかをインスタンスの形式で指定できます。
例えば、標準の NDEF で処理を行う際は、次のように Ndef クラスのインスタンスを宣言し、エラーハンドリングを行います。
```dart
onDiscovered: (NfcTag tag) async {
    Ndef? ndef = Ndef.from(tag);
    if (ndef == null) {
        // エラー処理
        return;
    }
}
```
Ndef クラスの他にも、 FeliCa や MiFare クラスが用意されています（[こちら](https://pub.dev/documentation/nfc_manager/latest/)を参照）。

# NFC タグの読み取り
読み取りは Ndef クラスの read メソッドを利用します。
```dart
NdefMessage message = await ndef.read();
```
返り値は NdefMessage クラスで、 records プロパティからデータを取り出します。
```dart
List<NdefRecord> records = message.records;
```
records は NdefRecord のリストとなっているので、ループを回すなどして要素を取り出します。
書き込まれている内容は payload プロパティ（ Uint8List ）から取り出せます。
```dart
for (NdefRecord record in records) {
    Uint8List payload = record.payload;
    // payload を処理
}
```
実際に文字列としてデータを処理する場合は、 payload を UTF-8 などにデコードする必要があります。
```dart
String str = '';
for (NdefRecord record in records) {
    Uint8List payload = record.payload;
    str += utf8.decode(payload);
}
```

FeliCa クラスでは readWithoutEncryption メソッドを利用します（暗号化されていないデータの読み取り）。
返り値の型などは[こちら](https://pub.dev/documentation/nfc_manager/latest/platform_tags/FeliCa/readWithoutEncryption.html)を参照してください。

# NFC タグへの書き込み
まず、利用する NFC タグに書き込みが可能かどうかをチェックします。
```dart
bool isWritable = ndef.isWritable;
```
書き込みには write メソッドを利用します。
引数は NdefMessage を指定する必要があるので、書き込むデータを NdefRecord に変換し、 NdefMessage のコンストラクタに渡します。
```dart
if (isWritable) {
    NdefRecord record = NdefRecord.createText("Hello World!");
    await ndef.write(NdefMessage([record]));
}
```
FeliCa では先ほどと同様に [writeWithoutEncryption](https://pub.dev/documentation/nfc_manager/latest/platform_tags/FeliCa/writeWithoutEncryption.html) メソッドを利用します。

# 実機テスト
Android 端末では NFC 機能を擬似的に再現できる[エミュレータ](https://pub.dev/packages/nfc_emulator/versions)が利用できますが、今回は iOS 端末をターゲットにしているので、実物の NFC タグを用意する必要があります。
ここではカードタイプの NTAG215 を使用します。品番によって書き込めるデータサイズなどが変わるので注意してください。

テストする iOS 端末を PC に接続して、ビルドを行います。今回はデバッグビルドでテストします。
ビルドに成功すると、 NFC タグの読み書きが行えます。今回は読み取り・書き込み・消去が行えるボタンを設置しました。
<!--![IMG_9390.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632213/a499a8ce-4aa2-ff0f-63d7-1fabd7edfe39.png)
![IMG_9391.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632213/7587f979-a4f2-f379-d2cd-04bc215e1c85.png)
![IMG_9393.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632213/f5cb60d2-943b-83c9-d2b5-7a444b60e632.png)
![IMG_9394.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632213/10cfc4d2-64db-a21e-a3f7-feefdcf7cc83.png)-->
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632213/a499a8ce-4aa2-ff0f-63d7-1fabd7edfe39.png" width="200">

まずは空の（空でなくても構いませんが）NFC タグに書き込みを行います。
テキストボックスに書き込むテキストを入力して、 "Write NFC" ボタンをタップします。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632213/7587f979-a4f2-f379-d2cd-04bc215e1c85.png" width="200">

すると NfcManager のセッションが開始するので、 NFC タグを端末の読み取り部分にかざします。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632213/f5cb60d2-943b-83c9-d2b5-7a444b60e632.png" width="200">

書き込みに成功すると、上部に "Write OK" のメッセージと書き込まれた内容が表示されます。
正しく書き込めているか確認するために、 "Read NFC" ボタンをタップし、同様に NFC タグをかざして読み取ります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632213/10cfc4d2-64db-a21e-a3f7-feefdcf7cc83.png" width="200">

読み取りに成功すると、 "Read OK" のメッセージと読み取った内容が表示されます。正しく書き込めています。

実際に読み取りしてみると、読み取り速度はかなり高速に感じました。
NFC 便利ですね。

# 全体のコード
```dart
import 'dart:convert';
import 'dart:typed_data';

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

void main() async {
  runApp(const MainApp());
}

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

  @override
  State<MainApp> createState() => _MainAppState();
}

class _MainAppState extends State<MainApp> {
  String _message = '';

  void readNfc() async {
    final bool isNfcAvailable = await NfcManager.instance.isAvailable();
    if (!isNfcAvailable) {
      print('NFC is not available on this device');
    } else {
      print('NFC is available on this device');
      NfcManager.instance.startSession(
        onDiscovered: (NfcTag tag) async {
          Ndef? ndef = Ndef.from(tag);
          if (ndef == null) {
            print('Tag is not ndef');
            return;
          }
          NdefMessage message = await ndef.read();
          List<NdefRecord> records = message.records;
          String str = '';
          for (NdefRecord record in records) {
            Uint8List payload = record.payload;
            str += utf8.decode(payload);
          }
          setState(() {
            _message = 'Read OK: ${str.substring(3)}';
          });
          NfcManager.instance.stopSession();
        },
        onError: (dynamic error) {
          print(error.message);
          return Future.value();
        },
      );
    }
  }

  void writeNfc(String text) async {
    final bool isNfcAvailable = await NfcManager.instance.isAvailable();
    if (!isNfcAvailable) {
      print('NFC is not available on this device');
    } else {
      print('NFC is available on this device');
      NfcManager.instance.startSession(
        onDiscovered: (NfcTag tag) async {
          Ndef? ndef = Ndef.from(tag);
          if (ndef == null) {
            print('Tag is not ndef');
            return;
          }
          bool isWritable = ndef.isWritable;
          if (isWritable) {
            NdefRecord record = NdefRecord.createText(text);
            await ndef.write(NdefMessage([record]));
            setState(() {
              _message = (text.isEmpty) ? 'Clear OK' : 'Write OK: $text';
            });
          } else {
            print('Tag is read-only');
          }
          NfcManager.instance.stopSession();
        },
        onError: (dynamic error) {
          print(error.message);
          return Future.value();
        },
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    final writeController = TextEditingController();
    return MaterialApp(
      home: GestureDetector(
        onTap: () => primaryFocus?.unfocus(),
        child: Scaffold(
          body: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              crossAxisAlignment: CrossAxisAlignment.center,
              children: [
                Text(_message),
                TextButton(onPressed: readNfc, child: const Text('Read NFC')),
                Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    SizedBox(
                      width: 200,
                      child: TextField(
                        controller: writeController,
                      ),
                    ),
                    TextButton(
                        onPressed: () => writeNfc(writeController.text),
                        child: const Text('Write NFC')),
                  ],
                ),
                TextButton(
                    onPressed: () => writeNfc(''),
                    child: const Text('Clear NFC')),
              ],
            ),
          ),
        ),
      ),
    );
  }
}
```

# 参考
[nfc_manager package documentation](https://pub.dev/documentation/nfc_manager/latest/)

[Reading and writing NFC using NfcManager in Flutter](https://medium.com/@antonioneus/reading-and-writing-nfc-using-nfcmanager-in-flutter-dc5420991967)

[^1]:[FeliCa技術方式の各種コードについて](https://www.sony.co.jp/Products/felica/business/tech-support/data/M619_FeliCaTechnologyCodeDescriptions_1.6j.pdf)