В этой статье будем реализовывать так называемую Host-based Card Emulation (HCE, Эмуляция банковской карты на телефоне). В сети много подробных описаний этой технологии, здесь я сделал акцент именно на получении работающих приложений эмулятора и ридера и решении ряда практических задач. Да, понадобятся 2 устройства с nfc.

Сценариев использования очень много: система пропусков, карты лояльности, транспортные карты, получение дополнительной информации об экспонатах в музее, менеджер паролей.

При этом приложение на телефоне, эмулирующем карту, может быть запущено или нет и экран вашего телефона может быть заблокирован.

Для Xamarin Android есть готовые примеры эмулятора карты и ридера.
Попробуем с помощью этих примеров сделать 2 приложения Xamarin Forms, эмулятор и ридер, и решить в них следующие задачи:

  1. выводить данные от эмулятора на экране ридера
  2. выводить данные от ридера на экране эмулятора
  3. эмулятор должен работать с незапущенным приложением и заблокированным экраном
  4. управление настройками эмулятора
  5. запуск приложения эмулятора при обнаружении ридера
  6. проверка состояния nfc-адаптера и переход в настройки nfc

Эта статья про андроид, поэтому, если у вас приложение также и под iOS, то там должна быть отдельная реализация.

Минимум теории.

Как написано в документации android, начиная с версии 4.4 (kitkat) добавлена возможность эмулировать ISO-DEP карты, и обрабатывать APDU-команды.

Эмуляция карт основана на сервисах android, известных как «HCE services».

Когда пользователь прикладывает устройство к NFC-ридеру, андроиду необходимо понять к какому HCE-сервису хочет подключиться ридер. В ISO/IEC 7816-4 описан способ выбора приложения, основанный на Application ID (AID).

Если интересно углубиться в прекрасный мир байтовых массивов, то здесь и здесь подробнее про APDU-команды. В данной статье используется всего пара команд, необходимых для обмена данными.

Приложение «Ридер»


Начнём с ридера, т.к. он проще.

Создаём в Visual Studio новый проект типа «Mobile App(Xamarin.Forms)» далее выбираем шаблон «Blank» и оставляем только галочку «Android» в разделе «Platforms».

В андроид-проекте надо сделать следующее:

  • Класс CardReader — в нём несколько констант и метод OnTagDiscovered
  • MainActivity — инициализация класса CardReader, а также методы OnPause и OnResume для включения/выключения ридера при сворачивании приложения
  • AndroidManifest.xml — разрешения для nfc

И в кроссплатформенном проекте в файле App.xaml.cs:

  • Метод для вывода сообщения пользователю

Класс CardReader


using Android.Nfc;
using Android.Nfc.Tech;
using System;
using System.Linq;
using System.Text;

namespace ApduServiceReaderApp.Droid.Services
{
    public class CardReader : Java.Lang.Object, NfcAdapter.IReaderCallback
    {
        // ISO-DEP command HEADER for selecting an AID.
        // Format: [Class | Instruction | Parameter 1 | Parameter 2]
        private static readonly byte[] SELECT_APDU_HEADER = new byte[] { 0x00, 0xA4, 0x04, 0x00 };

        // AID for our loyalty card service.
        private static readonly string SAMPLE_LOYALTY_CARD_AID = "F123456789";

        // "OK" status word sent in response to SELECT AID command (0x9000)
        private static readonly byte[] SELECT_OK_SW = new byte[] { 0x90, 0x00 };

        public async void OnTagDiscovered(Tag tag)
        {
            IsoDep isoDep = IsoDep.Get(tag);

            if (isoDep != null)
            {
                try
                {
                    isoDep.Connect();

                    var aidLength = (byte)(SAMPLE_LOYALTY_CARD_AID.Length / 2);
                    var aidBytes = StringToByteArray(SAMPLE_LOYALTY_CARD_AID);
                    var command = SELECT_APDU_HEADER
                        .Concat(new byte[] { aidLength })
                        .Concat(aidBytes)
                        .ToArray();

                    var result = isoDep.Transceive(command);
                    var resultLength = result.Length;
                    byte[] statusWord = { result[resultLength - 2], result[resultLength - 1] };
                    var payload = new byte[resultLength - 2];
                    Array.Copy(result, payload, resultLength - 2);
                    var arrayEquals = SELECT_OK_SW.Length == statusWord.Length;

                    if (Enumerable.SequenceEqual(SELECT_OK_SW, statusWord))
                    {
                        var msg = Encoding.UTF8.GetString(payload);
                        await App.DisplayAlertAsync(msg);
                    }
                }
                catch (Exception e)
                {
                    await App.DisplayAlertAsync("Error communicating with card: " + e.Message);
                }
            }
        }

