Android NFC读卡器,仿真卡流程学习

学习资料:

感谢laocaixw大佬,找了半天NFC相关开发的博客,终于找到一个简单明了的,就把代码抄了下来,以便之后再看

一台支持NFCAndroid手机,可以作为读卡器来读取一张银行卡或者公交卡,也可以模拟成一张卡来进行刷卡消费,也就是我所说的仿真卡,属于HCE相关开发

公司现在的项目属于HCE业务项目,要模拟银行卡,也提前了解些NFC相关的东西

本篇中的案例,需要两个支持NFC的手机才可以演示,一个作为读卡器,一个模拟卡实现仿真卡,当读卡器仿真卡贴在一起后,读卡器会先发送一个指令给仿真卡仿真卡验证指令后,就可以返回数据给读卡器

案例中获取卡号的流程只是简单演示用的,随意返回了一个16位卡号。实际获取卡号的流程比这复杂的多,需要发送多个指令才能拿到卡的有效信息


1.读卡器代码

权限

<uses-permission android:name="android.permission.NFC" />
<!--声明需要硬件支持nfc-->
<uses-feature
        android:name="android.hardware.nfc"
        android:required="true" />

actiivty配置

 <activity
            android:name=".NFCActivity"
            android:label="@string/nfc_name"
            android:launchMode="singleTop"
            android:screenOrientation="portrait" />

launchMode使用的是栈顶复用模式,activity启动自身,会执行onNewIntent()方法

屏幕锁死了竖屏,以避免手机在横竖屏切换时,导致Intent信息丢失


1.1 Activity代码

NFCActivity代码

public class NFCActivity extends AppCompatActivity {
    private final String TAG = NFCActivity.class.getSimpleName();
    private NfcAdapter mNfcAdapter;
    private PendingIntent mPendingIntent;
    private IntentFilter[] mIntentFilter;
    private String[][] mTechList;
    private TextView mTvView;

    // 卡片返回来的正确信号
    private final byte[] SELECT_OK = stringToBytes("1000");


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

        initView();

        nfcCheck();

