Хотите добавить в своё Android-приложение функцию бесконтактной оплаты, но не знаете, как это сделать? Тогда эта статья для вас! Заодно обсудим особенности реализации. В конце будет ссылка на репозиторий с примером.

Бесконтактная оплата

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

  • SDK, который обеспечивает защищённость и криптографические операции с данными.

  • Токенизация банковской карты с помощью SDK.

  • Сервис оплаты, который зарегистрирован определённым образом (ниже рассмотрим подробнее) для обмена APDU‑командами (Application Protocol Data Units) между терминалом и SDK.

При токенизации в Android-приложении применяется обычное клиент-серверное взаимодействие, поэтому здесь мы подробнее рассмотрим только сам сервис оплаты.

Сервис оплаты

При проведении транзакции через мобильное устройство приложение общается с POS-терминалом через сервис оплаты и передаёт ID токена вместо реальных данных карты, с одним платёжным ключом на терминал. Весь процесс оплаты выглядит так:

Чтобы Android-приложение могло общаться с терминалом, нужно указать разрешение в Manifest [1]: <uses-permission android:name="android.permission.NFC" />, а затем реализовать сервис, как показано ниже:

class MyHostApduService : HostApduService() {

    override fun processCommandApdu(commandApdu: ByteArray, extras: Bundle?): ByteArray {
       ...
    }

    override fun onDeactivated(reason: Int) {
       ...
    }
}

Для начала оплаты мы ждём от терминала команду APDU. После её получения вызываем метод processCommandApdu и передаём полученные данные на обработку в SDK. Все APDU определены в спецификации ISO/IEC 7816-4, это пакеты уровня приложения, которыми обмениваются считыватель NFC и сервис HostApduService. Такой протокол является полудуплексным, то есть считыватель NFC отправляет вам команду APDU и ждёт APDU-ответ.

Когда удалённое устройство NFC хочет связаться с вашим сервисом, оно отправляет APDU SELECT AID, как определено в спецификации ISO/IEC 7816-4. AID — это идентификатор приложения, процедура его регистрации определена в спецификации ISO/IEC 7816-5. Но если вы не хотите регистрировать свой AID, то можете использовать AID в собственном диапазоне, например, 0xF00102030405.

В некоторых случаях сервису может потребоваться зарегистрировать несколько AID в одном приложении. Тогда сервис должен быть обработчиком по умолчанию для всех этих идентификаторов.

Бывают ситуации, когда другому сервису передаётся группа идентификаторов — список AID, которые Android рассматривает как связанные друг с другом: все вызовы идентификаторов из этой группы обязательно перенаправляются на один сервис.

Каждую группу AID можно связать с категорией. Это позволяет Android классифицировать сервисы, а пользователю — устанавливать значения по умолчанию на уровне категории, а не на уровне AID. Рассмотрим пример регистрации сервиса:

<service android:name=".MyHostApduService" android:exported="true"
        android:permission="android.permission.BIND_NFC_SERVICE">
    <intent-filter>
        <action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE"/>
    </intent-filter>
    <meta-data android:name="android.nfc.cardemulation.host_apdu_service"
               android:resource="@xml/apduservice"/>
</service>

В строке meta-data есть ссылка на файл, который нужен для связи каждой группы AID с категорией. Пример файла apduservice:

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

После этих шагов приложение уже будет реагировать на терминал.

Процесс обмена командами APDU между терминалом и сервисом должен быть максимально быстрым. Обсудим два варианта приложений: маленькое (из одного или нескольких модулей) и большое (многомодульное).

  • В случае одномодульного приложения обмен APDU будет быстрым, и нет большой необходимости переносить сервис в другой процесс.

  • А в большом приложении, скорее всего, понадобится переносить сервис оплаты в другой процесс, чтобы не замедлять запуск.

Правда, во втором случае может возникнуть новая проблема: если нужно синхронизировать данные, которые используются в разных процессах, то придётся синхронизировать и взаимодействие. Рассмотрим несколько способов решения.

Синхронизация данных

Допустим, вам нужно сохранить в файловом кеше какой-то примитив и позже считывать его в другом месте. Если приложение работает с одним процессом, то обычно применяют SharedPreferences. Но для работы с разными процессами этот механизм нам не подойдёт. В документации говорится:

