Androidで学生証(Felica)のIC残高を読む

# はじめに

この記事は、[SLP KBIT Advent Calendar 2019](https://adventar.org/calendars/4214) の8日目の記事です。

最近、AndroidのNFC機能を使って、ICカードのデータを読み取ることができることを知りました。
そこで「自分の学生証も読み取れるのでは？」と思ったので試してみました。

# 読み取るデータについて

今回は学生証に格納されている大学生協のIC残高を読み取ってみたいと思います。

自分が持っている学生証は、Felicaに対応したICカードになっているので、NFCのFelicaに対応した端末で読み取ることができます。Felicaの規格に関しては、 [FeliCaカード ユーザーズマニュアル 抜粋版](https://www.sony.co.jp/Products/felica/business/tech-support/st_usmnl.html) にまとめられています。

学生証のデータ構造に関しては、 [大学生協Felicaの仕様](https://gist.github.com/oboenikui/ee9fb0cb07a6690c410b872f64345120) に要約されていたので、これを参考にしました。


今回取得したいデータは、

- システムコード **0xFE00**
- サービスコード **0x50D7**

に **Purse Service** として格納されています。
Purse Service の仕様によると、実際の残高は上位4バイトに、リトルエンディアン形式で格納されています。
そのためデータを取得した後に4バイトを抜き出して、ビッグエンディアン形式に変換する必要があります。

大まかにまとめると、以下の様な構造になっています。

```
- System 0:
    - ...
- System 1: (0xFE00)
    - IDm
    - Area
    - ...
    - Area
        - Service
        - ...
        - Purse Service: (0x50D7) <- ここ
```


# AndroidでFelicaの読み取り

全体的な処理は MainActivity.java で、
Felicaからデータを取得する処理は NfcReader.java で行います。

MainActivity.javaで行う処理は、以下のページを参考にしました。
[[Android]NFCを読み取ってみる : 工作と競馬 - Livedoor](http://blog.livedoor.jp/sce_info3-craft/archives/9588576.html)


別のカードやデータ等を読み取りたい時のために、カードの固有値を定数化してあります。

```MainActivity.java

import androidx.appcompat.app.AppCompatActivity;

import android.os.Bundle;
import android.app.PendingIntent;
import android.content.Intent;
import android.nfc.NfcAdapter;
import android.nfc.Tag;
import android.widget.Toast;

import java.nio.ByteBuffer;


public class MainActivity extends AppCompatActivity {

    // 定数
    private final byte[] TARGET_SYSTEM_CODE = new byte[]{(byte) 0xFE, (byte) 0x00};
    private final byte[] TARGET_SERVICE_CODE = new byte[]{(byte) 0x50, (byte) 0xD7};
    private final int TARGET_SIZE = 1;

    // アダプタを扱うための変数
    private NfcAdapter mNfcAdapter;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // アダプタのインスタンスを取得
        mNfcAdapter = NfcAdapter.getDefaultAdapter(this);
    }

    @Override
    protected void onResume() {

        super.onResume();

        // NFCがかざされたときの設定
        Intent intent = new Intent(this, this.getClass());
        intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);

        // ほかのアプリを開かないようにする
        PendingIntent pendingIntent = PendingIntent.getActivity(getApplicationContext(), 0, intent, 0);
        mNfcAdapter.enableForegroundDispatch(this, pendingIntent, null, null);

    }

    @Override
    protected void onPause() {
        super.onPause();

        // Activityがバックグラウンドになったときは、受け取らない
        mNfcAdapter.disableForegroundDispatch(this);
    }

    @Override
    protected void onNewIntent(Intent intent) {
        NfcReader nfcReader = new NfcReader();
        super.onNewIntent(intent);

        // NFCのTAGを取得
        Tag tag = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG);

        // 残高のデータを読み取る
        byte[][] data = nfcReader.readTag(tag, TARGET_SYSTEM_CODE, TARGET_SERVICE_CODE, TARGET_SIZE);

        // 読み取れなかった場合は終了
        if (data == null) {
            Toast.makeText(this, "error", Toast.LENGTH_SHORT).show();
            return;
        }

        // 変換
        byte[] balanceData = new byte[]{data[0][3], data[0][2], data[0][1], data[0][0]};
        int balance = ByteBuffer.wrap(balanceData).getInt();

        // 表示
        Toast.makeText(this, balance + "円", Toast.LENGTH_LONG).show();
    }
}
```
---
次に NfcReader.java を作成します。
NfcReader.java は以下のページのコードを参考にしました。
[AndroidでFelica(NFC)のブロックデータの取得](https://qiita.com/nshiba/items/38f94d61c020a17314b6)

処理内容はほぼ一緒ですが、readTagメソッドにカードの固有値を引数で渡せるよう変更してあります。

```NfcReader.java

import android.nfc.Tag;
import android.nfc.tech.NfcF;
import android.util.Log;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Arrays;

import static android.content.ContentValues.TAG;

class NfcReader {

    public byte[][] readTag(Tag tag, byte[] targetSystemCode, byte[] targetServiceCode, int size) {
        NfcF nfc = NfcF.get(tag);
        if (nfc == null) {
            return null;
        }
        try {
            nfc.connect();

            // polling コマンドを作成
            byte[] polling = polling(targetSystemCode);

            // コマンドを送信して結果を取得
            byte[] pollingRes = nfc.transceive(polling);

            // System 0 のIDｍを取得(1バイト目はデータサイズ、2バイト目はレスポンスコード、IDmのサイズは8バイト)
            byte[] targetIDm = Arrays.copyOfRange(pollingRes, 2, 10);

            // Read Without Encryption コマンドを作成
            byte[] req = readWithoutEncryption(targetIDm, size, targetServiceCode);

            // コマンドを送信して結果を取得
            byte[] res = nfc.transceive(req);

            nfc.close();

            // 結果をパースしてデータだけ取得
            return parse(res);
        } catch (Exception e) {
            Log.e(TAG, e.getMessage() , e);
        }
        return null;
    }

    /*
     * Pollingコマンドの取得。
     * @param systemCode byte[] 指定するシステムコード
     * @return Pollingコマンド
     * @throws IOException
     */
    private byte[] polling(byte[] systemCode) {
        ByteArrayOutputStream bout = new ByteArrayOutputStream(100);

        bout.write(0x00);           // データ長バイトのダミー
        bout.write(0x00);           // コマンドコード
        bout.write(systemCode[0]);  // systemCode
        bout.write(systemCode[1]);  // systemCode
        bout.write(0x01);           // リクエストコード
        bout.write(0x0f);           // タイムスロット

        byte[] msg = bout.toByteArray();
        msg[0] = (byte) msg.length; // 先頭１バイトはデータ長
        return msg;
    }

    /*
     * Read Without Encryptionコマンドの取得。
     * @param IDm 指定するシステムのID
     * @param size 取得するデータの数
     * @return Read Without Encryptionコマンド
     * @throws IOException
     */
    private byte[] readWithoutEncryption(byte[] idm, int size, byte[] serviceCode) throws IOException {
        ByteArrayOutputStream bout = new ByteArrayOutputStream(100);

        bout.write(0);              // データ長バイトのダミー
        bout.write(0x06);           // コマンドコード
        bout.write(idm);            // IDm 8byte
        bout.write(1);              // サービス数の長さ(以下2バイトがこの数分繰り返す)

        // サービスコードの指定はリトルエンディアンなので、下位バイトから指定します。
        bout.write(serviceCode[1]); // サービスコード下位バイト
        bout.write(serviceCode[0]); // サービスコード上位バイト
        bout.write(size);           // ブロック数

        // ブロック番号の指定
        for (int i = 0; i < size; i++) {
            bout.write(0x80);       // ブロックエレメント上位バイト 「Felicaユーザマニュアル抜粋」の4.3項参照
            bout.write(i);          // ブロック番号
        }

        byte[] msg = bout.toByteArray();
        msg[0] = (byte) msg.length; // 先頭１バイトはデータ長
        return msg;
    }

    /*
     * Read Without Encryption応答の解析。
     * @param res byte[]
     * @return 文字列表現
     * @throws Exception
     */
    private byte[][] parse(byte[] res) {
        // res[10] エラーコード。0x00の場合が正常
        if (res[10] != 0x00) {
            throw new RuntimeException("Read Without Encryption Command Error");
        }

        // res[12] 応答ブロック数
        // res[13 + n * 16] 実データ 16(byte/ブロック)の繰り返し
        int size = res[12];
        byte[][] data = new byte[size][16];
        for (int i = 0; i < size; i++) {
            byte[] tmp = new byte[16];
            int offset = 13 + i * 16;
            for (int j = 0; j < 16; j++) {
                tmp[j] = res[offset + j];
            }

            data[i] = tmp;
        }
        return data;
    }
}

```
# 使ってみた
学生証をスマホにかざしてみると、残高が正確に表示されました。
レシートと比較しても一致していました。
よかった。

# おわりに
Androidでの開発は初めてだったのですが、思っていたよりも面白かったです。
Androidには様々な機能があるので、時間があれば色々試してみたいと思いました。