22
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

FlutterでNFC機能を使用して学生証のプリペイド残高を取得してみた

Posted at

はじめに

 この記事では、Flutterで学生証のプリペイド残高を読み込んだときの手順を書いていくぜ!
 FlutterでNFC通信をして実際に欲しいデータを取得するような記事がまじで見つからない。あっても大体はID取得して終了みたいな感じだったので、Javaで実装しているこちらのサイトパクっ参考にしてバッコリ書いていくぜ!

やってみる

 とりあえず、みんなが早くやれとせかしてくるから、書いていきます。だから落ち着いてくれ!

使用するライブラリ

 FlutterにもNFC通信をするためのライブラリはたくさんありますが、今回は「nfc_mangaer」を使用していきます。
 ライブラリ読み込み後は忘れないうちに以下のパーミッションも設定しておきましょう

AndroidManifest.xml
<uses-permission android:name="android.permission.NFC" />
<uses-feature
        android:name="android.hardware.nfc"
        android:required="true" />

 ライブラリの読み方の方法については書かないので、分からない人は自分で調べてください!難しいことはなにもないので!

Felica(NfcF)読み込み

 まず、Felicaを読み込む前に、端末がそもそもNFCに対応しているのか、Felicaに対応しているかを確認する必要があります。確認方法は以下。

hoge.dart
bool check = NfcManager.instance.isAvailable() // 端末がNFCに対応しているかbool型で返ってくる(true:対応 false:非対応)

NfcManager.instance.startTagSession(onDiscovered: (NfcTag tag) async {

      // ここでFelica(NfcF)に対応しているか確認
      FeliCa felica(tag); // felica==nullなら非対応
      nfcf = NfcF.fromTag(tag); // nfcf==nullなら非対応

      NfcManager.instance.stopSession();  // 読み込み終了
});

 FelicaとNfcFが出てきて、どっち!?ってなったと思います。結論どっちでもOKです。AndroidはNfcFでiOSならFelicaみたいな認識でOK(筆者はAndroid)

対応が確認できたら、次に実際にNFC通信を行っていきます。大学生協Felicaの使用に学生証のデータ構造の要約があったため、これを基に必要なデータを取得していきます。(ちなみに筆者はこれを調べる前は自力で解読しにいくほどの大馬鹿野郎です。)
 ちなみに今回この中で使用するコードは以下の2つです
-システムコード:(0xFE00)
-サービスコード:(0x50D7)
 また、Felicaの仕様についてはFelicaカード ユーザーズマニュアル抜粋版にまとめられています。

ここからは、実際に筆者が書いたコードをもとに解説していくぜ~

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

import 'gakuseiNFC.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key}) : super(key: key);

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int balance = 0;
  NfcF nfcf;
  FeliCa felica;

  @override
  Widget build(BuildContext context) {
    _readTag();

    return Scaffold(
      appBar: AppBar(
        title: Text("学生証のプリペイド残高を読み込むぜ!"),  // アクションバーのタイトル
      ),

//      NFCに対応しているかを確認
      body:FutureBuilder(
        future: NfcManager.instance.isAvailable(),  // これでNFCに対応しているか確認する
        builder: (context,ss){  // 値が返ってきたら次の画面を描画する

          // 対応していない時(ss.data == false)
          if(!ss.data){
            return Center(
              child: Text("対応していません"),
            );
          }

          // 対応しているとき(ss.data == true )
          else{
            return Center(
              child: Text(
                "$balance円",
                style: TextStyle(
                  fontSize: 60,
                ),
              ),
            );
          }
        },
      ),
    );
  }

  // 学生証読み込み
  _readTag(){
    NfcManager.instance.startTagSession(onDiscovered: (NfcTag tag) async {
      nfcf = NfcF.fromTag(tag);

      // NfcFに対応してなかったら
      if(nfcf == null){
        print("効果は無いみたいだ・・・");
      }

      // NfcFに対応していたら
      else{
        print("効果は抜群だ!!");
        var balance = await getBalance(nfcf); // プリペイド残高の取得
        setState(() {
          this.balance = balance; // 残高データの更新
        });
      }
      NfcManager.instance.stopSession();  // 読み込み終了
    });
  }
}

アプリ起動して、ウィジェットを作成する時に「_tagRead()」を実行して、学生証読み込みを受け付けます。(バックグラウンド内や、何度も読み込む方法が分かるかた教えてほしい...)
その後はNFCに端末が対応していれば、画面中央に対応していませんのメッセージを表示し、対応していれば残高をセットします。

gakuseiNFC.dart
import 'dart:typed_data';
import 'package:nfc_manager/platform_tags.dart';

getBalance(NfcF nfcf) async {
  List<int> SYSTEM_CODE = [0xFE,0x00];  // システムコード
  List<int> SERVICE_CODE = [0x50,0xD7]; // サービスコード
  int SIZE = 1; // 取得するデータ数

  var poll = await _polling(SYSTEM_CODE); // pollingコマンドの作成
  var pollingRes = await nfcf.transceive(poll); // コマンドを送信して、レスポンスの取得
  var targetIDm = await pollingRes.sublist(2,10); // System0のIDmを取得
  var req = await _readWithoutEncryption(targetIDm, SIZE, SERVICE_CODE); // ReadWithoutEncryptionコマンドの作成
  var res = await nfcf.transceive(req); // コマンドを送信して、レスポンスの取得
  var data = _parse(res); // パース
  var balanceData = Uint8List.fromList([data[0][3],data[0][2],data[0][1],data[0][0]]).buffer; // バッファの作成
  var balance = balanceData.asByteData().getInt32(0); // 32バイトで1つの整数を表現する
  return balance;
}

