Help us understand the problem. What is going on with this article?

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

はじめに

この記事は、SLP KBIT Advent Calendar 2019 の8日目の記事です。

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

読み取るデータについて

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

自分が持っている学生証は、Felicaに対応したICカードになっているので、NFCのFelicaに対応した端末で読み取ることができます。Felicaの規格に関しては、 FeliCaカード ユーザーズマニュアル 抜粋版 にまとめられています。

学生証のデータ構造に関しては、 大学生協Felicaの仕様 に要約されていたので、これを参考にしました。

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

  • システムコード 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

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

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)のブロックデータの取得

処理内容はほぼ一緒ですが、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 のIDmを取得(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; // 先頭1バイトはデータ長
        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; // 先頭1バイトはデータ長
        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には様々な機能があるので、時間があれば色々試してみたいと思いました。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした