💳

【Flutter】NFC(FeliCa)で学生証内のプリペイド残高を読み取ってみる

2023/01/07に公開

はじめに

2020年3月に1度Qiitaのほうでも、同じ内容の記事を書いたのですが、Androidの説明しか書いてなかったので、新しくZennで書こうと思ったのが今回のきっかけです。

当時の記事は興味がある方は読んでみてください。
https://qiita.com/f-nakahara/items/82fefe574a2e5a25182b

使用ライブラリ

https://pub.dev/packages/nfc_manager

初期設定

Android

AndroidManifest.xml
<manifest ...>
    <!-- ここから -->
    <uses-permission android:name="android.permission.NFC" />
    <!-- ここまで -->
   <application
        ...
    </application>
</manifest>

iOS

Info.plist
<?xml ...?>
<!DOCTYPE ...>
<plist ...>
<dict>
	...
	// ここから
	<key>NFCReaderUsageDescription</key>
	<string>任意のテキスト</string>
	// ここまで
</dict>
</plist>

取得までの流れ

NFC(FeliCa)では、以下の流れで目的のデータを取得することができます。

  1. カードを認識する
  2. Pollingにより、カードを特定する
  3. Request Serviceにより、欲しい情報が存在するか確認する
  4. Read Without Encryptionにより、データを取得する

上記のPolling, RequestServiceなどはコマンドと呼ばれており、他にも様々なコマンドが存在しますが、本記事では紹介しません。
もっと詳しく知りたい方は、直接調べてみてください。
https://www.sony.co.jp/Products/felica/business/tech-support/st_usmnl.html

実装

いよいよ実際に実装していきます。
NFC(FeliCa)で必要な情報を取得する場合は、予め知って置かなければいけない情報があります。
学生証(大学生協)の情報は、すでにまとめてくれている神様がいるので、そちらを参考にしていきます。
https://gist.github.com/hinaloe/6c445a076d2fe05798596c8d066b422c

Polling

Pollingは、スマホから不特定多数のカードに呼びかけを行います。
これにより、反応したカードが何の種類の誰のカードか、通信状態はどうかといった情報を取得することができます。
今回の場合だと、誰の学生証か識別します。

Pollingに必要な情報は、システムコードと呼ばれるものです。
システムコードとは、事業者/使用目的ごとに割り当てられているコードのことです。
大学生協のシステムコードはFE 00です。

polling.dart
/// Polling
Future<Uint8List> polling(
  NfcTag tag, {
  required List<int> systemCode, // [0xFE, 0x00]
}) async {
  final List<int> packet = [];
  if (Platform.isAndroid) {
    packet.add(0x06);
  }
  packet.add(0x00);
  packet.addAll(systemCode.reversed);
  packet.add(0x01);
  packet.add(0x0F);

  final command = Uint8List.fromList(packet);

  late final Uint8List? res;

  if (NfcF.from(tag) != null) {
    final nfcf = NfcF.from(tag);
    res = await nfcf?.transceive(data: command);
  } else if (FeliCa.from(tag) != null) {
    final felica = FeliCa.from(tag);
    res = await felica?.sendFeliCaCommand(command);
  }
  if (res == null) {
    throw Exception();
  }
  return res;
}

Pollingコマンドを作成するときの注意点としては、システムコードの順序を逆にする必要があることです。

Pollingコマンドによって返却される値は以下

パラメータ名 サイズ データ
レスポンスコード 1 01h
IDm 8
PMm 8
リクエストデータ 2

Request Service

Request Serviceは、欲しい情報が存在するか確認を行うコマンドです。

コマンドを使用するために必要な情報としては、Pollingによって得られるIDmとサービスコードの2つです。
サービスコードとは、残高情報や利用履歴など目的サービスごとに割り当てられています。
今回は残高を取得したいので、50 D7というサービスコードになります。

request_service.dart
/// RequestService
Future<Uint8List> requestService(
  NfcTag tag, {
  required Uint8List idm,
  required List<int> serviceCode, // [0x50, 0xD7]
}) async {
  final nodeCodeList = Uint8List.fromList(serviceCode);
  final List<int> packet = [];
  if (Platform.isAndroid) {
    packet.add(0x06);
  }
  packet.add(0x02);
  packet.addAll(idm);
  packet.add(nodeCodeList.elementSizeInBytes);
  packet.addAll(serviceCode.reversed);

  final command = Uint8List.fromList(packet);

  late final Uint8List? res;

  if (NfcF.from(tag) != null) {
    final nfcf = NfcF.from(tag);
    res = await nfcf?.transceive(data: command);
  } else if (FeliCa.from(tag) != null) {
    final felica = FeliCa.from(tag);
    res = await felica?.sendFeliCaCommand(command);
  }
  if (res == null) {
    throw Exception();
  }
  return res;
}

Request Serviceコマンドを作成するときの注意点としては、サービスコードの順序を逆にする必要があることです。

Request Serviceコマンドによって返却される値は以下

パラメータ名 サイズ データ
レスポンスコード 1 03h
IDm 8
ノード数 1 n
ノード鍵バージョンリスト 2n

Read Without Encryption