// Pollingコマンドの作成
_polling (systemCode) {
  List<int> bout = [];

  bout.add(0x00); // 一時値をいれておく
  bout.add(0x00); // pollingのリクエストコマンド
  bout.add(systemCode[0]);  // システムコードの上位バイト
  bout.add(systemCode[1]);  // システムコードの下位バイト
  bout.add(0x01); // pollingのレスポンスコード
  bout.add(0x0f); // タイムスロット

  Uint8List msg = Uint8List.fromList(bout); // Uint8Listに変換
  msg[0] = msg.length;  // 先頭をデータ長に置き換える
  return msg;
}

// ReadWithoutEncryptionコマンドの作成
_readWithoutEncryption(targetIDm,size,targetServiceCode) {
  List<int> bout = [];

  bout.add(0); // 一時値をいれておく
  bout.add(0x06); // ReadWithoutEncryptionのリクエストコード
  bout.addAll(targetIDm); // IDm
  bout.add(1); // サービス数の長さ

  // サービスコードの指定(リトルエンディアンであることに注意)
  bout.add(targetServiceCode[1]); // サービスコードの下位バイト
  bout.add(targetServiceCode[0]); // サービスコードの上位バイト
  bout.add(size); // 取得数(ブロック数)

  for(var i=0;i<size;i++){
    bout.add(0x80); // ブロックエレメント上位バイト
    bout.add(i);  // ブロック番号
  }

  var msg = Uint8List.fromList(bout); // Uint8Listに変換
  msg[0] = msg.length;  // 先頭をデータ長に置き換える
  return msg;
}

// ReadWithoutEncryption応答の解析
_parse(res){
  // エラー時
  if(res[10] != 0x00){
    print("ケーシィはテレポートした");
  }

  int size = res[12]; // 応答ブロック数
  var data = List.generate(size, (_) => Uint8List.fromList(List.generate(16, (_) => 0)));
  // 実データの繰り返し
  for(var i=0;i<size;i++){
    var tmp = Uint8List.fromList(List.generate(16,(_)=>0));
    int offset = 13+i*16;
    for(int j=0;j<16;j++){
      tmp[j] = res[offset+j]; // 実データから必要部分だけ取得
    }
    data[i] = tmp;
  }
  return data;
}

実際のNFC通信については、マニュアルを読まないと正直きついと思います。
説明も難しいので、今回は「getBalance(NfcF nfcf)」関数内を上からハマった部分だけ説明していこうかと思います

poll

 _pollingでコマンドを作成する際に、配列の先頭を最初に一時値を入れています。これは、コマンドを作成するとき先頭をデータ長にする必要があるためです。
最初の段階ではデータ長不明ですので、一旦値をいれておいて、最後にデータ長に置き換えるという処理を行っています。
 SystemCodeをわざわざ2つに分けて入れるのは、これもマニュアルを見ればわかるのですが、システムコードは2バイトで1つの値を取っているためです。

pollingRes

 これは、_pollingで作成したコマンドを送って、返ってきたときの値が入ってます。

targetIDm

 pollingResの2番目から10番目の値をいれていますが、これもマニュアルを読めばわかるものです。IDmが2~10番目に記録されているからです。

req

 先頭の一時保管はpollと同じ。
 サービスコードをなぜ入れ替えてるかというと、マニュアルにサービスコードはリトルエンディアンと書いてあるからです。リトルエンディアンが分からない人は自分でお願いします!とりあえず、リトルエンディアンだったら入れかえるって思っておけばOK(1,2,3→3,2,1的な)
 ブロックていうのは、1ブロックにつき情報が1まとめ分になっているものです。(語彙力神!!)例えば「2020/3/11は残高100円」ていう情報で1つ。2つ目以降にも同じような構成の情報が続く(2020/3/10は残高250円的な感じで)

res

 コマンドを送信して、返ってきた値を取得しているだけです。

data

 resの中身を解析しています。
 10番目の値が0だったら通信が成功したことになります。
 あとは、必要な部分だけをブロック数だけ取得します。(今回は1)

balanceData, balance

 大学生協のコード構成を見てみると、残高が記録されているのは0~3番目であり、これがリトルエンディアンの形で記録されています。そのため、0~3番目の順番を逆にして、これを1つの数字とみなすために、32バイト分の値として読み取ります(普通は8バイトで1つの数字をあらわしている)。くそ適当な例をだすと、[0100]という数字の塊があったとして、これを[0,1,0,0]と読み取って4つの値とみなすか、0100→100と読み取って1つの値とみなすか的な感じです。
 NFC取得するためにはバイトのことなども勉強しないといけないからまじでだるすぎた!!!!

最後に

 長々書いてるくせに、中身はめちゃめちゃ適当。無駄な時間を過ごせて最高だろ!?
 もっと他の値を取得してみたいという方は是非NFCやらバイトやら勉強してみてください。
 とりあえず残高さえ、取得できればOKの人はコピペすれば秒で取得できます。(大学生協のプリペイド残高のみ)
 プリペイドの使用履歴などを取得する記事も気が向いたら書いていこうかの~

22
17
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
22
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?