В этой статье будем реализовывать так называемую Host-based Card Emulation (HCE, Эмуляция банковской карты на телефоне). В сети много подробных описаний этой технологии, здесь я сделал акцент именно на получении работающих приложений эмулятора и ридера и решении ряда практических задач. Да, понадобятся 2 устройства с nfc.
Сценариев использования очень много: система пропусков, карты лояльности, транспортные карты, получение дополнительной информации об экспонатах в музее, менеджер паролей.
При этом приложение на телефоне, эмулирующем карту, может быть запущено или нет и экран вашего телефона может быть заблокирован.
Для Xamarin Android есть готовые примеры эмулятора карты и ридера.
Попробуем с помощью этих примеров сделать 2 приложения Xamarin Forms, эмулятор и ридер, и решить в них следующие задачи:
- выводить данные от эмулятора на экране ридера
- выводить данные от ридера на экране эмулятора
- эмулятор должен работать с незапущенным приложением и заблокированным экраном
- управление настройками эмулятора
- запуск приложения эмулятора при обнаружении ридера
- проверка состояния 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
Автор: aleks42