Read Without Encryptionコマンドでは、認証不要の情報を取得します。
使用するために必要な情報は、IDm、サービスコード、ブロック数の3つです。

Request Serviceで話したように、今回は残高情報を取得したいため、サービスコードは50 D7です。
ブロック数は、1〜10の数値になりますが、残高情報は1で十分です。
(取引履歴など流れのあるものは、2以上の数値を使用することになります)

read_without_enqryotion.dart
/// ReadWithoutEncryption
Future<Uint8List> readWithoutEncryption(
  NfcTag tag, {
  required Uint8List idm,
  required List<int> serviceCode,
  required int blockCount,
}) async {
  final List<int> packet = [];
  if (Platform.isAndroid) {
    packet.add(0);
  }
  packet.add(0x06);
  packet.addAll(idm);
  packet.add(serviceCode.length ~/ 2);
  packet.addAll(serviceCode.reversed);
  packet.add(blockCount);

  for (int i = 0; i < blockCount; i++) {
    packet.add(0x80);
    packet.add(i);
  }
  if (Platform.isAndroid) {
    packet[0] = packet.length;
  }

  final command = Uint8List.fromList(packet);

  late final Uint8List? res;

  if (NfcF.from(tag) != null) {
    final nfcf = NfcF.from(tag);
    res = await nfcf?.transceive(data: command);
  } else if (FeliCa.from(tag) != null) {
    final felica = FeliCa.from(tag);
    res = await felica?.sendFeliCaCommand(command);
  }
  if (res == null) {
    throw Exception();
  }
  return res;
}

Read Without Encryptionコマンドを作成するときの注意点も、サービスコードの順序を逆にする必要があることです。

Read Without Encryptionコマンドによって返却される値は以下

パラメータ名 サイズ データ
レスポンスコード 1 07h
IDm 8
ステータスフラグ1 1
ステータスフラグ2 1
ブロック数 1 n
ブロックデータ 16n

Parse

Parseは、コマンドではありませんが、目的の情報を取得したあとに人間の読める形に整形させる必要があるため、念の為に説明を行います。

parse.dart
/// バイトデータから残高に変換
int parse(Uint8List rweRes) {
  // ステータスフラグが正常でなければ例外を投げる
  if (rweRes[10] != 0x00 || rweRes[11] != 0x00) {
    throw Exception();
  }
  final blockSize = rweRes[12]; // ブロック数
  const blockLength = 16;
  final data = List.generate(
      blockSize,
          (index) =>
          Uint8List.fromList(List.generate(blockLength, (index) => 0)));
  for (int i = 0; i < blockSize; i++) {
    final offset = 13 + i * blockLength;
    final tmp = rweRes.sublist(offset, offset + blockLength);
    data[i] = tmp;
  }
  final balanceData =
  Uint8List.fromList(data[0].sublist(0, 4).reversed.toList()); // リトルエンディアンのため順序を逆にする
  return balanceData.buffer.asByteData().getInt32(0);
}

ここでの処理は、具体的に以下の操作を行ってます

  1. ブロックデータを分ける
  2. 各ブロックデータを解析する

1の処理をする理由は、ブロックデータのかたまりを二次元配列に分け、扱いやすくするためです。
たとえば、[ブロックデータ1-1, ブロックデータ1-2, ... , ブロックデータ1-16, ブロックデータ2-1, ブロックデータ2-2, ... ,ブロックデータ2-16]と1次元配列のままだと、ブロックデータのグループ(ブロックデータn-1からブロックデータn-16)を特定するのがややこしくなります。
以下のコード部分が1の処理となってます。

  final blockSize = rweRes[12]; // ブロック数
  const blockLength = 16;
  final data = List.generate(
      blockSize,
          (index) =>
          Uint8List.fromList(List.generate(blockLength, (index) => 0)));
  for (int i = 0; i < blockSize; i++) {
    final offset = 13 + i * blockLength;
    final tmp = rweRes.sublist(offset, offset + blockLength);
    data[i] = tmp;
  }

2の処理は、そのままの通りで、実際の値を人がわかるように変換してあげるためです。
コードでいうと以下の部分です。

final balanceData = Uint8List.fromList(data[0].sublist(0, 4).reversed.toList()); // リトルエンディアンのため順序を逆にする
return balanceData.buffer.asByteData().getInt32(0);

少し補足させていただくと、大学生協のFeliCaの仕様から、残高情報はブロックデータの0-3番目にリトルエンディアンとして入っているため、順序を逆にする処理を挟んでいます。
さらに、それぞれのデータをバイトデータとして扱うためにasByteDate()を使用し、4箇所にデータがはいっているため、8×4=32でgetInt32()を使用しています(1つの数字は8バイトとなるため)。

全体コード

main.dart
import 'dart:io';
import 'dart:typed_data';

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

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

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: NfcReadPage(),
    );
  }
}

class NfcReadPage extends StatefulWidget {
  const NfcReadPage({Key? key}) : super(key: key);

  
  State<NfcReadPage> createState() => _NfcReadPageState();
}