Note: This class does not support use across multiple processes.

Первая мысль — использовать Content Provider, который идёт как обёртка вокруг SharedPreferences:

override fun insert(uri: Uri, values: ContentValues?): Uri? {
        when (matcher.match(uri)) {
            MATCH_DATA -> {
                val editor: SharedPreferences.Editor =
                    PreferenceManager.getDefaultSharedPreferences(context).edit()
                if (values?.valueSet() != null) {
                    for ((key, value) in values.valueSet()) {
                        editor.putString(key, value as? String?)
                    }
                    editor.apply()
                }
            }

            else -> throw IllegalArgumentException("Unsupported uri $uri")
        }
        return null
    }

А сам класс SharedPreferences может выглядеть так:

class MultiprocessSharedPreferences private constructor(private val context: Context) {
        fun edit(): Editor {
            return Editor(context)
        }

        fun getString(key: String?, def: String?): String? {
            val cursor = context.contentResolver.query(getContentUri(context, key, STRING_TYPE), null, null, null, null)
            return getStringValue(cursor, def)
        }

        private fun getContentUri(context: Context, key: String, type: String): Uri {
            if (BASE_URI == null) {
                init(context)
            }
            return BASE_URI.buildUpon().appendPath(key).appendPath(type).build()
        }
    }

Исходные код этого решения вы можете посмотреть здесь. Его преимущество в простоте: используются средства, которые предоставляет Android SDK. А главным недостатком является использование Content Provider-а, который работает в основном процессе приложения. Иными словами, каждый раз, когда мы общается к SharedPreferences из другого процесса, будет подниматься основной процесс приложения, что влияет на производительность (потребление памяти, CPU и т. д).

Теперь давайте рассмотрим решение на основе библиотеки Harmony. Она реализует интерфейс SharedPreferences, что позволяет легко использовать API в коде. Кроме того, для работы Harmony не требуется запуск каких-либо других процессов (Content Provider, Bound Service, AIDL и т. д.). Библиотека использует слушатель изменения файла и гарантирует, что данные map в памяти синхронизируются при каждом изменении. При этом все применяемые изменения упорядочены, так что можно одновременно вызывать изменения в нескольких процессах. Вот сравнение быстродействия нескольких библиотек:

Библиотека

Чтение

Запись

IPC

SharedPreferences

0,0006 мс

0,066 мс

N/A

Harmony

0,0008 мс

0,024 мс

102,018 мс

MMKV

0,009 мс

0,051 мс

93,628 мс

Tray

2,895 мс

8,225 мс

1,928 сек.

Библиотека MMKV использует нативный код, в Tray — решение на основе Content Provider-а. Как видите Harmony впереди по быстродействию, поэтому целесообразно использовать эту библиотеки.

Резюме

Мы обсудили:

  • Что необходимо для поддержки бесконтактной оплаты: SDK и сервис оплаты, который необходимо зарегистрировать в манифесте с указанием AID.

  • Особенности реализации сервиса оплаты в большом приложении. Для сохранения высокой скорости обмена командами APDU между терминалом и сервисом необходимо выносить сервис оплаты в другой процесс, чтобы уменьшить влияние на инициализацию приложения.

  • Проблему синхронизации данных между несколькими процессами в приложении и способы её решения. Проще всего использовать Content Provider как обёртку вокруг файловых данных. Но тогда нужно инициализировать второй процесс приложения, что приводит к низкой производительности. Самое оптимальное решение — библиотека Harmony, в основе которой лежит слушатель изменения в файле и не требуется Content Provider. Этот вариант показывает высокую скорость операций считывания и записи.

Посмотреть пример реализации бесконтактной оплаты можно по этой ссылке.

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


  1. AlexGluck
    01.12.2023 10:58

    Лучше бы рассказали как появился сервис бесконтактной оплаты на смартфоне вместо терминалов. Уже пару месяцев телефон прикладываю к телефону продавца и всё ещё в восторге. Только это кажется у другой компании.


  1. Zloy_Fey
    01.12.2023 10:58

    Есть ли примеры реализации процесса оплаты физической картой через телефон (Android c NFC), когда пользователь открывает в приложении оплату, достает с кармана физическую карту, прикладывает ее к телефону и совершает оплату без сохранения своих банковских данных?)