        public static byte[] StringToByteArray(string hex) =>
            Enumerable.Range(0, hex.Length)
                .Where(x => x % 2 == 0)
                .Select(x => Convert.ToByte(hex.Substring(x, 2), 16))
                .ToArray();
    }
}

В режиме чтения nfc-адаптера при обнаружении карты будет вызван метод OnTagDiscovered. В нём IsoDep — это объект с помощью которого мы будем обмениваться с картой командами (isoDep.Transceive(command)). Команды — это массивы байт.

В коде видно, что мы отправляем эмулятору последовательность, состоящую из заголовка SELECT_APDU_HEADER, длины нашего AID в байтах и самого AID:

0 164 4 0 // SELECT_APDU_HEADER
5 // длина AID в байтах
241 35 69 103 137 // SAMPLE_LOYALTY_CARD_AID (F1 23 45 67 89)

MainActivity ридера


Здесь надо объявить поле ридера:

public CardReader cardReader;

и два вспомогательных метода:

private void EnableReaderMode()
{
    var nfc = NfcAdapter.GetDefaultAdapter(this);
    if (nfc != null) nfc.EnableReaderMode(this, cardReader, READER_FLAGS, null);
}

private void DisableReaderMode()
{
    var nfc = NfcAdapter.GetDefaultAdapter(this);
    if (nfc != null) nfc.DisableReaderMode(this);
}

в методе OnCreate() инициализируем ридер и включаем режим чтения:

protected override void OnCreate(Bundle savedInstanceState)
{
    ...
    cardReader = new CardReader();
    EnableReaderMode();
    LoadApplication(new App());
}

а также, включаем/выключаем режим чтения при сворачивании/открытии приложения:

protected override void OnPause()
{
    base.OnPause();
    DisableReaderMode();
}

protected override void OnResume()
{
    base.OnResume();
    EnableReaderMode();
}

App.xaml.cs


Статический метод для вывода сообщения:

public static async Task DisplayAlertAsync(string msg) =>
    await Device.InvokeOnMainThreadAsync(async () => await Current.MainPage.DisplayAlert("message from service", msg, "ok"));

AndroidManifest.xml


В документации android написано, что для использования nfc в своём приложении и правильной с ним работы, надо объявить эти элементы в AndroidManifest.xml:

<uses-permission android:name="android.permission.NFC" />
 
<uses-sdk android:minSdkVersion="10"/>
а лучше
<uses-sdk android:minSdkVersion="14"/>
 
<uses-feature android:name="android.hardware.nfc" android:required="true" />

При этом, если ваше приложение может использовать nfc, но это не обязательная функция, то можете пропустить элемент uses-feature и проверять доступность nfc в процессе работы.

Это всё, что касается ридера.

Приложение «Эмулятор»


Опять создаём в Visual Studio новый проект типа «Mobile App(Xamarin.Forms)» далее выбираем шаблон «Blank» и оставляем только галочку «Android» в разделе «Platforms».

В Android-проекте надо сделать следующее:

  • Класс CardService — здесь нужны константы и метод ProcessCommandApdu(), а также метод SendMessageToActivity()
  • Описание сервиса в файле aid_list.xml
  • Механизм отправки сообщений в MainActivity
  • Запуск приложения (при необходимости)
  • AndroidManifest.xml — разрешения для nfc

И в кроссплатформенном проекте в файле App.xaml.cs:

  • Метод для вывода сообщения пользователю

Класс CardService


using Android.App;
using Android.Content;
using Android.Nfc.CardEmulators;
using Android.OS;
using System;
using System.Linq;
using System.Text;

namespace ApduServiceCardApp.Droid.Services
{
    [Service(Exported = true, Enabled = true, Permission = "android.permission.BIND_NFC_SERVICE"),
       IntentFilter(new[] { "android.nfc.cardemulation.action.HOST_APDU_SERVICE" }, Categories = new[] { "android.intent.category.DEFAULT" }),
       MetaData("android.nfc.cardemulation.host_apdu_service", Resource = "@xml/aid_list")]
    public class CardService : HostApduService
    {
        // ISO-DEP command HEADER for selecting an AID.
        // Format: [Class | Instruction | Parameter 1 | Parameter 2]
        private static readonly byte[] SELECT_APDU_HEADER = new byte[] { 0x00, 0xA4, 0x04, 0x00 };

        // "OK" status word sent in response to SELECT AID command (0x9000)
        private static readonly byte[] SELECT_OK_SW = new byte[] { 0x90, 0x00 };

