やりたいこと
FeliCaカードの固有IDであるIDmを読むサンプルはよくあるが、各種電子マネーの情報を読むサンプルはあまりないので、手元にあったWAONカードの情報(とりあえずWAON番号)を読んでみます。
WAONカードはミニストップなどで300円で売っている。
前提
- 言語はJavaを利用
- 開発はAndroid Studio 3.4.x on Mac
- 検証実機はXperia XZ Premium(SO-04j)
- FeliCa(NFC)の操作法に重点を置きたいのでエラー処理とかは最低限
- IDmの取得もtag.getId()とかではなくFeliCaコマンドで行う
仕様
以下のような感じとする。
- idm, pmm, WAON番号表示用のTextViewを配置
- polling start, stopボタンを配置
- polling startボタンを押したらpollingを開始し、カードがかざされたら各種IDを取得・表示
- polling stopボタンでpollingを停止
見た目は以下のような感じ。
処理の流れ
カードを認識して読み取るまでの大まかな流れは以下の通り。
- NfcAdapterを生成し、ReaderModeで読み取る(読み取りを待ち受ける)
- 認識されたカードはTagとして取得される
- Tagを指定してNfcFを生成し、通信を行う(各種コマンドを実行する)
- Pollingコマンドでカードの認識ID(IDm)を取得
- 対象特定のためIDmを添付して、読み取りコマンド(readWithoutEncryption)を実行
- 取得した内容を表示するなどする
準備
NFC機能を利用するにはAndroidManifext.xmlに以下の追記。
<uses-permission android:name="android.permission.NFC" />
そもそも、端末自体のReader/Writer機能をOnにする必要もあります。
実装
雑に実装してみます。
MainActivity.java
今回はボタンで読み取り機能のOn/OffをしたいのでReaderModeを利用しています。
そのenable/disableをstartボタン、stopボタンでそれぞれ制御しています。
カード(Tag)が認識されたらCallbackクラスのonTagDiscovered()が呼び出されるので、その中で必要な処理をします。
FeliCaコマンドを実行するにはtranscive()を利用します。今回はpollingとreadWithoutEncryptionの2つを実行しています。
pollingは通信先のカードID(IDm)を特定するために必須となります。
readWithoutEncryptionでは読み取る「場所」をサービスコードで指定する必要があります。今回はWAON番号(カード番号)を読み取りたいので0x68, 0x4fを指定しています。
サービスコードは公開されていませんが、有志によって調べられています。
WAONの他のサービス(残高とか)、他のカードの情報を利用したい場合、サービスコードやフォーマットを入手しましょう。
なお、FeliCaには鍵あり領域と鍵なし領域があり、チャージ等の領域は「鍵あり領域」に保持されているため第三者が操作することはできません。念の為。
package jp.bluecode.waon_java;
import android.nfc.NfcAdapter;
import android.nfc.Tag;
import android.nfc.tech.NfcF;
import android.os.Handler;
import android.os.Looper;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Arrays;
import java.util.Formatter;
public class MainActivity extends AppCompatActivity {
//Widgetの宣言
TextView txt_idm;
TextView txt_pmm;
TextView txt_waonno;
Button btn_start;
Button btn_stop;
//NfcAdapterの宣言
NfcAdapter nfcAdapter;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
//Widgetの初期化
txt_idm = findViewById(R.id.txt_idm);
txt_pmm = findViewById(R.id.txt_pmm);
txt_waonno = findViewById(R.id.txt_waonno);
btn_start = findViewById(R.id.btn_start);
btn_stop = findViewById(R.id.btn_stop);
//初期設定(トグル)
btn_stop.setEnabled(false);
//NfcAdapterの初期化
nfcAdapter = NfcAdapter.getDefaultAdapter(this);
//onClickイベントの設定
btn_start.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
//トグル設定
btn_start.setEnabled(false);
btn_stop.setEnabled(true);
//TextView初期化
txt_idm.setText("");
txt_pmm.setText("");
txt_waonno.setText("");
//ReaderMode On
nfcAdapter.enableReaderMode(MainActivity.this,new MyReaderCallback(),NfcAdapter.FLAG_READER_NFC_F,null);
}
});
btn_stop.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
//トグル設定
btn_start.setEnabled(true);
btn_stop.setEnabled(false);
//ReaderMode Off
nfcAdapter.disableReaderMode(MainActivity.this);
}
});
}
//Callback用 inner class記述
private class MyReaderCallback implements NfcAdapter.ReaderCallback{
@Override
public void onTagDiscovered(Tag tag){
//タグが見つかったらとりあえずログ出力
Log.d("Hoge","Tag Discovered!");
//FeliCaと通信するためのNfcFを初期化
NfcF nfc = NfcF.get(tag);
try{
nfc.connect();
//とりあえずいろいろFeliCaの生コマンドで制御。idmとかならtag.getId()とかで取れる。
//pollingコマンド(FeliCa生コマンド) 共通領域のシステムコード0xFE 0x00を指定。
byte[] polling_request = {(byte)0x06,(byte)0x00,(byte)0xFE,(byte)0x00,(byte)0x00,(byte)0x00};
//response受け取り用byte配列
//コマンド送信・受信
byte[] polling_response = nfc.transceive(polling_request);
//idmの取り出し
byte[] idm = Arrays.copyOfRange(polling_response,2,10);
//pmmの取り出し(ついで)
byte[] pmm = Arrays.copyOfRange(polling_response,11,19);
//byte列を文字列に変換
final String idmString = bytesToHexString(idm);
final String pmmString = bytesToHexString(pmm);
//waonno処理
//waonnno request
//カスタム関数をつかってrequestコマンドを組み立て
byte[] waonno_request = readWithoutEncryption(idm,2);
//コマンド送信・受信
byte[] wannno_response = nfc.transceive(waonno_request);
//WAON番号部分を切り取り
byte[] waonno = Arrays.copyOfRange(wannno_response,13,21);
//文字列変換
final String waonnoString = bytesToHexString(waonno);
//親スレッドのUI更新
Handler mainHandler = new Handler(Looper.getMainLooper());
mainHandler.post(new Runnable() {
@Override
public void run() {
txt_idm.setText(idmString);
txt_pmm.setText(pmmString);
txt_waonno.setText(waonnoString);
}
});
nfc.close();
}catch(Exception e){
Log.e("Hoge",e.getMessage());
}
}
}
//非暗号領域読み取りコマンド(WAON番号領域特化)
private byte[] readWithoutEncryption(byte[] idm, int blocksize) throws IOException {
ByteArrayOutputStream bout = new ByteArrayOutputStream(100); //とりあえず
//readWithoutEncryptionコマンド組み立て
bout.write(0); //コマンド長(後で入れる)
bout.write(0x06); //0x06はRead Without Encryptionを表す
bout.write(idm); //8byte:idm
bout.write(1); //サービス数
bout.write(0x4f); //サービスコードリスト WAONカード番号は684F
bout.write(0x68); //サービスコードリスト
bout.write(blocksize); //ブロック数
for(int i=0; i<blocksize; i++){
bout.write(0x80); //ブロックリスト
bout.write(i);
}
byte[] msg = bout.toByteArray();
msg[0] = (byte)msg.length;
return msg;
}
//bytesを16進数型文字列に変換用関数
private String bytesToHexString(byte[] bytes){
StringBuilder sb = new StringBuilder();
Formatter formatter = new Formatter(sb);
for(byte b: bytes){
formatter.format("%02x",b);
}
//大文字にして戻す(見た目の調整だけ)
return sb.toString().toUpperCase();
}
}
以上です。
activity_main.xml
参考程度にactivity_main.xmlも記載しておきますが、お好みでレイアウトしたほうがいいでしょう。
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="@+id/lbl_top"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:text="WAON番号 Viewer"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/lbl_idm"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="68dp"
android:layout_marginTop="32dp"
android:text="IDm:"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/lbl_top" />
<TextView
android:id="@+id/txt_idm"
android:layout_width="180dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="32dp"
android:background="#E8EAF6"
app:layout_constraintStart_toEndOf="@+id/lbl_idm"
app:layout_constraintTop_toBottomOf="@+id/lbl_top" />
<TextView
android:id="@+id/lbl_pmm"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="60dp"
android:layout_marginTop="24dp"
android:text="PMm:"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/lbl_idm" />
<TextView
android:id="@+id/txt_pmm"
android:layout_width="180dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="24dp"
android:background="#E8EAF6"
app:layout_constraintStart_toEndOf="@+id/lbl_pmm"
app:layout_constraintTop_toBottomOf="@+id/txt_idm" />
<TextView
android:id="@+id/lbl_wanno"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="24dp"
android:text="WAON番号:"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/lbl_pmm" />
<TextView
android:id="@+id/txt_waonno"
android:layout_width="180dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="24dp"
android:background="#F3E5F5"
app:layout_constraintStart_toEndOf="@+id/lbl_wanno"
app:layout_constraintTop_toBottomOf="@+id/txt_pmm" />
<Button
android:id="@+id/btn_start"
android:layout_width="160dp"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:text="Start Polling"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/txt_waonno" />
<Button
android:id="@+id/btn_stop"
android:layout_width="160dp"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Stop Polling"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/btn_start" />
</android.support.constraint.ConstraintLayout>
AndroidManifest.xml
こちらも参考程度に。編集しているのは、冒頭に触れたNFCのパーミッションだけです。
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="jp.bluecode.waon_java">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
<uses-permission android:name="android.permission.NFC" />
</manifest>