class _NfcReadPageState extends State<NfcReadPage> {
  int balance = 0;

  
  void initState() {
    super.initState();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: FutureBuilder(
          future: NfcManager.instance.isAvailable(),
          builder: (context, ss) {
            if (ss.data == true) {
              return Center(
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    Text('$balance 円'),
                    ElevatedButton(
                      onPressed: () async {
                        NfcManager.instance.startSession(
                          onDiscovered: (tag) async {
                            final systemCode = [0xFE, 0x00];
                            final serviceCode = [0x50, 0xD7];
                            final pollingRes =
                                await polling(tag, systemCode: systemCode);
                            final idm = pollingRes.sublist(2, 10);
                            final requestServiceRes = await requestService(
                              tag,
                              idm: idm,
                              serviceCode: serviceCode,
                            );
			    // 情報が存在すれば残高情報を取得する
                            if (requestServiceRes[11] == 00 && requestServiceRes[12] == 0) {
                              final readWithoutEncryptionRes =
                                  await readWithoutEncryption(
                                tag,
                                idm: idm,
                                serviceCode: serviceCode,
                                blockCount: 1,
                              );
                              final balance = parse(readWithoutEncryptionRes);
                              setState(() {
                                this.balance = balance;
                              });
                            }
                          },
                        );
                      },
                      child: const Text('読み込む'),
                    ),
                  ],
                ),
              );
            }
            return const Center(
              child: Text('対応していません'),
            );
          },
        ),
      ),
    );
  }

  /// Polling
  Future<Uint8List> polling(
    NfcTag tag, {
    required List<int> systemCode,
  }) async {
    final List<int> packet = [];
    if (Platform.isAndroid) {
      packet.add(0x06);
    }
    packet.add(0x00);
    packet.addAll(systemCode.reversed);
    packet.add(0x01);
    packet.add(0x0F);

    final command = Uint8List.fromList(packet);

    late final Uint8List? res;

    if (NfcF.from(tag) != null) {
      final nfcf = NfcF.from(tag);
      res = await nfcf?.transceive(data: command);
    } else if (FeliCa.from(tag) != null) {
      final felica = FeliCa.from(tag);
      res = await felica?.sendFeliCaCommand(command);
    }
    if (res == null) {
      throw Exception();
    }
    return res;
  }

  /// RequestService
  Future<Uint8List> requestService(
    NfcTag tag, {
    required Uint8List idm,
    required List<int> serviceCode,
  }) async {
    final nodeCodeList = Uint8List.fromList(serviceCode);
    final List<int> packet = [];
    if (Platform.isAndroid) {
      packet.add(0x06);
    }
    packet.add(0x02);
    packet.addAll(idm);
    packet.add(nodeCodeList.elementSizeInBytes);
    packet.addAll(serviceCode.reversed);

    final command = Uint8List.fromList(packet);

    late final Uint8List? res;

    if (NfcF.from(tag) != null) {
      final nfcf = NfcF.from(tag);
      res = await nfcf?.transceive(data: command);
    } else if (FeliCa.from(tag) != null) {
      final felica = FeliCa.from(tag);
      res = await felica?.sendFeliCaCommand(command);
    }
    if (res == null) {
      throw Exception();
    }
    return res;
  }

  /// ReadWithoutEncryption
  Future<Uint8List> readWithoutEncryption(
    NfcTag tag, {
    required Uint8List idm,
    required List<int> serviceCode,
    required int blockCount,
  }) async {
    final List<int> packet = [];
    if (Platform.isAndroid) {
      packet.add(0);
    }
    packet.add(0x06);
    packet.addAll(idm);
    packet.add(serviceCode.length ~/ 2);
    packet.addAll(serviceCode.reversed);
    packet.add(blockCount);

    for (int i = 0; i < blockCount; i++) {
      packet.add(0x80);
      packet.add(i);
    }
    if (Platform.isAndroid) {
      packet[0] = packet.length;
    }

    final command = Uint8List.fromList(packet);

    late final Uint8List? res;

    if (NfcF.from(tag) != null) {
      final nfcf = NfcF.from(tag);
      res = await nfcf?.transceive(data: command);
    } else if (FeliCa.from(tag) != null) {
      final felica = FeliCa.from(tag);
      res = await felica?.sendFeliCaCommand(command);
    }
    if (res == null) {
      throw Exception();
    }
    return res;
  }

  /// バイトデータの変換
  int parse(Uint8List rweRes) {
    if (rweRes[10] != 0x00) {
      throw Exception();
    }
    final blockSize = rweRes[12];
    const blockLength = 16;
    final data = List.generate(
        blockSize,
        (index) =>
            Uint8List.fromList(List.generate(blockLength, (index) => 0)));
    for (int i = 0; i < blockSize; i++) {
      final offset = 13 + i * blockLength;
      final tmp = rweRes.sublist(offset, offset + blockLength);
      data[i] = tmp;
    }
    final balanceData =
        Uint8List.fromList(data[0].sublist(0, 4).reversed.toList());
    return balanceData.buffer.asByteData().getInt32(0);
  }
}

さいごに

NFC(FeliCa)の操作はなかなか1回では理解するのは難しいと思いますが、1度理解してしまえば割と簡単なので是非勉強してみてください。

Discussion