        // "UNKNOWN" status word sent in response to invalid APDU command (0x0000)
        private static readonly byte[] UNKNOWN_CMD_SW = new byte[] { 0x00, 0x00 };

        public override byte[] ProcessCommandApdu(byte[] commandApdu, Bundle extras)
        {
            if (commandApdu.Length >= SELECT_APDU_HEADER.Length
                && Enumerable.SequenceEqual(commandApdu.Take(SELECT_APDU_HEADER.Length), SELECT_APDU_HEADER))
            {
                var hexString = string.Join("", Array.ConvertAll(commandApdu, b => b.ToString("X2")));
                SendMessageToActivity($"Recieved message from reader: {hexString}");

                var messageToReader = "Hello Reader!";
                var messageToReaderBytes = Encoding.UTF8.GetBytes(messageToReader);
                return messageToReaderBytes.Concat(SELECT_OK_SW).ToArray();
            }

            return UNKNOWN_CMD_SW;
        }

        public override void OnDeactivated(DeactivationReason reason) { }

        private void SendMessageToActivity(string msg)
        {
            Intent intent = new Intent("MSG_NAME");
            intent.PutExtra("MSG_DATA", msg);
            SendBroadcast(intent);
        }
    }
}

При получении APDU-команды от ридера, будет вызван метод ProcessCommandApdu и в него передана команда в виде массива байтов.

Сначала проверяем, что сообщение начинается на SELECT_APDU_HEADER и если это так, составляем ответ ридеру. В реальности обмен может проходить в несколько шагов вопрос-ответ вопрос-ответ итд.

Перед классом в атрибуте Service описаны параметры сервиса android. При сборке xamarin преобразует это описание в такой элемент в AndroidManifest.xml:

<service
    name='md51c8b1c564e9c74403ac6103c28fa46ff.CardService'
    permission='android.permission.BIND_NFC_SERVICE'
    enabled='true'
    exported='true'>
  <meta-data
      name='android.nfc.cardemulation.host_apdu_service'
      resource='@res/0x7F100000'>
  </meta-data>
  <intent-filter>
    <action
        name='android.nfc.cardemulation.action.HOST_APDU_SERVICE'>
    </action>
    <category
        name='android.intent.category.DEFAULT'>
    </category>
  </intent-filter>
</service>

Описание сервиса в файле aid_list.xml


В папке xml надо создать файл aid_list.xml:

<?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:description="@string/card_title" android:category="other">
    <aid-filter android:name="F123456789"/>
  </aid-group>
</host-apdu-service>

Ссылка на него есть в атрибуте Service в классе CardService — Resource = "@xml/aid_list"
Здесь задан AID нашего приложения, по которому ридер будет к нему обращаться и атрибут requireDeviceUnlock=«false» чтобы карта считывалась при неразблокированном экране.

В коде есть 2 константы: @string/service_name и @string/card_title. Они объявляются в файле values/strings.xml:

<resources>
  <string name="card_title">My Loyalty Card</string>
  <string name="service_name">My Company</string>
</resources>

Механизм отправки сообщений:


Сервис не имеет ссылок на MainActivity, которая в момент получения APDU команды может быть и вовсе не запущена. Поэтому отправляем сообщения из CardService в MainActivity с помощью BroadcastReceiver следующим образом:

Метод для отправки сообщения из CardService:

private void SendMessageToActivity(string msg)
{
    Intent intent = new Intent("MSG_NAME");
    intent.PutExtra("MSG_DATA", msg);
    SendBroadcast(intent);
}

Получение сообщения:
Создаём класс MessageReceiver:

using Android.Content;

namespace ApduServiceCardApp.Droid.Services
{
    public class MessageReceiver : BroadcastReceiver
    {
        public override async void OnReceive(Context context, Intent intent)
        {
            var message = intent.GetStringExtra("MSG_DATA");
            await App.DisplayAlertAsync(message);
        }
    }
}

Регистрируем MessageReceiver в MainActivity:

protected override void OnCreate(Bundle savedInstanceState)
{
...
    var receiver = new MessageReceiver();
    RegisterReceiver(receiver, new IntentFilter("MSG_NAME"));
    LoadApplication(new App());
}

App.xaml.cs


Такой же как в ридере метод для вывода сообщения:

public static async Task DisplayAlertAsync(string msg) =>
    await Device.InvokeOnMainThreadAsync(async () => await Current.MainPage.DisplayAlert("message from service", msg, "ok"));

