В этой статье будем реализовывать так называемую Host-based Card Emulation (HCE, Эмуляция банковской карты на телефоне). В сети много подробных описаний этой технологии, здесь я сделал акцент именно на получении работающих приложений эмулятора и ридера и решении ряда практических задач. Да, понадобятся 2 устройства с nfc.
Сценариев использования очень много: система пропусков, карты лояльности, транспортные карты, получение дополнительной информации об экспонатах в музее, менеджер паролей.
При этом приложение на телефоне, эмулирующем карту, может быть запущено или нет и экран вашего телефона может быть заблокирован.
Для Xamarin Android есть готовые примеры эмулятора карты и ридера.
Попробуем с помощью этих примеров сделать 2 приложения Xamarin Forms, эмулятор и ридер, и решить в них следующие задачи:
Эта статья про андроид, поэтому, если у вас приложение также и под 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».
В андроид-проекте надо сделать следующее:
И в кроссплатформенном проекте в файле App.xaml.cs:
В режиме чтения nfc-адаптера при обнаружении карты будет вызван метод OnTagDiscovered. В нём IsoDep — это объект с помощью которого мы будем обмениваться с картой командами (isoDep.Transceive(command)). Команды — это массивы байт.
В коде видно, что мы отправляем эмулятору последовательность, состоящую из заголовка SELECT_APDU_HEADER, длины нашего AID в байтах и самого AID:
Здесь надо объявить поле ридера:
и два вспомогательных метода:
в методе OnCreate() инициализируем ридер и включаем режим чтения:
а также, включаем/выключаем режим чтения при сворачивании/открытии приложения:
Статический метод для вывода сообщения:
В документации android написано, что для использования nfc в своём приложении и правильной с ним работы, надо объявить эти элементы в AndroidManifest.xml:
При этом, если ваше приложение может использовать nfc, но это не обязательная функция, то можете пропустить элемент uses-feature и проверять доступность nfc в процессе работы.
Это всё, что касается ридера.
Опять создаём в Visual Studio новый проект типа «Mobile App(Xamarin.Forms)» далее выбираем шаблон «Blank» и оставляем только галочку «Android» в разделе «Platforms».
В Android-проекте надо сделать следующее:
И в кроссплатформенном проекте в файле App.xaml.cs:
При получении APDU-команды от ридера, будет вызван метод ProcessCommandApdu и в него передана команда в виде массива байтов.
Сначала проверяем, что сообщение начинается на SELECT_APDU_HEADER и если это так, составляем ответ ридеру. В реальности обмен может проходить в несколько шагов вопрос-ответ вопрос-ответ итд.
Перед классом в атрибуте Service описаны параметры сервиса android. При сборке xamarin преобразует это описание в такой элемент в AndroidManifest.xml:
В папке xml надо создать файл aid_list.xml:
Ссылка на него есть в атрибуте Service в классе CardService — Resource = "@xml/aid_list"
Здесь задан AID нашего приложения, по которому ридер будет к нему обращаться и атрибут requireDeviceUnlock=«false» чтобы карта считывалась при неразблокированном экране.
В коде есть 2 константы:
Сервис не имеет ссылок на MainActivity, которая в момент получения APDU команды может быть и вовсе не запущена. Поэтому отправляем сообщения из CardService в MainActivity с помощью BroadcastReceiver следующим образом:
Метод для отправки сообщения из CardService:
Получение сообщения:
Создаём класс MessageReceiver:
Регистрируем MessageReceiver в MainActivity:
Такой же как в ридере метод для вывода сообщения:
На данный момент у нас уже есть следующие функции:
Далее.
Настройки буду хранить с помощью Xamarin.Essentials.
Сделаем так: при перезапуске приложения эмулятора будем обновлять настройку:
а в методе ProcessCommandApdu будем каждый раз заново брать это значение:
теперь при каждом перезапуске(не сворачивании) приложения эмулятора видим новый guid, например:
Так же через настройки включаем/выключаем эмулятор:
а в начало метода ProcessCommandApdu добавляем:
Это простой способ, но есть и другие.
Если надо просто открыть приложение эмулятора, то в методе ProcessCommandApdu добавьте строку:
Если необходимо передать в приложение параметры, то так:
Прочитать переданные параметры можно в классе MainActivity в методе OnCreate:
Этот раздел применим и к ридеру и к эмулятору.
Создадим в андроид-проекте NfcHelper и используем DependencyService для доступа к нему из кода страницы MainPage.
Теперь в кроссплатформенном проекте добавим интерфейс INfcHelper:
и используем всё это в коде MainPage.xaml.cs:
эмулятор
ридер
Сценариев использования очень много: система пропусков, карты лояльности, транспортные карты, получение дополнительной информации об экспонатах в музее, менеджер паролей.
При этом приложение на телефоне, эмулирующем карту, может быть запущено или нет и экран вашего телефона может быть заблокирован.
Для 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
эмулятор
ридер
