Здравствуйте! В этой статье мы рассмотрим, как реализовать NFC в приложении для Android, читая и записывая данные в метки с помощью формата NDEF. Мы продемонстрируем это на примере реализации простой системы предоплаты, похожей на системы общественного транспорта, где метки, обычно в формате карт, можно заряжать и использовать для проезда. Android-устройство будет выступать в роли платежного терминала, а метки будут хранить баланс и список приобретенных товаров.
В примерах мы рассмотрим только интеграцию с NFC и структуру передаваемых данных. Полный исходный код, включая реализацию пользовательского интерфейса и функции, доступен в репозитории Clevent на GitHub.
Прежде чем мы перейдем к коду, давайте быстро узнаем некоторые основные понятия о NFC.
NFC
NFC (Near-field Communication) — это технология, которая позволяет обмениваться данными на коротком расстоянии без проводов, обычно на расстоянии до 4 см, между подключаемым устройством, например Android-устройством, и NFC-меткой. Эта технология может применяться во многих контекстах, таких как авторизация, платежи или автоматизация в целом.
Android SDK имеет обширные инструменты для работы с NFC, позволяющие считывать и записывать данные на метки и даже заставлять устройство действовать как метка.
Метки
Метки NFC — это небольшие карты памяти, которые встраиваются в различные форм-факторы, такие как стикеры, карты или браслеты. Они оснащены чипом, способным хранить и передавать данные на другие устройства без встроенного источника питания. Вся энергия, необходимая для питания чипа, поступает от магнитного поля, создаваемого при движении подключаемого устройства.
Метки имеют различные технические характеристики, такие как объем памяти, время хранения данных и поддержка шифрования. Они также могут поддерживать различные протоколы для обмена и структурирования данных, такие как NFC-A, NFC-B и NFC-F. Каждый из них имеет свои преимущества и недостатки в зависимости от сферы применения.
NDEF
NFC Forum, организация, создающая спецификации для технологии NFC, разработала формат NDEF (NFC Data Exchange Format). Этот формат абстрагирует сложность взаимодействия с различными протоколами меток стандартизированным способом. Этот формат достаточно гибок, чтобы структурировать данные в известных форматах, таких как обычный текст, медиа и URI, или создать свой собственный тип данных.
Сообщения (Messages) и записи (Records) — это основные понятия, с которыми мы должны быть знакомы при работе с форматом NDEF.
Сообщение NDEF — это пакет данных, который можно передавать между устройствами. Каждая NDEF метка может хранить до одного сообщения NDEF.
Внутри сообщения находится одна или несколько записей NDEF. Записи — это фрагменты информации, которые мы хотим передать. Короче говоря, если мы хотим передать список ссылок на сайты в NDEF, мы можем создать сообщение, состоящее из нескольких записей URI.
Каждая запись будет иметь тип, полезную нагрузку в виде данных и, опционально, идентификатор. Тип выражается двумя свойствами: TNF и Type.
- TNF (Type Name Format) — это постоянное значение, которое определяет, как интерпретировать данные. Примерами TNF являются
TNF_WELL_KNOWN
,TNF_MIME_MEDIA
иTNF_EXTERNAL_TYPE
для данных, представленных в каком-либо специфическом для приложения формате; - Type используется для определения типа записи. Некоторые примеры
RTD_TEXT
иRTD_URI
.
Запись, хранящая обычные текстовые данные, будет использовать TNF_WELL_KNOWN
с RTD_TEXT
, а запись, хранящая URI, будет использовать TNF_WELL_KNOWN
с RTD_URI
. Обычно нам не нужно напрямую работать с TNF и Types для создания записей, потому что в SDK есть несколько методов, которые помогают нам создавать более сложные записи и избегать ошибок. Мы рассмотрим некоторые из этих методов в этом посте.
Система диспетчеризации меток
Когда устройство Android попадает в поле действия NFC-метки, ОС считывает ее содержимое и создает Интент с этими данными. Этот Интент используется для запуска Активити, которые фильтруют его с помощью Фильтров Интентов. Система, отвечающая за обнаружение и диспетчеризацию меток, называется Tag Dispatch System.
Приложения могут создавать фильтры интентов для меток с определенным типом контента, например JSON или любого медиаконтента. Однако, поскольку NDEF-сообщение может содержать несколько записей разных типов, система диспетчеризации меток всегда использует только первую запись для классификации типа всего сообщения. Категоризация осуществляется путем сопоставления ранее упомянутых свойств TNF и Type.
Интенты NFC могут иметь три различных действия в зависимости от данных, содержащихся в метке:
ACTION_NDEF_DISCOVERED
: Когда метка содержит данные в формате NDEF и известный тип;ACTION_TECH_DISCOVERED
: Когда метка не содержит данных в формате NDEF или они не могут быть сопоставлены с известными типами;ACTION_TAG_DISCOVERED
: Резервный вариант, когда ни одна Активити не регистрирует Фильтр Интентов ни для одного из Интентов.
Мы можем добавить Фильтры Интентов для этих типов Интентов в манифест Android, чтобы запустить Активити при обнаружении NFC-метки. Однако мы не будем рассматривать этот вариант дальше, поскольку новые версии Android имеют определенные ограничения на открытие Акивити, если приложение находится в фоновом режиме. Чтобы избежать этих ограничений, мы будем использовать Foreground Dispatch System.
Система диспетчеризации на переднем плане
Когда Активити находится на переднем плане, она может использовать Foreground Dispatch System, которая работает аналогично Tag Dispatch System, но дает приоритет Активити получать все интенты NFC без возможности интерпретации их другими приложениями. Давайте рассмотрим пошаговое руководство по включению этой функции в Активити.
Первый шаг — объявить разрешение android.permission.NFC в файле манифеста приложения:
<uses-permission android:name="android.permission.NFC" />
Это разрешение обычного типа, поэтому нам не нужно запрашивать его у пользователя во время выполнения, так как оно будет предоставлено при установке приложения.
Далее необходимо инициализировать два объекта в Activity: экземпляр NfcAdapter
, который является классом, позволяющим нам запустить систему диспетчеризации переднего плана, и PendingIntent
, который будет использоваться для получения NFC-интентов в Активити:
import android.app.PendingIntent import android.nfc.NfcAdapter class MainActivity : ComponentActivity() { private var mNfcAdapter: NfcAdapter? = null private var mPendingIntent: PendingIntent? = null override fun onCreate(savedInstanceState: Bundle?) { // ... this.mNfcAdapter = NfcAdapter.getDefaultAdapter(this) ?: return // If the device doesn't have NFC support, null will be returned val intent = Intent(this, javaClass).apply { addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) } this.mPendingIntent = PendingIntent.getActivity( /* context = */ this, /* requestCode = */ 0, /* intent = */ intent, /* flags = */ PendingIntent.FLAG_MUTABLE, ) } }
Когда экземпляр NfcAdapter
готов, мы можем использовать методы enableForegroundDispatch
и disableForegroundDispatch
для управления системой диспетчеризации переднего плана.
Метод enableForegroundDispatch
должен вызываться из главного потока и только тогда, когда Активити находится на переднем плане, поэтому мы будем вызывать его в onResume
:
import android.nfc.tech.Ndef override fun onResume() { super.onResume() // Intents we want to filter val filters = arrayOf( IntentFilter(NfcAdapter.ACTION_NDEF_DISCOVERED), IntentFilter(NfcAdapter.ACTION_TECH_DISCOVERED), ) // An array of tag technologies we can handle. Here we will only handle NDEF. val techList = arrayOf(Ndef::class.java.name) mNfcAdapter?.enableForegroundDispatch( /* activity = */ this, /* intent = */ mPendingIntent, /* filters = */ filters, /* techLists = */ arrayOf(techList), ) }
Нам нужно остановить диспетчеризацию переднего плана, когда Активити покидает передний план — мы можем сделать это в onPause
:
public override fun onPause() { super.onPause() mNfcAdapter?.disableForegroundDispatch(this) }
Теперь нам нужно реализовать метод onNewIntent
, который будет вызываться при обнаружении NFC-метки:
override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) Toast.makeText(this, "Tag scanned!", Toast.LENGTH_SHORT).show() }
Если запустить приложение и приблизить устройство к NFC-метке, на экране появится тост:
Считывание данных с метки
Теперь, когда мы можем успешно прослушивать NFC Интенты в приложении, мы можем извлекать из них информацию.
Интенты NFC могут содержать следующие дополнительные данные:
EXTRA_TAG
: объект Tag, представляющий отсканированную метку. Этот объект позволяет нам выполнять различные операции с меткой. Это дополнение всегда присутствует в любом NFC-интенте;EXTRA_NDEF_MESSAGES
: Массив со всеми NDEF-сообщениями, записанными в отсканированной метке. Эти данные всегда присутствует в намеренияхNDEF_DISCOVERED
и необязательно в остальных. Когда это дополнение присутствует, в нем всегда будет хотя бы одно NDEF-сообщение. Обычно NDEF-метки хранят только одно сообщение, но Android реализует его в виде массива для будущей совместимости;EXTRA_ID
: необязательный идентификатор метки.
Давайте воспользуемся этими дополнительными параметрами, чтобы узнать, какие технологии поддерживает сканируемая метка и сколько NDEF-сообщений в ней записано, с помощью следующего кода в методе onNewIntent
:
import android.nfc.NfcAdapter import android.nfc.Tag override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) // Obtain tag from intent extra val tag: Tag? = getTagFromIntent(intent) // Print tag's supported technologies val availableTechnologies = tag?.techList?.joinToString() Log.d("NFC", "Technologies available in tag: $availableTechnologies") // Print the number of NDEF Messages stored in the tag val messages = intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES) Log.d("NFC", "NDEF Messages on tag: ${messages?.size}") } private fun getTagFromIntent(intent: Intent): Tag? { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { @Suppress("DEPRECATION") return intent.getParcelableExtra(NfcAdapter.EXTRA_TAG) } return intent.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java) }
Запустив приложение и прочитав отформатированную метку модели NTAG-216, мы получим следующий результат в Logcat:
Technologies available in tag: android.nfc.tech.NfcA, android.nfc.tech.MifareUltralight, android.nfc.tech.Ndef NDEF Messages on tag: null
Эта метка поддерживает три технологии: NfaA, Mifare Ultralight и NDEF. Дополнительный параметр EXTRA_NDEF_MESSAGES
отсутствует, так как в метке не записано ни одно NDEF-сообщение.
Следующий шаг — начать записывать данные в метку, но сначала нам нужно подумать о том, как структурировать наши данные.
Моделирование данных
Мы уже знаем, что наши данные должны быть структурированы в NDEF-сообщении с помощью одной или нескольких записей, и эти записи могут быть разных типов, но какой тип мы должны использовать?
Если мы создаем приложение, которое будет обмениваться данными, не требующими понимания другими приложениями, как в нашем случае, мы можем использовать тип External. Этот формат позволяет хранить массивы байтов, интерпретация которых нужна только нашему приложению.
В нашем приложении клиент должен иметь две важные данные: баланс и список приобретенных товаров. Мы можем смоделировать это с помощью двух классов:
import java.io.Serializable class Customer( var balance: Int = 0, products: Map<Product, Int> = emptyMap(), ) : Serializable { private val products: MutableMap<Product, Int> = products.toMutableMap() } data class Product( val id: Int, val name: String, val price: Int, ) : Serializable
Баланс типизирован как целое число, поскольку это простой способ хранения денежных значений. Значение выражается в наименьшей денежной единице, например центе, что позволяет избежать проблем с округлением при выполнении операций. Map хранит количество купленных продуктов.
С помощью этой структуры мы можем представить клиента, у которого на балансе 80 евро и который купил три товара: два попкорна и одну газировку:
val soda = Product(id = 1, name = "Soda", price = 200) val popcorn = Product(id = 2, name = "Popcorn", price = 150) val customer = Customer( balance = 8000, products = mapOf(soda to 1, popcorn to 2), )
Затем мы можем добавить в эти классы любые методы, которые помогут нам реализовать все варианты использования, например, методы addProduct
и addToBalance
:
class Customer() { // ... fun addProduct(product: Product, quantity: Int) { val total = product.price * quantity addToBalance(-total) this.products[product] = this.products[product]?.plus(quantity) ?: quantity } fun addToBalance(value: Int) { val newBalance: Long = balance.toLong() + value if (newBalance !in 0..Int.MAX_VALUE) { throw Exception("Balance boundaries exceeded.") } balance = newBalance.toInt() } }
Сериализация данных и создание NDEF-сообщения
Теперь нам нужно преобразовать экземпляр Customer во внешнюю NDEF-запись и поместить ее в NDEF-сообщение.
Первым шагом будет преобразование экземпляра Customer в байтовый массив. Этот процесс называется сериализацией и может быть выполнен различными способами, в зависимости от сложности объекта и его потребностей. В данном примере мы воспользуемся наиболее удобным способом, который заключается в использовании встроенных функций языка, позволяющих сериализовать классы, реализующие интерфейс java.io.Serializable
.
Сериализация объекта Customer в байтовый массив:
import java.io.ByteArrayOutputStream import java.io.ObjectOutputStream fun serializeCustomer(customer: Customer): ByteArray { val outputStream = ByteArrayOutputStream() ObjectOutputStream(outputStream).use { it.writeObject(customer) } return outputStream.toByteArray() }
Десериализация байтов обратно в экземпляр Customer:
import java.io.ByteArrayInputStream import java.io.ObjectInputStream fun deserializeCustomer(data: ByteArray): Customer { val inputStream = ByteArrayInputStream(data) val objectInputStream = ObjectInputStream(inputStream) return objectInputStream.readObject() as Customer }
Хотя для нашего примера этот метод вполне работоспособен, имейте в виду, что он неэффективен. Сгенерированный массив байтов будет неоправданно большим и займет слишком много места в метке, которая обычно имеет очень ограниченную емкость.
Вместо того чтобы сериализовать весь объект со всеми его свойствами, лучше сериализовать только те данные, которые имеют значение, если они позволяют впоследствии восстановить объект без потерь. Для этого можно использовать форматы вроде Protobuf или написать собственный сериализатор, как в этом примере, который сериализует только идентификаторы и количество предметов, поскольку название предметов можно получить из локальной базы данных по идентификатору:
Сериализовав объект Customer
, мы можем использовать статический метод createExternal
, один из тех вспомогательных методов, которые доступны для создания записей:
import android.nfc.NdefRecord override fun onNewIntent(intent: Intent) { // ... val serializedCustomer = serializeCustomer(customer) val customerRecord = NdefRecord.createExternal( /* domain = */ "com.myapplication", /* type = */ "customer", /* data = */ serializedCustomer, ) }
domain
используется для идентификации эмитента записи. Как правило, это имя пакета приложения;type
определяет тип данных, которые будут храниться. В нашем случае тип будет customer;data
— это полезная нагрузка записи, в которую будет помещен сериализованный объект Customer.
Под капотом метод createExternal
создаст запись с TNF как TNF_EXTERNAL_TYPE
, а Type будет представлять собой конкатенацию параметров domain и type, разделенных двоеточием: com.myapplication:customer
.
Создав запись, нам нужно поместить ее в сообщение, и этот процесс очень прост, нам нужно только передать запись в конструктор класса NdefMessage
.
import android.nfc.NdefMessage val ndefMessage = NdefMessage(customerRecord)
Запись данных в метку
Для записи данных в NDEF метки нам нужен экземпляр класса android.nfc.tech.Ndef
. Мы можем получить этот экземпляр с помощью статического метода get
, передав в качестве параметра объект метки:
import android.nfc.tech.Ndef val tag = getTagFromIntent(intent) val ndef = Ndef.get(tag)
В приведенном ниже списке показаны основные операции, которые мы можем выполнить с помощью класса Ndef. Некоторые из этих методов могут вызывать радиочастотную активность, что означает, что Android будет взаимодействовать с меткой при вызове метода. Эти методы должны выполняться вне основного потока, так как они будут блокировать его до завершения операции.
connect()
: Открывает соединение с меткой для выполнения операций ввода-вывода. Может вызвать радиочастотную активность.isConnected()
: Возвращает true, если соединение с меткой открыто и готово к приему команд ввода/вывода. Не вызывает радиочастотной активности.getMaxSize()
: Возвращает максимальный размер в байтах, который может иметь NDEF-сообщение, чтобы поместиться в метку. Не вызывает радиочастотной активности.getNdefMessage()
: Возвращает NDEF-сообщение, записанное в метке в данный момент. Может вызывать радиочастотную активность.getCachedNdefMessage()
: Возвращает NDEF-сообщение, записанное в метке, когда она была отсканирована. Это значение не будет обновлено, если содержимое было изменено позже. Не вызывает радиочастотной активности.writeNdefMessage()
: Записывает NDEF-сообщение в метку, перезаписывая все существующие. Может вызвать радиочастотную активность.close()
: Закрывает соединение с меткой, отменяя все операции, которые еще открыты. Не вызывает радиочастотной активности.
Для записи сообщения в метку мы будем использовать три метода из этого списка: connect()
, writeNdefMessage()
и close()
:
lifecycleScope.launch(Dispatchers.IO) { ndef.connect() ndef.writeNdefMessage(ndefMessage) ndef.close() Log.d("NFC", "${ndefMessage.byteArrayLength} bytes message successfully written") }
Поскольку методы connect()
и writeNdefMessage()
являются блокирующими, мы использовали корутины, чтобы избежать слишком большой нагрузки на главный поток, но вы можете использовать любую другую технику по своему усмотрению.
При запуске приложения и сканировании метки в Logcat будет выведен следующий результат:
484 bytes message successfully written
Собираем все вместе
Теперь, когда мы знаем, как получать Интенты NFC и выполнять операции чтения и записи, мы объединим все это, чтобы реализовать функцию пополнения баланса. Мы будем извлекать экземпляр клиента из метки, добавлять баланс, а затем переписывать его обратно в метку.
Давайте начнем с создания нескольких функций расширения, которые помогут нам манипулировать данными, начиная с одной, которая позволяет нам подтвердить, что данные, записанные в метке, являются экземпляром Customer:
private fun NdefRecord.isCustomer(): Boolean { return this.type contentEquals "com.myapplication:customer".toByteArray() }
Эта извлекает данные из метки и десериализует их обратно в экземпляр Customer:
private fun Ndef.getCustomer(): Customer { val ndefMessage = this.cachedNdefMessage val ndefRecord = ndefMessage?.records?.first() if (ndefRecord == null || !ndefRecord.isCustomer()) { return Customer() } return deserializeCustomer(ndefRecord.payload) }
Наконец, последняя сериализует Customer и записывает его в метку:
private suspend fun Ndef.writeCustomer(customer: Customer) { val customerRecord = NdefRecord.createExternal( /* domain = */ "com.myapplication", /* type = */ "customer", /* data = */ serializeCustomer(customer), ) val ndefMessage = NdefMessage(customerRecord) withContext(Dispatchers.IO) { connect() writeNdefMessage(ndefMessage) close() } }
Полный пример будет выглядеть следующим образом:
override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) val tag = getTagFromIntent(intent) val ndef = Ndef.get(tag) val customer = ndef.getCustomer() customer.addToBalance(10000) lifecycleScope.launch(Dispatchers.IO) { ndef.writeCustomer(customer) Log.d("NFC", "Tag balance is ${customer.balance}") } }
Запустив приложение, мы видим, что баланс увеличивается каждый раз, когда мы считываем метку:
Tag balance is 10000 Tag balance is 20000 Tag balance is 30000
Android Application Records
Начиная с 14-го уровня API появился новый тип записей под названием Android Application Record (AAR), который позволяет нам определить, какое приложение будет открыто при считывании метки. Если приложение не установлено, Google Play автоматически откроет страницу приложения.
Чтобы создать AAR, мы можем использовать вспомогательный метод createApplicationRecord:
val aarRecord = NdefRecord.createApplicationRecord(PACKAGE_NAME)
Чтобы создать сообщение, содержащее как запись с Customer, так и AAR:
val ndefMessage = NdefMessage( NdefRecord.createExternal(DOMAIN, TYPE, serializedCustomer), NdefRecord.createApplicationRecord(PACKAGE_NAME) )
Размещение AAR в качестве второй записи важно потому, что Tag Dispatch System всегда использует первую запись для классификации типа сообщения. AAR должна быть первой записью только в том случае, если сообщение имеет только одну запись.
Заключение
В этой статье мы рассмотрели NFC, метки и формат NDEF. Мы также рассмотрели, как Android взаимодействует с метками, классифицирует данные и запускает интенты, которые приложения могут получать и использовать для выполнения операций чтения и записи на метках.
Полный исходный код, включая все функции, реализованные с дополнительными уровнями безопасности, такими как шифрование данных и проверка целостности с помощью контрольных сумм, можно найти в репозитории GitHub.