AndroidManifest.xml


    <uses-feature android:name="android.hardware.nfc.hce" android:required="true" />
    <uses-feature android:name="FEATURE_NFC_HOST_CARD_EMULATION"/>
    <uses-permission android:name="android.permission.NFC" />
    <uses-permission android:name="android.permission.BIND_NFC_SERVICE" />
 
    <uses-sdk android:minSdkVersion="10"/>
или 14
    

На данный момент у нас уже есть следующие функции:

  • выводить данные от эмулятора на экране ридера
  • выводить данные от ридера на экране эмулятора
  • эмулятор должен работать с незапущенным приложением и с выключенным экраном.

Далее.

Управление эмулятором


Настройки буду хранить с помощью Xamarin.Essentials.

Сделаем так: при перезапуске приложения эмулятора будем обновлять настройку:

Xamarin.Essentials.Preferences.Set("key1", Guid.NewGuid().ToString());

а в методе ProcessCommandApdu будем каждый раз заново брать это значение:

var messageToReader = $"Hello Reader! - {Xamarin.Essentials.Preferences.Get("key1", "key1 not found")}";

теперь при каждом перезапуске(не сворачивании) приложения эмулятора видим новый guid, например:

Hello Reader! - 76324a99-b5c3-46bc-8678-5650dab0529d

Так же через настройки включаем/выключаем эмулятор:

Xamarin.Essentials.Preferences.Set("IsEnabled", false);

а в начало метода ProcessCommandApdu добавляем:

var IsEnabled = Xamarin.Essentials.Preferences.Get("IsEnabled", false);
if (!IsEnabled) return UNKNOWN_CMD_SW; // 0x00, 0x00

Это простой способ, но есть и другие.

Запуск приложения эмулятора при обнаружении ридера


Если надо просто открыть приложение эмулятора, то в методе ProcessCommandApdu добавьте строку:

StartActivity(typeof(MainActivity));

Если необходимо передать в приложение параметры, то так:

var activity = new Intent(this, typeof(MainActivity));
intent.PutExtra("MSG_DATA", "data for application");
this.StartActivity(activity);

Прочитать переданные параметры можно в классе MainActivity в методе OnCreate:

...
LoadApplication(new App());

if (Intent.Extras != null)
{
    var message = Intent.Extras.GetString("MSG_DATA");
    await App.DisplayAlertAsync(message);
}

Проверка состояния nfc-адаптера и переход в настройки nfc


Этот раздел применим и к ридеру и к эмулятору.

Создадим в андроид-проекте NfcHelper и используем DependencyService для доступа к нему из кода страницы MainPage.

using Android.App;
using Android.Content;
using Android.Nfc;
using ApduServiceCardApp.Services;
using Xamarin.Forms;

[assembly: Dependency(typeof(ApduServiceCardApp.Droid.Services.NfcHelper))]
namespace ApduServiceCardApp.Droid.Services
{
    public class NfcHelper : INfcHelper
    {
        public NfcAdapterStatus GetNfcAdapterStatus()
        {
            var adapter = NfcAdapter.GetDefaultAdapter(Forms.Context as Activity);
            return adapter == null ? NfcAdapterStatus.NoAdapter : adapter.IsEnabled ? NfcAdapterStatus.Enabled : NfcAdapterStatus.Disabled;
        }

        public void GoToNFCSettings()
        {
            var intent = new Intent(Android.Provider.Settings.ActionNfcSettings);
            intent.AddFlags(ActivityFlags.NewTask);
            Android.App.Application.Context.StartActivity(intent);
        }
    }
}

Теперь в кроссплатформенном проекте добавим интерфейс INfcHelper:

namespace ApduServiceCardApp.Services
{
    public interface INfcHelper
    {
        NfcAdapterStatus GetNfcAdapterStatus();

        void GoToNFCSettings();
    }

    public enum NfcAdapterStatus
    {
        Enabled,
        Disabled,
        NoAdapter
    }
}

и используем всё это в коде MainPage.xaml.cs:

        protected override async void OnAppearing()
        {
            base.OnAppearing();
            await CheckNfc();
        }

        private async Task CheckNfc()
        {
            var nfcHelper = DependencyService.Get<INfcHelper>();
            var status = nfcHelper.GetNfcAdapterStatus();

            switch (status)
            {
                case NfcAdapterStatus.Enabled:
                default:
                    await App.DisplayAlertAsync("nfc enabled!");
                    break;
                case NfcAdapterStatus.Disabled:
                    nfcHelper.GoToNFCSettings();
                    break;
                case NfcAdapterStatus.NoAdapter:
                    await App.DisplayAlertAsync("no nfc adapter found!");
                    break;
            }
        }

Ссылки на GitHub


эмулятор
ридер

Комментарии (0)