        init();
    }

    private void initView() {
        mTvView = (TextView) findViewById(R.id.nfc_activity_tv_info);
    }

    /**
     * 初始化
     */
    private void init() {
        // NFCActivity 一般设置为: SingleTop模式 ,并且锁死竖屏,以避免屏幕旋转Intent丢失
        Intent intent = new Intent(NFCActivity.this, NFCActivity.class);

        // 私有的请求码
        final int REQUEST_CODE = 1 << 16;

        final int FLAG = 0;
        mPendingIntent = PendingIntent.getActivity(NFCActivity.this, REQUEST_CODE, intent, FLAG);

        // 三种过滤器
        IntentFilter ndef = new IntentFilter(NfcAdapter.ACTION_NDEF_DISCOVERED);
        IntentFilter tech = new IntentFilter(NfcAdapter.ACTION_TECH_DISCOVERED);
        IntentFilter tag = new IntentFilter(NfcAdapter.ACTION_TAG_DISCOVERED);
        mIntentFilter = new IntentFilter[]{ndef, tech, tag};

        // 只针对ACTION_TECH_DISCOVERED
        mTechList = new String[][]{
                {IsoDep.class.getName()}, {NfcA.class.getName()}, {NfcB.class.getName()},
                {NfcV.class.getName()}, {NfcF.class.getName()}, {Ndef.class.getName()}};
    }

    @Override
    protected void onNewIntent(Intent intent) {
        super.onNewIntent(intent);
        // IsoDep卡片通信的工具类,Tag就是卡
        IsoDep isoDep = IsoDep.get((Tag) intent.getParcelableExtra(NfcAdapter.EXTRA_TAG));
        if (isoDep == null) {
            String info = "读取卡信息失败";
            toast(info);
            return;
        }
        try {
            // NFC与卡进行连接
            isoDep.connect();

            final String AID = "F123466666";
            //转换指令为byte[]
            byte[] command = buildSelectApdu(AID);

            // 发送指令
            byte[] result = isoDep.transceive(command);

            // 截取响应数据
            int resultLength = result.length;
            byte[] statusWord = {result[resultLength - 2], result[resultLength - 1]};
            byte[] payload = Arrays.copyOf(result, resultLength - 2);

            // 检验响应数据
            if (Arrays.equals(SELECT_OK, statusWord)) {
                String accountNumber = new String(payload, "UTF-8");
                Log.e(TAG, "----> " + accountNumber);
                mTvView.setText(accountNumber);
            } else {
                String info = bytesToString(result);
                Log.e(TAG, "----> error" + info);
                mTvView.setText(info);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
 
    /**
     * 开启检测,检测到卡后,onNewIntent() 执行
     * enableForegroundDispatch()只能在onResume() 方法中,否则会报:
     * Foreground dispatch can only be enabled when your activity is resumed
     */
    @Override
    protected void onResume() {
        super.onResume();
        if (mNfcAdapter == null) return;
        mNfcAdapter.enableForegroundDispatch(this, mPendingIntent, mIntentFilter, mTechList);
    }

    /**
     * 关闭检测
     */
    @Override
    protected void onPause() {
        super.onPause();
        if (mNfcAdapter == null) return;
        mNfcAdapter.disableForegroundDispatch(this);
    }

    /**
     * 检测是否支持 NFC
     */
    private void nfcCheck() {
        mNfcAdapter = NfcAdapter.getDefaultAdapter(this);
        if (mNfcAdapter == null) {
            String info = "手机不支付NFC功能";
            toast(info);
            return;
        }
        if (!mNfcAdapter.isEnabled()) {
            String info = "手机NFC功能没有打开";
            toast(info);
            Intent setNfc = new Intent(Settings.ACTION_NFC_SETTINGS);
            startActivity(setNfc);
        } else {
            String info = "手机NFC功能正常";
            toast(info);
        }
    }

    private byte[] stringToBytes(String s) {
        int len = s.length();
        if (len % 2 == 1) {
            throw new IllegalArgumentException("指令字符串长度必须为偶数 !!!");
        }
        byte[] data = new byte[len / 2];
        for (int i = 0; i < len; i += 2) {
            data[(i / 2)] = ((byte) ((Character.digit(s.charAt(i), 16) << 4) + Character
                    .digit(s.charAt(i + 1), 16)));
        }
        return data;
    }

    private String bytesToString(byte[] data) {
        StringBuilder sb = new StringBuilder();
        for (byte d : data) {
            sb.append(String.format("%02X", d));
        }
        return sb.toString();
    }


    private byte[] buildSelectApdu(String aid) {
        final String HEADER = "00A40400";
        return stringToBytes(HEADER + String.format("%02X", aid.length() / 2) + aid);
    }

    private void toast(String info) {
        Toast.makeText(NFCActivity.this, info, Toast.LENGTH_SHORT).show();
    }
}

onResume()onPause()分别就是,一旦在onResume()中检测到卡,会在onNewIntent()方法中执行读卡信息


2. 仿真卡代码

权限:

    <uses-permission android:name="android.permission.NFC" />
    <!-- 声明需要硬件支持nfc -->
    <uses-feature
        android:name="android.hardware.nfc.hce"
        android:required="true" />

配置:

<!--仿真卡服务-->
<service
    android:name=".CardService"
    android:exported="true"
    android:permission="android.permission.BIND_NFC_SERVICE">

    <intent-filter>
         z<action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" />
                <category android:name="android.intent.category.DEFAULT" />
    </intent-filter>
    
    <meta-data
          android:name="android.nfc.cardemulation.host_apdu_service"
          android:resource="@xml/aid_list" />
</service>

在res下建立一个xml文件夹,创建aid_li文件st

<?xml version="1.0" encoding="utf-8"?>

<host-apdu-service xmlns:android="http://schemas.android.com/apk/res/android"
    android:description="@string/service_name" 
    android:requireDeviceUnlock="false">
    <aid-group
        android:category="other"
        android:description="@string/card_title">
        <aid-filter
            android:name="F123466666" />
    </aid-group>

</host-apdu-service>

android:requireDeviceUnlock="false"程序运行,手机亮屏不解锁的情况下,服务可以启动

android:name="F123466666"这一行很关键

读卡器想要识别一个卡,肯定要有一个识别的标记,这个就是指定的识别标记,需要和代码中发送的指令进行统一。这个是我瞎写的, 必须偶数位


2.1 CardService代码

@TargetApi(Build.VERSION_CODES.KITKAT)
public class CardService extends HostApduService {
    // 正确信号
    private byte[] SELECT_OK = hexStringToByteArray("1000");

    // 错误信号
    private byte[] UNKNOWN_ERROR = hexStringToByteArray("0000");

    /**
     * 接收到 NFC 读卡器发送的应用协议数据单元 (APDU) 调用
     * 注意:此方法回调在UI线程,若进行联网操作时,需开子线程
     * 并先返回null,当子线程有数据结果后,再进行回调返回处理
     */
    @Override
    public byte[] processCommandApdu(byte[] commandApdu, Bundle extras) {
        final String AID = "F123466666";

        // 将指令转换成 byte[]
        byte[] selectAPDU = buildSelectApdu(AID);

        // 判断是否和读卡器发来的数据相同
        if (Arrays.equals(selectAPDU, commandApdu)) {
            // 直接模拟返回16位卡号
            String account = "6222222200000001";

            // 获取卡号 byte[]
            byte[] accountBytes = account.getBytes();

            // 处理欲返回的响应数据
            return concatArrays(accountBytes, SELECT_OK);
        } else {
            return UNKNOWN_ERROR;
        }
    }

    @Override
    public void onDeactivated(int reason) {

    }

    private byte[] hexStringToByteArray(String s) throws IllegalArgumentException {
        int len = s.length();
        if (len % 2 == 1) {
            throw new IllegalArgumentException("指令字符串长度必须为偶数 !!!");
        }
        byte[] data = new byte[len / 2];
        for (int i = 0; i < len; i += 2) {
            data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4)
                    + Character.digit(s.charAt(i + 1), 16));
        }
        return data;
    }

    private byte[] buildSelectApdu(String aid) {
        final String HEADER = "00A40400";
        return hexStringToByteArray(HEADER + String.format("%02X", aid.length() / 2) + aid);
    }

    private byte[] concatArrays(byte[] first, byte[]... rest) {
        int totalLength = first.length;
        for (byte[] array : rest) {
            totalLength += array.length;
        }
        byte[] result = Arrays.copyOf(first, totalLength);
        int offset = first.length;
        for (byte[] array : rest) {
            System.arraycopy(array, 0, result, offset, array.length);
            offset += array.length;
        }
        return result;
    }
}

简易的一个流程就是这样,坑还很多,之后项目实际开发完成后,再来补充下实际开发中遇到的坑


2.1 一些已经知道的浅坑

项目中开发的需求是:当使用 App 仿真卡与 POS机靠近后,要求弹出卡面,指纹验证后,进行交易

  1. 写上面的代码学习时,身边没有POS机,也不清楚具体的指令,就使用了两个手机来学习,但手机还是和POS机硬件有些差别的,和手机一样,POS机厂商也会对自己的POS机做一些有别与其他品牌的优化之类的

  2. POS机发来一个指令后,当不能立即响应指令时,仿真卡在processCommandApdu ()方法可以先返回NULL的。例如,我们项目的一个需求,仿真卡一接到PPSE指令时,在返回响应指令前,需要手机端先进行指纹验证时,就可以先返回NULL,在经过指纹验证之后,再使用sendResponeApdu()方法再来发送响应指令。需要注意的是,不同的POS机,等待响应指令的时间可能不同


3. 最后

最近接触到了一些银行POS业务,被POS机交易需要用到8583报文折磨到吐,感叹JSON真方便

有错误,请指出

共勉 :)

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343

推荐阅读更多精彩内容