From 4dec9247c55382789e7d58c781794a212cb19889 Mon Sep 17 00:00:00 2001
From: Timothy <6560631+TimoPtr@users.noreply.github.com>
Date: Fri, 31 Jul 2026 11:36:09 +0200
Subject: [PATCH 1/3] Add DebugNfcTagEmulator in DevPlaygound
---
app/src/debug/AndroidManifest.xml | 21 +-
.../developer/DevPlaygroundActivity.kt | 7 +
.../developer/nfc/DebugNfcTagEmulator.kt | 213 ++++++++++++++++++
.../nfc/DebugNfcTagEmulatorActivity.kt | 180 +++++++++++++++
app/src/debug/res/values/strings.xml | 1 +
.../res/xml/debug_nfc_tag_apduservice.xml | 11 +
.../companion/android/nfc/NFCUtil.kt | 21 +-
.../companion/android/nfc/NfcSetupActivity.kt | 2 +-
.../nfc/DebugNfcTagEmulatorServiceTest.kt | 118 ++++++++++
9 files changed, 568 insertions(+), 6 deletions(-)
create mode 100644 app/src/debug/kotlin/io/homeassistant/companion/android/developer/nfc/DebugNfcTagEmulator.kt
create mode 100644 app/src/debug/kotlin/io/homeassistant/companion/android/developer/nfc/DebugNfcTagEmulatorActivity.kt
create mode 100644 app/src/debug/res/xml/debug_nfc_tag_apduservice.xml
create mode 100644 app/src/testDebug/kotlin/io/homeassistant/companion/android/developer/nfc/DebugNfcTagEmulatorServiceTest.kt
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()
+ 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() ?: 0)
+ INS_UPDATE_BINARY -> updateBinary(offset, apdu)
+ else -> STATUS_INS_NOT_SUPPORTED
+ }
+ }
+
+ private fun selectApplication(apdu: ByteArray): ByteArray {
+ val length = apdu.getOrNull(4)?.toInt() ?: 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..2e5423af6a5
--- /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.IconButton
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Clear
+import androidx.compose.material3.Icon
+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..68513d8c739 100644
--- a/app/src/debug/res/values/strings.xml
+++ b/app/src/debug/res/values/strings.xml
@@ -1,4 +1,5 @@
DevPlayground
+ Debug NFC tag emulator
\ No newline at end of file
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..606bccb617b
--- /dev/null
+++ b/app/src/debug/res/xml/debug_nfc_tag_apduservice.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
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..4d4fa7671f9
--- /dev/null
+++ b/app/src/testDebug/kotlin/io/homeassistant/companion/android/developer/nfc/DebugNfcTagEmulatorServiceTest.kt
@@ -0,0 +1,118 @@
+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 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))
+ }
+}
From 5a3d56579003a97605d1eaf9d18546d54a71bbd0 Mon Sep 17 00:00:00 2001
From: Timothy <6560631+TimoPtr@users.noreply.github.com>
Date: Fri, 31 Jul 2026 12:13:52 +0200
Subject: [PATCH 2/3] Remove lint issues
---
app/src/debug/res/values/strings.xml | 6 +++---
app/src/debug/res/xml/debug_nfc_tag_apduservice.xml | 5 ++++-
2 files changed, 7 insertions(+), 4 deletions(-)
diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml
index 68513d8c739..deca554134d 100644
--- a/app/src/debug/res/values/strings.xml
+++ b/app/src/debug/res/values/strings.xml
@@ -1,5 +1,5 @@
-
+
DevPlayground
- Debug NFC tag emulator
-
\ 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
index 606bccb617b..fd505188de4 100644
--- a/app/src/debug/res/xml/debug_nfc_tag_apduservice.xml
+++ b/app/src/debug/res/xml/debug_nfc_tag_apduservice.xml
@@ -1,7 +1,10 @@
+
+ android:requireDeviceUnlock="false"
+ tools:ignore="UnusedResources">
From db297187f82c9e13c049a74782c54f9260c5c0d2 Mon Sep 17 00:00:00 2001
From: Timothy <6560631+TimoPtr@users.noreply.github.com>
Date: Fri, 31 Jul 2026 12:50:34 +0200
Subject: [PATCH 3/3] Apply copilot suggestions
---
.../developer/nfc/DebugNfcTagEmulator.kt | 10 ++++++++--
.../nfc/DebugNfcTagEmulatorActivity.kt | 2 +-
.../nfc/DebugNfcTagEmulatorServiceTest.kt | 19 +++++++++++++++++++
3 files changed, 28 insertions(+), 3 deletions(-)
diff --git a/app/src/debug/kotlin/io/homeassistant/companion/android/developer/nfc/DebugNfcTagEmulator.kt b/app/src/debug/kotlin/io/homeassistant/companion/android/developer/nfc/DebugNfcTagEmulator.kt
index 9eb953f9db3..4bd9204f74b 100644
--- a/app/src/debug/kotlin/io/homeassistant/companion/android/developer/nfc/DebugNfcTagEmulator.kt
+++ b/app/src/debug/kotlin/io/homeassistant/companion/android/developer/nfc/DebugNfcTagEmulator.kt
@@ -43,6 +43,12 @@ object DebugNfcTagEmulatorState {
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()
@@ -119,14 +125,14 @@ class DebugNfcTagEmulatorService : android.nfc.cardemulation.HostApduService() {
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() ?: 0)
+ 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() ?: return STATUS_WRONG_LENGTH
+ 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
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
index 2e5423af6a5..6a0e9f6c557 100644
--- 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
@@ -16,10 +16,10 @@ 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.IconButton
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
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
index 4d4fa7671f9..4c8e0e6b5a9 100644
--- 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
@@ -100,6 +100,25 @@ class DebugNfcTagEmulatorServiceTest {
}
}
+ @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))