はじめに
この記事は、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
別のカードやデータ等を読み取りたい時のために、カードの固有値を定数化してあります。
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メソッドにカードの固有値を引数で渡せるよう変更してあります。
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には様々な機能があるので、時間があれば色々試してみたいと思いました。