diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml index e7ebda6b7d0..4ddb0419f51 100644 --- a/app/src/debug/AndroidManifest.xml +++ b/app/src/debug/AndroidManifest.xml @@ -12,7 +12,26 @@ android:exported="true" /> + android:exported="false" /> + + + + + + + + + + ", rawHex = "0000")) + + /** The current tag content, for the Dev Playground. */ + val content: StateFlow = _content.asStateFlow() + + /** Replaces the tag content with what the app writes to a real tag for [tagId]. */ + @Synchronized + fun setTagId(tagId: String) { + val message = NFCUtil.createTagMessage(NFCUtil.createTagUrl(tagId)) + val bytes = message.toByteArray() + if (bytes.size > MAX_NDEF_FILE_SIZE - 2) { + Timber.w("Tag message of ${bytes.size} bytes exceeds the emulated file size") + ndefFile.fill(0) + _content.value = EmulatedTagContent(summary = "", rawHex = "0000") + return + } + ndefFile.fill(0) + ndefFile[0] = (bytes.size shr 8).toByte() + ndefFile[1] = bytes.size.toByte() + bytes.copyInto(ndefFile, destinationOffset = 2) + updateSummary() + } + + @Synchronized + fun read(offset: Int, length: Int): ByteArray = + ndefFile.copyOfRange(offset.coerceIn(0, ndefFile.size), (offset + length).coerceIn(0, ndefFile.size)) + + @Synchronized + fun write(offset: Int, data: ByteArray) { + data.copyInto(ndefFile, destinationOffset = offset) + updateSummary() + } + + private fun updateSummary() { + val messageLength = ((ndefFile[0].toInt() and 0xFF) shl 8) or (ndefFile[1].toInt() and 0xFF) + val summary = if (messageLength == 0) { + "" + } else { + try { + NdefMessage(ndefFile.copyOfRange(2, 2 + messageLength)) + .records + .joinToString { record -> record.describe() } + } catch (e: Exception) { + "" + } + } + val rawEnd = (2 + messageLength).coerceAtMost(ndefFile.size) + _content.value = EmulatedTagContent(summary = summary, rawHex = ndefFile.copyOfRange(0, rawEnd).toHex()) + } + + /** + * Decodes URI and Android application records into readable text, like the app writes. The + * application record check must come first: [NdefRecord.toUri] also matches it but renders + * it as an opaque `vnd.android.nfc://ext/` URI without the application id. + */ + private fun NdefRecord.describe(): String = when { + tnf == NdefRecord.TNF_EXTERNAL_TYPE && type.contentEquals(ANDROID_PACKAGE_RECORD_TYPE) -> + "app:${payload.decodeToString()}" + toUri() != null -> toUri().toString() + else -> toString() + } +} + +private val ANDROID_PACKAGE_RECORD_TYPE = "android.com:pkg".toByteArray() + +/** + * Emulates a writable NFC Forum Type 4 Tag through host card emulation, so another device can read + * and write a Home Assistant NFC tag against this device without physical tag hardware. + * + * The emulated content is exposed and configurable in the Dev Playground through + * [DebugNfcTagEmulatorState]. Every APDU is logged for debugging. + */ +class DebugNfcTagEmulatorService : android.nfc.cardemulation.HostApduService() { + + private var selectedFileId: Int? = null + + override fun processCommandApdu(commandApdu: ByteArray, extras: Bundle?): ByteArray { + val response = handle(commandApdu) + Timber.d("APDU ${commandApdu.toHex()} -> ${response.toHex()}") + return response + } + + private fun handle(apdu: ByteArray): ByteArray { + if (apdu.size < 4) return STATUS_WRONG_LENGTH + + val instruction = apdu[1].toInt() and 0xFF + val p1 = apdu[2].toInt() and 0xFF + val offset = ((apdu[2].toInt() and 0xFF) shl 8) or (apdu[3].toInt() and 0xFF) + + return when (instruction) { + INS_SELECT if p1 == SELECT_BY_NAME -> selectApplication(apdu) + INS_SELECT -> selectFile(apdu) + INS_READ_BINARY -> readBinary(offset, length = apdu.getOrNull(4)?.toInt()?.and(0xFF) ?: 0) + INS_UPDATE_BINARY -> updateBinary(offset, apdu) + else -> STATUS_INS_NOT_SUPPORTED + } + } + + private fun selectApplication(apdu: ByteArray): ByteArray { + val length = apdu.getOrNull(4)?.toInt()?.and(0xFF) ?: return STATUS_WRONG_LENGTH + val aid = apdu.copyOfRange(5, (5 + length).coerceAtMost(apdu.size)) + return if (aid.contentEquals(NDEF_AID)) { + selectedFileId = null + STATUS_OK + } else { + STATUS_FILE_NOT_FOUND + } + } + + private fun selectFile(apdu: ByteArray): ByteArray { + if (apdu.size < 7) return STATUS_WRONG_LENGTH + val fileId = ((apdu[5].toInt() and 0xFF) shl 8) or (apdu[6].toInt() and 0xFF) + return if (fileId == CC_FILE_ID || fileId == NDEF_FILE_ID) { + selectedFileId = fileId + STATUS_OK + } else { + STATUS_FILE_NOT_FOUND + } + } + + private fun readBinary(offset: Int, length: Int): ByteArray { + val requested = if (length == 0) MAX_APDU_DATA_SIZE else length + val data = when (selectedFileId) { + CC_FILE_ID -> CC_FILE.copyOfRange( + offset.coerceIn(0, CC_FILE.size), + (offset + requested).coerceIn(0, CC_FILE.size), + ) + NDEF_FILE_ID -> DebugNfcTagEmulatorState.read(offset, requested) + else -> return STATUS_FILE_NOT_FOUND + } + return data + STATUS_OK + } + + private fun updateBinary(offset: Int, apdu: ByteArray): ByteArray { + if (selectedFileId != NDEF_FILE_ID) return STATUS_FILE_NOT_FOUND + val length = apdu.getOrNull(4)?.toInt()?.and(0xFF) ?: return STATUS_WRONG_LENGTH + if (apdu.size < 5 + length || offset + length > DebugNfcTagEmulatorState.MAX_NDEF_FILE_SIZE) { + return STATUS_WRONG_LENGTH + } + DebugNfcTagEmulatorState.write(offset, apdu.copyOfRange(5, 5 + length)) + return STATUS_OK + } + + override fun onDeactivated(reason: Int) { + Timber.d("Deactivated: $reason") + selectedFileId = null + } + + companion object { + /** NFC Forum Type 4 Tag application identifier. */ + private val NDEF_AID = byteArrayOf(0xD2.toByte(), 0x76, 0x00, 0x00, 0x85.toByte(), 0x01, 0x01) + + private const val INS_SELECT = 0xA4 + private const val INS_READ_BINARY = 0xB0 + private const val INS_UPDATE_BINARY = 0xD6 + private const val SELECT_BY_NAME = 0x04 + + private const val CC_FILE_ID = 0xE103 + private const val NDEF_FILE_ID = 0xE104 + private const val MAX_APDU_DATA_SIZE = 0xFF + + private val STATUS_OK = byteArrayOf(0x90.toByte(), 0x00) + private val STATUS_FILE_NOT_FOUND = byteArrayOf(0x6A, 0x82.toByte()) + private val STATUS_INS_NOT_SUPPORTED = byteArrayOf(0x6D, 0x00) + private val STATUS_WRONG_LENGTH = byteArrayOf(0x67, 0x00) + + /** + * Capability container: version 2.0, MLe/MLc 255 bytes, one NDEF file (id E104) of + * [DebugNfcTagEmulatorState.MAX_NDEF_FILE_SIZE] bytes, freely readable and writable. + */ + private val CC_FILE = byteArrayOf( + 0x00, 0x0F, // CCLEN + 0x20, // mapping version 2.0 + 0x00, 0xFF.toByte(), // MLe + 0x00, 0xFF.toByte(), // MLc + 0x04, 0x06, // NDEF file control TLV + 0xE1.toByte(), 0x04, // file id + (DebugNfcTagEmulatorState.MAX_NDEF_FILE_SIZE shr 8).toByte(), + DebugNfcTagEmulatorState.MAX_NDEF_FILE_SIZE.toByte(), + 0x00, // read access without security + 0x00, // write access without security + ) + } +} diff --git a/app/src/debug/kotlin/io/homeassistant/companion/android/developer/nfc/DebugNfcTagEmulatorActivity.kt b/app/src/debug/kotlin/io/homeassistant/companion/android/developer/nfc/DebugNfcTagEmulatorActivity.kt new file mode 100644 index 00000000000..6a0e9f6c557 --- /dev/null +++ b/app/src/debug/kotlin/io/homeassistant/companion/android/developer/nfc/DebugNfcTagEmulatorActivity.kt @@ -0,0 +1,180 @@ +package io.homeassistant.companion.android.developer.nfc + +import android.content.ComponentName +import android.content.pm.PackageManager +import android.os.Bundle +import android.view.WindowManager +import androidx.activity.compose.LocalActivity +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContent +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.homeassistant.companion.android.common.compose.composable.HAFilledButton +import io.homeassistant.companion.android.common.compose.composable.HATextField +import io.homeassistant.companion.android.common.compose.composable.HATopBar +import io.homeassistant.companion.android.common.compose.theme.HADimens +import io.homeassistant.companion.android.common.compose.theme.HAFontSize +import io.homeassistant.companion.android.common.compose.theme.HATextStyle +import io.homeassistant.companion.android.common.compose.theme.HATheme +import io.homeassistant.companion.android.common.compose.theme.HAThemeForPreview +import io.homeassistant.companion.android.util.enableEdgeToEdgeCompat + +/** + * Turns this device into a writable NFC tag through [DebugNfcTagEmulatorService], so another + * device can be tested against it without physical tag hardware. + * + * The tag is only exposed while this activity is visible: the service component ships disabled + * and is enabled/disabled with the activity lifecycle. + */ +class DebugNfcTagEmulatorActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdgeCompat() + + // HCE only answers while the screen is on, keep it on while emulating + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + + setContent { + HATheme { + DebugNfcTagEmulatorScreen() + } + } + } + + override fun onStart() { + super.onStart() + setTagEmulationEnabled(true) + } + + override fun onStop() { + super.onStop() + setTagEmulationEnabled(false) + } + + private fun setTagEmulationEnabled(enabled: Boolean) { + packageManager.setComponentEnabledSetting( + ComponentName(this, DebugNfcTagEmulatorService::class.java), + if (enabled) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + } else { + PackageManager.COMPONENT_ENABLED_STATE_DISABLED + }, + PackageManager.DONT_KILL_APP, + ) + } +} + +@Composable +private fun DebugNfcTagEmulatorScreen() { + var tagId by remember { mutableStateOf("") } + val tagContent by DebugNfcTagEmulatorState.content.collectAsStateWithLifecycle() + val activity = LocalActivity.current + + Scaffold( + contentWindowInsets = WindowInsets.safeContent, + topBar = { + HATopBar( + onCloseClick = { + activity?.finish() + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(padding) + .padding(horizontal = HADimens.SPACE4) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(HADimens.SPACE2), + ) { + Text( + "Emulates a writable NFC Type 4 tag while this screen is visible. Keep this " + + "device unlocked and tap another phone against it to read or write the tag.", + style = HATextStyle.Body, + ) + HATextField( + value = tagId, + onValueChange = { tagId = it }, + label = { Text("NFC tag id to emulate") }, + modifier = Modifier.fillMaxWidth(), + trailingIcon = { + IconButton( + onClick = { + tagId = "" + }, + ) { + Icon( + imageVector = Icons.Filled.Clear, + contentDescription = "Clear tag ID", + ) + } + }, + ) + HAFilledButton( + text = "Set emulated NFC tag", + enabled = tagId.isNotBlank(), + onClick = { DebugNfcTagEmulatorState.setTagId(tagId) }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = HADimens.SPACE4), + ) + RawDataText( + "Emulated tag content", + tagContent.summary, + modifier = Modifier.padding(bottom = HADimens.SPACE4), + ) + RawDataText("Raw NDEF file", tagContent.rawHex) + } + } +} + +@Composable +private fun RawDataText(title: String, text: String, modifier: Modifier = Modifier) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(HADimens.SPACE2)) { + Text( + title, + style = HATextStyle.HeadlineMedium.copy(fontSize = HAFontSize.L), + ) + SelectionContainer { + Text( + text, + fontFamily = FontFamily.Monospace, + style = HATextStyle.BodyMedium, + textAlign = TextAlign.Start, + ) + } + } +} + +@Preview +@Composable +private fun DebugNfcTagEmulatorScreenPreview() { + HAThemeForPreview { + DebugNfcTagEmulatorScreen() + } +} diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml index db75a585ab9..deca554134d 100644 --- a/app/src/debug/res/values/strings.xml +++ b/app/src/debug/res/values/strings.xml @@ -1,4 +1,5 @@ - + DevPlayground - \ No newline at end of file + Debug NFC tag emulator + diff --git a/app/src/debug/res/xml/debug_nfc_tag_apduservice.xml b/app/src/debug/res/xml/debug_nfc_tag_apduservice.xml new file mode 100644 index 00000000000..fd505188de4 --- /dev/null +++ b/app/src/debug/res/xml/debug_nfc_tag_apduservice.xml @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/nfc/NFCUtil.kt b/app/src/main/kotlin/io/homeassistant/companion/android/nfc/NFCUtil.kt index d84260a3b37..821047fb2be 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/nfc/NFCUtil.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/nfc/NFCUtil.kt @@ -30,15 +30,28 @@ object NFCUtil { return ndefMessage?.records?.get(0)?.toUri() } - @Throws(Exception::class) - fun createNFCMessage(url: String, intent: Intent?): Boolean { + /** + * Returns the URL stored on Home Assistant NFC tags for [tagId], the reverse of + * [io.homeassistant.companion.android.util.UrlUtil.splitNfcTagId]. + */ + fun createTagUrl(tagId: String): String = "https://www.home-assistant.io/tag/$tagId" + + /** + * Returns the NDEF message written to Home Assistant NFC tags: the tag [url] plus application + * records so that scanning the tag launches the app. + */ + fun createTagMessage(url: String): NdefMessage { val nfcRecord = NdefRecord.createUri(url) val applicationRecords = BuildConfig.APPLICATION_IDS.map { NdefRecord.createApplicationRecord(it) } + return NdefMessage(arrayOf(nfcRecord) + applicationRecords) + } - val nfcMessage = NdefMessage(arrayOf(nfcRecord) + applicationRecords) - val nfcFallbackMessage = NdefMessage(arrayOf(nfcRecord)) + @Throws(Exception::class) + fun createNFCMessage(url: String, intent: Intent?): Boolean { + val nfcMessage = createTagMessage(url) + val nfcFallbackMessage = NdefMessage(arrayOf(NdefRecord.createUri(url))) intent?.let { val tag = IntentCompat.getParcelableExtra(it, NfcAdapter.EXTRA_TAG, Tag::class.java) return writeMessageToTag(nfcMessage, nfcFallbackMessage, tag) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/nfc/NfcSetupActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/nfc/NfcSetupActivity.kt index 91dd115bbce..f92c0fc8757 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/nfc/NfcSetupActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/nfc/NfcSetupActivity.kt @@ -116,7 +116,7 @@ class NfcSetupActivity : BaseActivity() { } } else { try { - val nfcTagUrl = "https://www.home-assistant.io/tag/$nfcTagToWriteUUID" + val nfcTagUrl = NFCUtil.createTagUrl(requireNotNull(nfcTagToWriteUUID)) NFCUtil.createNFCMessage(nfcTagUrl, intent) Timber.d("Wrote nfc tag with url: $nfcTagUrl") diff --git a/app/src/testDebug/kotlin/io/homeassistant/companion/android/developer/nfc/DebugNfcTagEmulatorServiceTest.kt b/app/src/testDebug/kotlin/io/homeassistant/companion/android/developer/nfc/DebugNfcTagEmulatorServiceTest.kt new file mode 100644 index 00000000000..4c8e0e6b5a9 --- /dev/null +++ b/app/src/testDebug/kotlin/io/homeassistant/companion/android/developer/nfc/DebugNfcTagEmulatorServiceTest.kt @@ -0,0 +1,137 @@ +package io.homeassistant.companion.android.developer.nfc + +import dagger.hilt.android.testing.HiltTestApplication +import io.homeassistant.companion.android.BuildConfig +import org.junit.Before +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +private const val TAG_ID = "test-tag" +private val STATUS_OK = byteArrayOf(0x90.toByte(), 0x00) +private val STATUS_FILE_NOT_FOUND = byteArrayOf(0x6A, 0x82.toByte()) + +private val SELECT_NDEF_APPLICATION = + byteArrayOf(0x00, 0xA4.toByte(), 0x04, 0x00, 0x07) + + byteArrayOf(0xD2.toByte(), 0x76, 0x00, 0x00, 0x85.toByte(), 0x01, 0x01) +private val SELECT_CC_FILE = byteArrayOf(0x00, 0xA4.toByte(), 0x00, 0x0C, 0x02, 0xE1.toByte(), 0x03) +private val SELECT_NDEF_FILE = byteArrayOf(0x00, 0xA4.toByte(), 0x00, 0x0C, 0x02, 0xE1.toByte(), 0x04) + +@RunWith(RobolectricTestRunner::class) +@Config(application = HiltTestApplication::class) +class DebugNfcTagEmulatorServiceTest { + + private val service = DebugNfcTagEmulatorService() + + @Before + fun setup() { + DebugNfcTagEmulatorState.setTagId(TAG_ID) + } + + private fun readBinary(offset: Int, length: Int) = service.processCommandApdu( + byteArrayOf(0x00, 0xB0.toByte(), (offset shr 8).toByte(), offset.toByte(), length.toByte()), + null, + ) + + private fun updateBinary(offset: Int, data: ByteArray) = service.processCommandApdu( + byteArrayOf(0x00, 0xD6.toByte(), (offset shr 8).toByte(), offset.toByte(), data.size.toByte()) + data, + null, + ) + + @Test + fun `Given NDEF application selected when reading capability container then it describes a writable NDEF file`() { + assertArrayEquals(STATUS_OK, service.processCommandApdu(SELECT_NDEF_APPLICATION, null)) + assertArrayEquals(STATUS_OK, service.processCommandApdu(SELECT_CC_FILE, null)) + + val response = readBinary(offset = 0, length = 15) + + // CCLEN, version 2.0 + assertEquals(0x00, response[0].toInt()) + assertEquals(0x0F, response[1].toInt()) + assertEquals(0x20, response[2].toInt()) + // Freely readable and writable + assertEquals(0x00, response[13].toInt()) + assertEquals(0x00, response[14].toInt()) + assertArrayEquals(STATUS_OK, response.copyOfRange(15, 17)) + } + + @Test + fun `Given NDEF file selected when reading then content contains the tag url`() { + assertArrayEquals(STATUS_OK, service.processCommandApdu(SELECT_NDEF_APPLICATION, null)) + assertArrayEquals(STATUS_OK, service.processCommandApdu(SELECT_NDEF_FILE, null)) + + val response = readBinary(offset = 0, length = 100) + + val content = response.dropLast(2).toByteArray().decodeToString() + assertTrue(content.contains("home-assistant.io/tag/$TAG_ID")) + } + + @Test + fun `Given NDEF file selected when writing then the new content is readable and reflected in the summary`() { + assertArrayEquals(STATUS_OK, service.processCommandApdu(SELECT_NDEF_APPLICATION, null)) + assertArrayEquals(STATUS_OK, service.processCommandApdu(SELECT_NDEF_FILE, null)) + + // Write a new NDEF message for another tag id like a real writer would: content then length + DebugNfcTagEmulatorState.setTagId("placeholder") + val newContent = DebugNfcTagEmulatorState.read(0, DebugNfcTagEmulatorState.MAX_NDEF_FILE_SIZE) + DebugNfcTagEmulatorState.setTagId(TAG_ID) + + val messageLength = ((newContent[0].toInt() and 0xFF) shl 8) or (newContent[1].toInt() and 0xFF) + assertArrayEquals(STATUS_OK, updateBinary(offset = 2, data = newContent.copyOfRange(2, 2 + messageLength))) + assertArrayEquals(STATUS_OK, updateBinary(offset = 0, data = newContent.copyOfRange(0, 2))) + + assertTrue(DebugNfcTagEmulatorState.content.value.summary.contains("placeholder")) + assertTrue( + DebugNfcTagEmulatorState.content.value.rawHex.startsWith(newContent.copyOfRange(0, 2).toHex()), + ) + } + + @Test + fun `Given a tag id when set then the summary lists the tag url and the application ids`() { + val summary = DebugNfcTagEmulatorState.content.value.summary + + assertTrue(summary.contains("home-assistant.io/tag/$TAG_ID")) + BuildConfig.APPLICATION_IDS.forEach { applicationId -> + assertTrue(summary.contains(applicationId)) + } + } + + @Test + fun `Given NDEF file selected when reading with a length above 127 then the length is treated as unsigned`() { + assertArrayEquals(STATUS_OK, service.processCommandApdu(SELECT_NDEF_APPLICATION, null)) + assertArrayEquals(STATUS_OK, service.processCommandApdu(SELECT_NDEF_FILE, null)) + + // 0xFF is negative as a signed byte, it must not crash or corrupt the response + val response = readBinary(offset = 0, length = 0xFF) + + assertArrayEquals(STATUS_OK, response.copyOfRange(response.size - 2, response.size)) + assertEquals(0xFF + 2, response.size) + } + + @Test + fun `Given a too long tag id when set then the content reports it instead of crashing`() { + DebugNfcTagEmulatorState.setTagId("x".repeat(DebugNfcTagEmulatorState.MAX_NDEF_FILE_SIZE)) + + assertTrue(DebugNfcTagEmulatorState.content.value.summary.contains("too large")) + } + + @Test + fun `Given unknown file when selecting then file not found is returned`() { + assertArrayEquals(STATUS_OK, service.processCommandApdu(SELECT_NDEF_APPLICATION, null)) + + val response = service.processCommandApdu(byteArrayOf(0x00, 0xA4.toByte(), 0x00, 0x0C, 0x02, 0x12, 0x34), null) + + assertArrayEquals(STATUS_FILE_NOT_FOUND, response) + } + + @Test + fun `Given no file selected when reading then file not found is returned`() { + assertArrayEquals(STATUS_OK, service.processCommandApdu(SELECT_NDEF_APPLICATION, null)) + + assertArrayEquals(STATUS_FILE_NOT_FOUND, readBinary(offset = 0, length = 10)) + } +}